From ce58ceec7b79452bbf783283fbec6e72ecfabad8 Mon Sep 17 00:00:00 2001 From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Date: Tue, 4 Aug 2026 17:32:02 -0400 Subject: [PATCH] Require exact consent cookie domain --- docs/servers/auth/oauth-proxy.mdx | 12 ++-- docs/servers/auth/oidc-proxy.mdx | 4 +- docs/v2/servers/auth/oauth-proxy.mdx | 12 ++-- docs/v2/servers/auth/oidc-proxy.mdx | 4 +- docs/v3/servers/auth/oauth-proxy.mdx | 12 ++-- docs/v3/servers/auth/oidc-proxy.mdx | 4 +- .../server/auth/oauth_proxy/__init__.py | 3 +- .../server/auth/oauth_proxy/consent.py | 5 +- .../fastmcp/server/auth/oauth_proxy/proxy.py | 46 +++++++------ .../fastmcp/server/auth/oidc_proxy.py | 16 ++--- .../fastmcp/server/auth/providers/azure.py | 18 ++--- tests/server/auth/providers/test_azure.py | 5 +- tests/server/auth/test_oauth_consent_page.py | 68 ++++++++++++------- tests/server/auth/test_oidc_proxy.py | 5 +- 14 files changed, 121 insertions(+), 93 deletions(-) diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx index 193993c15..c8bf9516b 100644 --- a/docs/servers/auth/oauth-proxy.mdx +++ b/docs/servers/auth/oauth-proxy.mdx @@ -342,23 +342,23 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) - - Controls the browser-enforced scope of the signed cookies used by FastMCP's consent flow. + + Sets an explicit domain for the signed cookies used by FastMCP's consent flow. By default, FastMCP uses `__Host-` cookies on HTTPS, which browsers restrict to the exact host. - - `"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. + Set this only when a hosting layer, such as Azure Functions, adds a `Domain` attribute to response cookies. The value must exactly match the hostname in `base_url`; FastMCP rejects parent domains. Configuring it uses `__Secure-` cookies and preserves signing, `Secure`, `HttpOnly`, and `SameSite=Lax`. ```python from fastmcp.server.auth.providers.azure import AzureProvider auth = AzureProvider( ..., - consent_cookie_policy="domain-compatible", + base_url="https://my-app.azurewebsites.net", + consent_cookie_domain="my-app.azurewebsites.net", ) ``` - 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. + Prefer the host-only default. A domain cookie is also sent to child subdomains, so only enable this when those hosts are trusted and the deployment platform makes `__Host-` cookies unusable. diff --git a/docs/servers/auth/oidc-proxy.mdx b/docs/servers/auth/oidc-proxy.mdx index aa8072fd2..a1b62f52e 100644 --- a/docs/servers/auth/oidc-proxy.mdx +++ b/docs/servers/auth/oidc-proxy.mdx @@ -209,8 +209,8 @@ 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. - - 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. + + Explicit domain for consent cookies. By default, FastMCP uses host-only `__Host-` cookies on HTTPS. Set this only for hosting layers that add a `Domain` attribute; the value must exactly match the hostname in `base_url`. See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for the security trade-off. diff --git a/docs/v2/servers/auth/oauth-proxy.mdx b/docs/v2/servers/auth/oauth-proxy.mdx index 56caf5c85..5daef663e 100644 --- a/docs/v2/servers/auth/oauth-proxy.mdx +++ b/docs/v2/servers/auth/oauth-proxy.mdx @@ -323,23 +323,23 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) - - Controls the browser-enforced scope of the signed cookies used by FastMCP's consent flow. + + Sets an explicit domain for the signed cookies used by FastMCP's consent flow. By default, FastMCP uses `__Host-` cookies on HTTPS, which browsers restrict to the exact host. - - `"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. + Set this only when a hosting layer, such as Azure Functions, adds a `Domain` attribute to response cookies. The value must exactly match the hostname in `base_url`; FastMCP rejects parent domains. Configuring it uses `__Secure-` cookies and preserves signing, `Secure`, `HttpOnly`, and `SameSite=Lax`. ```python from fastmcp.server.auth.providers.azure import AzureProvider auth = AzureProvider( ..., - consent_cookie_policy="domain-compatible", + base_url="https://my-app.azurewebsites.net", + consent_cookie_domain="my-app.azurewebsites.net", ) ``` - 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. + Prefer the host-only default. A domain cookie is also sent to child subdomains, so only enable this when those hosts are trusted and the deployment platform makes `__Host-` cookies unusable. diff --git a/docs/v2/servers/auth/oidc-proxy.mdx b/docs/v2/servers/auth/oidc-proxy.mdx index e1071180a..c90efc171 100644 --- a/docs/v2/servers/auth/oidc-proxy.mdx +++ b/docs/v2/servers/auth/oidc-proxy.mdx @@ -202,8 +202,8 @@ 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. - - 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. + + Explicit domain for consent cookies. By default, FastMCP uses host-only `__Host-` cookies on HTTPS. Set this only for hosting layers that add a `Domain` attribute; the value must exactly match the hostname in `base_url`. See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for the security trade-off. diff --git a/docs/v3/servers/auth/oauth-proxy.mdx b/docs/v3/servers/auth/oauth-proxy.mdx index 4fbed28e1..d7227b961 100644 --- a/docs/v3/servers/auth/oauth-proxy.mdx +++ b/docs/v3/servers/auth/oauth-proxy.mdx @@ -337,23 +337,23 @@ auth = OAuthProxy(..., client_storage=MemoryStore()) - - Controls the browser-enforced scope of the signed cookies used by FastMCP's consent flow. + + Sets an explicit domain for the signed cookies used by FastMCP's consent flow. By default, FastMCP uses `__Host-` cookies on HTTPS, which browsers restrict to the exact host. - - `"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. + Set this only when a hosting layer, such as Azure Functions, adds a `Domain` attribute to response cookies. The value must exactly match the hostname in `base_url`; FastMCP rejects parent domains. Configuring it uses `__Secure-` cookies and preserves signing, `Secure`, `HttpOnly`, and `SameSite=Lax`. ```python from fastmcp.server.auth.providers.azure import AzureProvider auth = AzureProvider( ..., - consent_cookie_policy="domain-compatible", + base_url="https://my-app.azurewebsites.net", + consent_cookie_domain="my-app.azurewebsites.net", ) ``` - 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. + Prefer the host-only default. A domain cookie is also sent to child subdomains, so only enable this when those hosts are trusted and the deployment platform makes `__Host-` cookies unusable. diff --git a/docs/v3/servers/auth/oidc-proxy.mdx b/docs/v3/servers/auth/oidc-proxy.mdx index c44ee2f20..8993b5e3b 100644 --- a/docs/v3/servers/auth/oidc-proxy.mdx +++ b/docs/v3/servers/auth/oidc-proxy.mdx @@ -202,8 +202,8 @@ 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. - - 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. + + Explicit domain for consent cookies. By default, FastMCP uses host-only `__Host-` cookies on HTTPS. Set this only for hosting layers that add a `Domain` attribute; the value must exactly match the hostname in `base_url`. See the [OAuthProxy documentation](/servers/auth/oauth-proxy) for the security trade-off. diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/__init__.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/__init__.py index 58e0b9f05..5e9ff4315 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/__init__.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/__init__.py @@ -7,9 +7,8 @@ This package provides OAuth proxy functionality split across multiple modules: - proxy: Main OAuthProxy class """ -from fastmcp.server.auth.oauth_proxy.proxy import ConsentCookiePolicy, OAuthProxy +from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy __all__ = [ - "ConsentCookiePolicy", "OAuthProxy", ] diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py index 3b7f65c70..21b37414a 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/consent.py @@ -76,7 +76,7 @@ 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": + if self._consent_cookie_domain is not None: return f"__Secure-{base_name}" return f"__Host-{base_name}" return f"__{base_name}" @@ -178,6 +178,7 @@ class ConsentMixin: httponly=True, samesite="lax", path="/", + domain=self._consent_cookie_domain, ) def _read_consent_bindings(self: OAuthProxy, request: Request) -> dict[str, str]: @@ -220,6 +221,7 @@ class ConsentMixin: httponly=True, samesite="lax", path="/", + domain=self._consent_cookie_domain, ) return payload_bytes = json.dumps(bindings, separators=(",", ":")).encode() @@ -233,6 +235,7 @@ class ConsentMixin: httponly=True, samesite="lax", path="/", + domain=self._consent_cookie_domain, ) def _set_consent_binding_cookie( diff --git a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py index afe5611ec..985f057fb 100644 --- a/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oauth_proxy/proxy.py @@ -119,8 +119,6 @@ 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 @@ -334,7 +332,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_cookie_domain: str | None = None, consent_csp_policy: str | None = None, # Token expiry fallback fallback_access_token_expiry_seconds: int | None = None, @@ -406,13 +404,14 @@ 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_cookie_domain: Explicit domain for consent cookies. By default, + FastMCP uses host-only ``__Host-`` cookies on HTTPS. Set this only + when a hosting layer adds a ``Domain`` attribute to response cookies. + The value must exactly match the hostname in ``base_url``; parent + domains are rejected. Configuring a domain uses ``__Secure-`` cookies + and allows them to reach child subdomains, weakening browser-enforced + host isolation. 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). @@ -527,17 +526,26 @@ 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_cookie_domain: str | None = None + if consent_cookie_domain is not None: + assert self.base_url is not None + assert self.base_url.host is not None + configured_domain = consent_cookie_domain.lower().rstrip(".") + base_host = self.base_url.host.lower().rstrip(".") + if configured_domain != base_host: + raise ValueError( + "consent_cookie_domain must exactly match the hostname in base_url" + ) + if self.base_url.scheme != "https": + raise ValueError("consent_cookie_domain requires an HTTPS base_url") + self._consent_cookie_domain = configured_domain self._consent_csp_policy: str | None = consent_csp_policy - if consent_cookie_policy == "domain-compatible": + if self._consent_cookie_domain is not None: logger.warning( - "Domain-compatible consent cookies use __Secure- instead of " - "__Host-; this permits Domain rewrites but weakens browser-enforced " - "host isolation." + "Consent cookies are scoped to %s and use __Secure- instead of " + "__Host-. Child subdomains can receive or replace these cookies; " + "only use this setting when those hosts are trusted.", + self._consent_cookie_domain, ) if require_authorization_consent == "external": logger.info( diff --git a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py index 783436c37..69413a8fd 100644 --- a/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py +++ b/fastmcp_slim/fastmcp/server/auth/oidc_proxy.py @@ -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 ConsentCookiePolicy, OAuthProxy +from fastmcp.server.auth.oauth_proxy import 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,7 +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_cookie_domain: str | None = None, consent_csp_policy: str | None = None, forward_resource: bool = True, # Extra parameters @@ -311,11 +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_cookie_domain: Explicit domain for consent cookies. By default, + FastMCP uses host-only ``__Host-`` cookies on HTTPS. Set this only + for hosting layers that add a ``Domain`` attribute. The value must + exactly match the hostname in ``base_url`` and causes ``__Secure-`` + cookies to be used, allowing child subdomains to receive them. 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). @@ -436,7 +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_cookie_domain": consent_cookie_domain, "consent_csp_policy": consent_csp_policy, "forward_resource": forward_resource, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, diff --git a/fastmcp_slim/fastmcp/server/auth/providers/azure.py b/fastmcp_slim/fastmcp/server/auth/providers/azure.py index 74fe9a08f..b38acf9ec 100644 --- a/fastmcp_slim/fastmcp/server/auth/providers/azure.py +++ b/fastmcp_slim/fastmcp/server/auth/providers/azure.py @@ -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 ConsentCookiePolicy, OAuthProxy +from fastmcp.server.auth.oauth_proxy import 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,7 +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_cookie_domain: str | None = None, consent_csp_policy: str | None = None, forward_resource: bool = True, fallback_refresh_token_expiry_seconds: int | None = None, @@ -178,12 +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. + consent_cookie_domain: Explicit domain for consent cookies. By default, + FastMCP uses host-only ``__Host-`` cookies on HTTPS. Set this only + for hosting layers such as Azure Functions that add a ``Domain`` + attribute. The value must exactly match the hostname in ``base_url`` + and causes ``__Secure-`` cookies to be used, allowing child + subdomains to receive them. 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. @@ -280,7 +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_cookie_domain=consent_cookie_domain, consent_csp_policy=consent_csp_policy, forward_resource=forward_resource, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, diff --git a/tests/server/auth/providers/test_azure.py b/tests/server/auth/providers/test_azure.py index 4bbcdb29d..981b4f3e0 100644 --- a/tests/server/auth/providers/test_azure.py +++ b/tests/server/auth/providers/test_azure.py @@ -66,7 +66,7 @@ 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): + def test_explicit_consent_cookie_domain(self, memory_storage: MemoryStore): provider = AzureProvider( client_id="test_client", client_secret="test_secret", @@ -75,12 +75,13 @@ class TestAzureProvider: required_scopes=["read"], jwt_signing_key="test-secret", client_storage=memory_storage, - consent_cookie_policy="domain-compatible", + consent_cookie_domain="myserver.com", ) assert ( provider._cookie_name("MCP_CONSENT_STATE") == "__Secure-MCP_CONSENT_STATE" ) + assert provider._consent_cookie_domain == "myserver.com" def test_offline_access_automatically_included(self, memory_storage: MemoryStore): """Test that offline_access is automatically added to get refresh tokens.""" diff --git a/tests/server/auth/test_oauth_consent_page.py b/tests/server/auth/test_oauth_consent_page.py index f7019906d..2f092eff8 100644 --- a/tests/server/auth/test_oauth_consent_page.py +++ b/tests/server/auth/test_oauth_consent_page.py @@ -65,8 +65,8 @@ def oauth_proxy_https_remember(): @pytest.fixture -def oauth_proxy_https_domain_compatible(): - """OAuthProxy using __Secure- cookies for platforms that add Domain.""" +def oauth_proxy_https_with_cookie_domain(): + """OAuthProxy using an explicit cookie domain for platform compatibility.""" return OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", upstream_token_endpoint="https://github.com/login/oauth/access_token", @@ -76,7 +76,7 @@ def oauth_proxy_https_domain_compatible(): base_url="https://myserver.example", client_storage=MemoryStore(), jwt_signing_key="test-secret", - consent_cookie_policy="domain-compatible", + consent_cookie_domain="myserver.example", ) @@ -116,11 +116,11 @@ def _extract_csrf(html: str) -> str | None: return m.group(1) if m else None -class TestConsentCookiePolicy: - def test_invalid_policy_is_rejected(self): +class TestConsentCookieDomain: + def test_parent_domain_is_rejected(self): with pytest.raises( ValueError, - match="consent_cookie_policy must be 'host-only' or 'domain-compatible'", + match="consent_cookie_domain must exactly match the hostname in base_url", ): OAuthProxy( upstream_authorization_endpoint="https://github.com/login/oauth/authorize", @@ -131,20 +131,36 @@ class TestConsentCookiePolicy: base_url="https://myserver.example", client_storage=MemoryStore(), jwt_signing_key="test-secret", - consent_cookie_policy="invalid", # ty: ignore[invalid-argument-type] + consent_cookie_domain="example", ) - async def test_domain_compatible_policy_completes_consent_with_secure_cookies( - self, oauth_proxy_https_domain_compatible + def test_domain_requires_https(self): + with pytest.raises( + ValueError, match="consent_cookie_domain requires an HTTPS base_url" + ): + 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="http://myserver.example", + client_storage=MemoryStore(), + jwt_signing_key="test-secret", + consent_cookie_domain="myserver.example", + ) + + async def test_explicit_domain_completes_consent_with_secure_cookies( + self, oauth_proxy_https_with_cookie_domain ): txn_id, _ = await _start_flow( - oauth_proxy_https_domain_compatible, - "domain-compatible-client", + oauth_proxy_https_with_cookie_domain, + "explicit-domain-client", "http://localhost:6000/callback", ) - app = Starlette(routes=oauth_proxy_https_domain_compatible.get_routes()) + app = Starlette(routes=oauth_proxy_https_with_cookie_domain.get_routes()) - with TestClient(app) as client: + with TestClient(app, base_url="https://myserver.example") as client: consent = client.get(f"/consent?txn_id={txn_id}") csrf = _extract_csrf(consent.text) assert csrf @@ -154,9 +170,8 @@ class TestConsentCookiePolicy: assert "Secure" in set_cookie assert "HttpOnly" in set_cookie assert "SameSite=lax" in set_cookie + assert "Domain=myserver.example" in set_cookie - for key, value in consent.cookies.items(): - client.cookies.set(key, value) response = client.post( "/consent", data={ @@ -168,25 +183,27 @@ class TestConsentCookiePolicy: ) assert response.status_code in (302, 303) - assert "__Secure-MCP_CONSENT_BINDING=" in response.headers.get("set-cookie", "") + binding_cookie = response.headers.get("set-cookie", "") + assert "__Secure-MCP_CONSENT_BINDING=" in binding_cookie + assert "Domain=myserver.example" in binding_cookie - async def test_domain_compatible_policy_does_not_accept_host_prefix( - self, oauth_proxy_https_domain_compatible + async def test_explicit_domain_does_not_accept_host_prefix( + self, oauth_proxy_https_with_cookie_domain ): txn_id, _ = await _start_flow( - oauth_proxy_https_domain_compatible, + oauth_proxy_https_with_cookie_domain, "wrong-prefix-client", "http://localhost:6001/callback", ) - app = Starlette(routes=oauth_proxy_https_domain_compatible.get_routes()) + app = Starlette(routes=oauth_proxy_https_with_cookie_domain.get_routes()) - with TestClient(app) as browser: + with TestClient(app, base_url="https://myserver.example") 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: + with TestClient(app, base_url="https://myserver.example") as browser: browser.cookies.set("__Host-MCP_CONSENT_STATE", signed_state) response = browser.post( "/consent", @@ -200,9 +217,7 @@ class TestConsentCookiePolicy: assert response.status_code == 403 - async def test_default_policy_does_not_accept_secure_prefix( - self, oauth_proxy_https - ): + async def test_default_does_not_accept_secure_prefix(self, oauth_proxy_https): txn_id, _ = await _start_flow( oauth_proxy_https, "default-policy-client", @@ -230,11 +245,12 @@ class TestConsentCookiePolicy: assert response.status_code == 403 - def test_default_policy_remains_host_only(self, oauth_proxy_https): + def test_default_remains_host_only(self, oauth_proxy_https): assert ( oauth_proxy_https._cookie_name("MCP_CONSENT_STATE") == "__Host-MCP_CONSENT_STATE" ) + assert oauth_proxy_https._consent_cookie_domain is None class TestConsentPageServerIcon: diff --git a/tests/server/auth/test_oidc_proxy.py b/tests/server/auth/test_oidc_proxy.py index c10ec990c..2bad5140c 100644 --- a/tests/server/auth/test_oidc_proxy.py +++ b/tests/server/auth/test_oidc_proxy.py @@ -489,7 +489,7 @@ 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): + def test_explicit_consent_cookie_domain(self, valid_oidc_configuration_dict): with patch( "fastmcp.server.auth.oidc_proxy.OIDCConfiguration.get_oidc_configuration" ) as mock_get: @@ -504,13 +504,14 @@ class TestOIDCProxyInitialization: client_secret=TEST_CLIENT_SECRET, base_url=TEST_BASE_URL, jwt_signing_key="test-secret", - consent_cookie_policy="domain-compatible", + consent_cookie_domain="example.com", ) validate_proxy(mock_get, proxy, oidc_config) assert ( proxy._cookie_name("MCP_CONSENT_STATE") == "__Secure-MCP_CONSENT_STATE" ) + assert proxy._consent_cookie_domain == "example.com" def test_token_verifier_initialization(self, valid_oidc_configuration_dict): """Test token verifier initialization."""