Add domain-compatible consent cookies

This commit is contained in:
Jeremiah Lowin 2026-08-04 14:28:06 -04:00
commit eb804d26e3
No known key found for this signature in database
14 changed files with 294 additions and 9 deletions

View file

@ -342,6 +342,26 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
</Warning>
</ParamField>
<ParamField body="consent_cookie_policy" type='Literal["host-only", "domain-compatible"]' default='"host-only"'>
Controls the browser-enforced scope of the signed cookies used by FastMCP's consent flow.
- `"host-only"` (default) uses the `__Host-` prefix on HTTPS. This is the strongest policy because browsers reject a `Domain` attribute and prevent sibling subdomains from setting the cookie.
- `"domain-compatible"` uses the `__Secure-` prefix on HTTPS. Use this only when a hosting layer, such as Azure Functions, adds a `Domain` attribute to response cookies. Cookie signing, `Secure`, `HttpOnly`, and `SameSite=Lax` remain enabled, but browser-enforced host isolation is lost.
```python
from fastmcp.server.auth.providers.azure import AzureProvider
auth = AzureProvider(
...,
consent_cookie_policy="domain-compatible",
)
```
<Warning>
Prefer the default `"host-only"` policy. The compatibility policy allows consent cookies to be scoped to sibling subdomains and should only be enabled when the deployment platform makes `__Host-` cookies unusable.
</Warning>
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page.

View file

@ -209,6 +209,10 @@ auth = OIDCProxy(
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
</ParamField>
<ParamField body="consent_cookie_policy" type='Literal["host-only", "domain-compatible"]' default='"host-only"'>
Consent cookie scope policy. The default `"host-only"` uses `__Host-` cookies on HTTPS. `"domain-compatible"` uses `__Secure-` cookies for hosting layers that add a `Domain` attribute, weakening browser-enforced host isolation while retaining signed, `Secure`, `HttpOnly`, `SameSite=Lax` cookies. See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for the security trade-off.
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page.

View file

@ -323,6 +323,26 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
</Warning>
</ParamField>
<ParamField body="consent_cookie_policy" type='Literal["host-only", "domain-compatible"]' default='"host-only"'>
Controls the browser-enforced scope of the signed cookies used by FastMCP's consent flow.
- `"host-only"` (default) uses the `__Host-` prefix on HTTPS. This is the strongest policy because browsers reject a `Domain` attribute and prevent sibling subdomains from setting the cookie.
- `"domain-compatible"` uses the `__Secure-` prefix on HTTPS. Use this only when a hosting layer, such as Azure Functions, adds a `Domain` attribute to response cookies. Cookie signing, `Secure`, `HttpOnly`, and `SameSite=Lax` remain enabled, but browser-enforced host isolation is lost.
```python
from fastmcp.server.auth.providers.azure import AzureProvider
auth = AzureProvider(
...,
consent_cookie_policy="domain-compatible",
)
```
<Warning>
Prefer the default `"host-only"` policy. The compatibility policy allows consent cookies to be scoped to sibling subdomains and should only be enabled when the deployment platform makes `__Host-` cookies unusable.
</Warning>
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page.

View file

@ -202,6 +202,10 @@ auth = OIDCProxy(
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/v2/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
</ParamField>
<ParamField body="consent_cookie_policy" type='Literal["host-only", "domain-compatible"]' default='"host-only"'>
Consent cookie scope policy. The default `"host-only"` uses `__Host-` cookies on HTTPS. `"domain-compatible"` uses `__Secure-` cookies for hosting layers that add a `Domain` attribute, weakening browser-enforced host isolation while retaining signed, `Secure`, `HttpOnly`, `SameSite=Lax` cookies. See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for the security trade-off.
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page.

View file

@ -337,6 +337,26 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
</Warning>
</ParamField>
<ParamField body="consent_cookie_policy" type='Literal["host-only", "domain-compatible"]' default='"host-only"'>
Controls the browser-enforced scope of the signed cookies used by FastMCP's consent flow.
- `"host-only"` (default) uses the `__Host-` prefix on HTTPS. This is the strongest policy because browsers reject a `Domain` attribute and prevent sibling subdomains from setting the cookie.
- `"domain-compatible"` uses the `__Secure-` prefix on HTTPS. Use this only when a hosting layer, such as Azure Functions, adds a `Domain` attribute to response cookies. Cookie signing, `Secure`, `HttpOnly`, and `SameSite=Lax` remain enabled, but browser-enforced host isolation is lost.
```python
from fastmcp.server.auth.providers.azure import AzureProvider
auth = AzureProvider(
...,
consent_cookie_policy="domain-compatible",
)
```
<Warning>
Prefer the default `"host-only"` policy. The compatibility policy allows consent cookies to be scoped to sibling subdomains and should only be enabled when the deployment platform makes `__Host-` cookies unusable.
</Warning>
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page.

View file

@ -202,6 +202,10 @@ auth = OIDCProxy(
Consent screen behavior for authorization requests. Accepts `True` (default; always prompt — strongest protection), `"remember"` (silent consent on return visits via signed cookie, gated by `Sec-Fetch-Site` to block AS-in-the-middle attacks), `"external"` (same authorization path as `False`, but the warning is suppressed because the operator asserts that equivalent protections are enforced externally), or `False` (disable entirely; local/testing only). See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for full details on each mode and the security trade-offs.
</ParamField>
<ParamField body="consent_cookie_policy" type='Literal["host-only", "domain-compatible"]' default='"host-only"'>
Consent cookie scope policy. The default `"host-only"` uses `__Host-` cookies on HTTPS. `"domain-compatible"` uses `__Secure-` cookies for hosting layers that add a `Domain` attribute, weakening browser-enforced host isolation while retaining signed, `Secure`, `HttpOnly`, `SameSite=Lax` cookies. See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for the security trade-off.
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page.

View file

@ -7,8 +7,9 @@ This package provides OAuth proxy functionality split across multiple modules:
- proxy: Main OAuthProxy class
"""
from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy.proxy import ConsentCookiePolicy, OAuthProxy
__all__ = [
"ConsentCookiePolicy",
"OAuthProxy",
]

View file

@ -76,6 +76,8 @@ class ConsentMixin:
def _cookie_name(self: OAuthProxy, base_name: str) -> str:
"""Return secure cookie name for HTTPS, fallback for HTTP development."""
if self._is_https:
if self._consent_cookie_policy == "domain-compatible":
return f"__Secure-{base_name}"
return f"__Host-{base_name}"
return f"__{base_name}"
@ -129,9 +131,8 @@ class ConsentMixin:
"""Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid."""
secure_name = self._cookie_name(base_name)
raw = request.cookies.get(secure_name)
# Only fall back to the non-__Host- name over plain HTTP. On HTTPS,
# __Host- enforces host-only scope; accepting the weaker name would
# let a sibling-subdomain attacker inject a domain-scoped cookie.
# Only fall back to the unprefixed development name over plain HTTP.
# On HTTPS, accept exactly the prefix selected by the configured policy.
if not raw and not self._is_https:
raw = request.cookies.get(f"__{base_name}")
if not raw:
@ -186,9 +187,8 @@ class ConsentMixin:
"""
cookie_name = self._cookie_name("MCP_CONSENT_BINDING")
raw = request.cookies.get(cookie_name)
# Only fall back to the non-__Host- name over plain HTTP. On HTTPS,
# __Host- enforces host-only scope; accepting the weaker name would
# bypass that guarantee.
# Only fall back to the unprefixed development name over plain HTTP.
# On HTTPS, accept exactly the prefix selected by the configured policy.
if not raw and not self._is_https:
raw = request.cookies.get("__MCP_CONSENT_BINDING")
if not raw:

View file

@ -119,6 +119,8 @@ from fastmcp.utilities.logging import get_logger
logger = get_logger(__name__)
ConsentCookiePolicy = Literal["host-only", "domain-compatible"]
_REFRESH_LOCK_CACHE_SIZE = 10_000
#: SEP-837: the client's declared `application_type`, recovered from the raw DCR
@ -332,6 +334,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
jwt_signing_key: str | bytes | None = None,
# Consent screen configuration
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_cookie_policy: ConsentCookiePolicy = "host-only",
consent_csp_policy: str | None = None,
# Token expiry fallback
fallback_access_token_expiry_seconds: int | None = None,
@ -403,6 +406,13 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
FastMCP does not provide or verify those external protections.
- False: skip consent entirely. SECURITY WARNING: only set to
False for local development or testing environments.
consent_cookie_policy: Cookie scope policy for the consent flow.
``"host-only"`` (default) uses ``__Host-`` cookies on HTTPS,
which browsers reject if a hosting layer adds a ``Domain``
attribute. ``"domain-compatible"`` uses ``__Secure-`` cookies
instead so those rewrites remain valid, at the cost of allowing
the cookies to be scoped to sibling subdomains. Cookie signing,
``Secure``, ``HttpOnly``, and ``SameSite=Lax`` remain enabled.
consent_csp_policy: Content Security Policy for the consent page.
If None (default), uses the built-in CSP policy with appropriate directives.
If empty string "", disables CSP entirely (no meta tag is rendered).
@ -517,7 +527,18 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
self._require_authorization_consent: bool | Literal["remember", "external"] = (
require_authorization_consent
)
if consent_cookie_policy not in ("host-only", "domain-compatible"):
raise ValueError(
"consent_cookie_policy must be 'host-only' or 'domain-compatible'"
)
self._consent_cookie_policy: ConsentCookiePolicy = consent_cookie_policy
self._consent_csp_policy: str | None = consent_csp_policy
if consent_cookie_policy == "domain-compatible":
logger.warning(
"Domain-compatible consent cookies use __Secure- instead of "
"__Host-; this permits Domain rewrites but weakens browser-enforced "
"host isolation."
)
if require_authorization_consent == "external":
logger.info(
"Built-in consent screen disabled; consent is handled externally."

View file

@ -19,7 +19,7 @@ from typing_extensions import Self
from fastmcp.server.auth import TokenVerifier
from fastmcp.server.auth.identity_assertion import IdentityAssertion
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy import ConsentCookiePolicy, OAuthProxy
from fastmcp.server.auth.oauth_proxy.models import UpstreamTokenSet
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.logging import get_logger
@ -234,6 +234,7 @@ class OIDCProxy(OAuthProxy):
token_endpoint_auth_method: str | None = None,
# Consent screen configuration
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_cookie_policy: ConsentCookiePolicy = "host-only",
consent_csp_policy: str | None = None,
forward_resource: bool = True,
# Extra parameters
@ -310,6 +311,11 @@ class OIDCProxy(OAuthProxy):
but the warning is suppressed as an operator acknowledgment that
equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
consent_cookie_policy: Cookie scope policy for the consent flow.
``"host-only"`` (default) uses ``__Host-`` cookies on HTTPS.
``"domain-compatible"`` uses ``__Secure-`` cookies for hosting
layers that add a ``Domain`` attribute, weakening browser-enforced
host isolation while retaining signed, secure cookies.
consent_csp_policy: Content Security Policy for the consent page.
If None (default), uses the built-in CSP policy with appropriate directives.
If empty string "", disables CSP entirely (no meta tag is rendered).
@ -430,6 +436,7 @@ class OIDCProxy(OAuthProxy):
"jwt_signing_key": jwt_signing_key,
"token_endpoint_auth_method": token_endpoint_auth_method,
"require_authorization_consent": require_authorization_consent,
"consent_cookie_policy": consent_cookie_policy,
"consent_csp_policy": consent_csp_policy,
"forward_resource": forward_resource,
"fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds,

View file

@ -15,7 +15,7 @@ from key_value.aio.protocols import AsyncKeyValue
from fastmcp.dependencies import Dependency
from fastmcp.server.auth.auth import MultiAuth
from fastmcp.server.auth.oauth_proxy import OAuthProxy
from fastmcp.server.auth.oauth_proxy import ConsentCookiePolicy, OAuthProxy
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes
from fastmcp.utilities.logging import get_logger
@ -113,6 +113,7 @@ class AzureProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_cookie_policy: ConsentCookiePolicy = "host-only",
consent_csp_policy: str | None = None,
forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None,
@ -177,6 +178,12 @@ class AzureProvider(OAuthProxy):
but the warning is suppressed as an operator acknowledgment that
equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments.
consent_cookie_policy: Cookie scope policy for the consent flow.
``"host-only"`` (default) uses ``__Host-`` cookies on HTTPS.
``"domain-compatible"`` uses ``__Secure-`` cookies for hosting
layers such as Azure Functions that add a ``Domain`` attribute,
weakening browser-enforced host isolation while retaining signed,
secure cookies.
http_client: Optional httpx2.AsyncClient for connection pooling in JWKS fetches.
When provided, the client is reused for JWT key fetches and the caller
is responsible for its lifecycle. When None (default), a fresh client is created per fetch.
@ -273,6 +280,7 @@ class AzureProvider(OAuthProxy):
client_storage=client_storage,
jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent,
consent_cookie_policy=consent_cookie_policy,
consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,

View file

@ -66,6 +66,22 @@ class TestAzureProvider:
assert provider._redirect_path == "/auth/callback"
# Azure provider defaults are set but we can't easily verify them without accessing internals
def test_domain_compatible_consent_cookies(self, memory_storage: MemoryStore):
provider = AzureProvider(
client_id="test_client",
client_secret="test_secret",
tenant_id="test-tenant",
base_url="https://myserver.com",
required_scopes=["read"],
jwt_signing_key="test-secret",
client_storage=memory_storage,
consent_cookie_policy="domain-compatible",
)
assert (
provider._cookie_name("MCP_CONSENT_STATE") == "__Secure-MCP_CONSENT_STATE"
)
def test_offline_access_automatically_included(self, memory_storage: MemoryStore):
"""Test that offline_access is automatically added to get refresh tokens."""
# Without specifying offline_access

View file

@ -64,6 +64,22 @@ def oauth_proxy_https_remember():
)
@pytest.fixture
def oauth_proxy_https_domain_compatible():
"""OAuthProxy using __Secure- cookies for platforms that add Domain."""
return OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="client-id",
upstream_client_secret="client-secret",
token_verifier=_Verifier(),
base_url="https://myserver.example",
client_storage=MemoryStore(),
jwt_signing_key="test-secret",
consent_cookie_policy="domain-compatible",
)
async def _start_flow(
proxy: OAuthProxy, client_id: str, redirect: str
) -> tuple[str, str]:
@ -100,6 +116,127 @@ def _extract_csrf(html: str) -> str | None:
return m.group(1) if m else None
class TestConsentCookiePolicy:
def test_invalid_policy_is_rejected(self):
with pytest.raises(
ValueError,
match="consent_cookie_policy must be 'host-only' or 'domain-compatible'",
):
OAuthProxy(
upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
upstream_token_endpoint="https://github.com/login/oauth/access_token",
upstream_client_id="client-id",
upstream_client_secret="client-secret",
token_verifier=_Verifier(),
base_url="https://myserver.example",
client_storage=MemoryStore(),
jwt_signing_key="test-secret",
consent_cookie_policy="invalid", # ty: ignore[invalid-argument-type]
)
async def test_domain_compatible_policy_completes_consent_with_secure_cookies(
self, oauth_proxy_https_domain_compatible
):
txn_id, _ = await _start_flow(
oauth_proxy_https_domain_compatible,
"domain-compatible-client",
"http://localhost:6000/callback",
)
app = Starlette(routes=oauth_proxy_https_domain_compatible.get_routes())
with TestClient(app) as client:
consent = client.get(f"/consent?txn_id={txn_id}")
csrf = _extract_csrf(consent.text)
assert csrf
set_cookie = consent.headers.get("set-cookie", "")
assert "__Secure-MCP_CONSENT_STATE=" in set_cookie
assert "__Host-" not in set_cookie
assert "Secure" in set_cookie
assert "HttpOnly" in set_cookie
assert "SameSite=lax" in set_cookie
for key, value in consent.cookies.items():
client.cookies.set(key, value)
response = client.post(
"/consent",
data={
"action": "approve",
"txn_id": txn_id,
"csrf_token": csrf,
},
follow_redirects=False,
)
assert response.status_code in (302, 303)
assert "__Secure-MCP_CONSENT_BINDING=" in response.headers.get("set-cookie", "")
async def test_domain_compatible_policy_does_not_accept_host_prefix(
self, oauth_proxy_https_domain_compatible
):
txn_id, _ = await _start_flow(
oauth_proxy_https_domain_compatible,
"wrong-prefix-client",
"http://localhost:6001/callback",
)
app = Starlette(routes=oauth_proxy_https_domain_compatible.get_routes())
with TestClient(app) as browser:
consent = browser.get(f"/consent?txn_id={txn_id}")
csrf = _extract_csrf(consent.text)
assert csrf
signed_state = consent.cookies["__Secure-MCP_CONSENT_STATE"]
with TestClient(app) as browser:
browser.cookies.set("__Host-MCP_CONSENT_STATE", signed_state)
response = browser.post(
"/consent",
data={
"action": "approve",
"txn_id": txn_id,
"csrf_token": csrf,
},
follow_redirects=False,
)
assert response.status_code == 403
async def test_default_policy_does_not_accept_secure_prefix(
self, oauth_proxy_https
):
txn_id, _ = await _start_flow(
oauth_proxy_https,
"default-policy-client",
"http://localhost:6002/callback",
)
app = Starlette(routes=oauth_proxy_https.get_routes())
with TestClient(app) as browser:
consent = browser.get(f"/consent?txn_id={txn_id}")
csrf = _extract_csrf(consent.text)
assert csrf
signed_state = consent.cookies["__Host-MCP_CONSENT_STATE"]
with TestClient(app) as browser:
browser.cookies.set("__Secure-MCP_CONSENT_STATE", signed_state)
response = browser.post(
"/consent",
data={
"action": "approve",
"txn_id": txn_id,
"csrf_token": csrf,
},
follow_redirects=False,
)
assert response.status_code == 403
def test_default_policy_remains_host_only(self, oauth_proxy_https):
assert (
oauth_proxy_https._cookie_name("MCP_CONSENT_STATE")
== "__Host-MCP_CONSENT_STATE"
)
class TestConsentPageServerIcon:
"""Tests for server icon display in OAuth consent screen."""

View file

@ -489,6 +489,29 @@ class TestOIDCProxyInitialization:
call_args = mock_get.call_args
assert call_args[1]["timeout_seconds"] == 12
def test_domain_compatible_consent_cookies(self, valid_oidc_configuration_dict):
with patch(
"fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration"
) as mock_get:
oidc_config = OIDCConfiguration.model_validate(
valid_oidc_configuration_dict
)
mock_get.return_value = oidc_config
proxy = OIDCProxy(
config_url=TEST_CONFIG_URL,
client_id=TEST_CLIENT_ID,
client_secret=TEST_CLIENT_SECRET,
base_url=TEST_BASE_URL,
jwt_signing_key="test-secret",
consent_cookie_policy="domain-compatible",
)
validate_proxy(mock_get, proxy, oidc_config)
assert (
proxy._cookie_name("MCP_CONSENT_STATE") == "__Secure-MCP_CONSENT_STATE"
)
def test_token_verifier_initialization(self, valid_oidc_configuration_dict):
"""Test token verifier initialization."""
with patch(