Compare commits

...

2 commits

Author SHA1 Message Date
Jeremiah Lowin
ce58ceec7b
Require exact consent cookie domain 2026-08-04 17:32:09 -04:00
Jeremiah Lowin
eb804d26e3
Add domain-compatible consent cookies 2026-08-04 17:32:09 -04:00
13 changed files with 319 additions and 6 deletions

View file

@ -342,6 +342,26 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
</Warning> </Warning>
</ParamField> </ParamField>
<ParamField body="consent_cookie_domain" type="str | None" default="None">
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.
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(
...,
base_url="https://my-app.azurewebsites.net",
consent_cookie_domain="my-app.azurewebsites.net",
)
```
<Warning>
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.
</Warning>
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None"> <ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page. 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. 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>
<ParamField body="consent_cookie_domain" type="str | None" default="None">
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.
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None"> <ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page. Content Security Policy for the consent page.

View file

@ -323,6 +323,26 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
</Warning> </Warning>
</ParamField> </ParamField>
<ParamField body="consent_cookie_domain" type="str | None" default="None">
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.
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(
...,
base_url="https://my-app.azurewebsites.net",
consent_cookie_domain="my-app.azurewebsites.net",
)
```
<Warning>
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.
</Warning>
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None"> <ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page. 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. 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>
<ParamField body="consent_cookie_domain" type="str | None" default="None">
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.
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None"> <ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page. Content Security Policy for the consent page.

View file

@ -337,6 +337,26 @@ auth = OAuthProxy(..., client_storage=MemoryStore())
</Warning> </Warning>
</ParamField> </ParamField>
<ParamField body="consent_cookie_domain" type="str | None" default="None">
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.
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(
...,
base_url="https://my-app.azurewebsites.net",
consent_cookie_domain="my-app.azurewebsites.net",
)
```
<Warning>
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.
</Warning>
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None"> <ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page. 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. 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>
<ParamField body="consent_cookie_domain" type="str | None" default="None">
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.
</ParamField>
<ParamField body="consent_csp_policy" type="str | None" default="None"> <ParamField body="consent_csp_policy" type="str | None" default="None">
Content Security Policy for the consent page. Content Security Policy for the consent page.

View file

@ -76,6 +76,8 @@ class ConsentMixin:
def _cookie_name(self: OAuthProxy, base_name: str) -> str: def _cookie_name(self: OAuthProxy, base_name: str) -> str:
"""Return secure cookie name for HTTPS, fallback for HTTP development.""" """Return secure cookie name for HTTPS, fallback for HTTP development."""
if self._is_https: if self._is_https:
if self._consent_cookie_domain is not None:
return f"__Secure-{base_name}"
return f"__Host-{base_name}" return f"__Host-{base_name}"
return f"__{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.""" """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid."""
secure_name = self._cookie_name(base_name) secure_name = self._cookie_name(base_name)
raw = request.cookies.get(secure_name) raw = request.cookies.get(secure_name)
# Only fall back to the non-__Host- name over plain HTTP. On HTTPS, # Only fall back to the unprefixed development name over plain HTTP.
# __Host- enforces host-only scope; accepting the weaker name would # On HTTPS, accept exactly the prefix selected by the configured policy.
# let a sibling-subdomain attacker inject a domain-scoped cookie.
if not raw and not self._is_https: if not raw and not self._is_https:
raw = request.cookies.get(f"__{base_name}") raw = request.cookies.get(f"__{base_name}")
if not raw: if not raw:
@ -177,6 +178,7 @@ class ConsentMixin:
httponly=True, httponly=True,
samesite="lax", samesite="lax",
path="/", path="/",
domain=self._consent_cookie_domain,
) )
def _read_consent_bindings(self: OAuthProxy, request: Request) -> dict[str, str]: def _read_consent_bindings(self: OAuthProxy, request: Request) -> dict[str, str]:
@ -186,9 +188,8 @@ class ConsentMixin:
""" """
cookie_name = self._cookie_name("MCP_CONSENT_BINDING") cookie_name = self._cookie_name("MCP_CONSENT_BINDING")
raw = request.cookies.get(cookie_name) raw = request.cookies.get(cookie_name)
# Only fall back to the non-__Host- name over plain HTTP. On HTTPS, # Only fall back to the unprefixed development name over plain HTTP.
# __Host- enforces host-only scope; accepting the weaker name would # On HTTPS, accept exactly the prefix selected by the configured policy.
# bypass that guarantee.
if not raw and not self._is_https: if not raw and not self._is_https:
raw = request.cookies.get("__MCP_CONSENT_BINDING") raw = request.cookies.get("__MCP_CONSENT_BINDING")
if not raw: if not raw:
@ -220,6 +221,7 @@ class ConsentMixin:
httponly=True, httponly=True,
samesite="lax", samesite="lax",
path="/", path="/",
domain=self._consent_cookie_domain,
) )
return return
payload_bytes = json.dumps(bindings, separators=(",", ":")).encode() payload_bytes = json.dumps(bindings, separators=(",", ":")).encode()
@ -233,6 +235,7 @@ class ConsentMixin:
httponly=True, httponly=True,
samesite="lax", samesite="lax",
path="/", path="/",
domain=self._consent_cookie_domain,
) )
def _set_consent_binding_cookie( def _set_consent_binding_cookie(

View file

@ -332,6 +332,7 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
jwt_signing_key: str | bytes | None = None, jwt_signing_key: str | bytes | None = None,
# Consent screen configuration # Consent screen configuration
require_authorization_consent: bool | Literal["remember", "external"] = True, require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_cookie_domain: str | None = None,
consent_csp_policy: str | None = None, consent_csp_policy: str | None = None,
# Token expiry fallback # Token expiry fallback
fallback_access_token_expiry_seconds: int | None = None, fallback_access_token_expiry_seconds: int | None = None,
@ -403,6 +404,14 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
FastMCP does not provide or verify those external protections. FastMCP does not provide or verify those external protections.
- False: skip consent entirely. SECURITY WARNING: only set to - False: skip consent entirely. SECURITY WARNING: only set to
False for local development or testing environments. False for local development or testing environments.
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. consent_csp_policy: Content Security Policy for the consent page.
If None (default), uses the built-in CSP policy with appropriate directives. If None (default), uses the built-in CSP policy with appropriate directives.
If empty string "", disables CSP entirely (no meta tag is rendered). If empty string "", disables CSP entirely (no meta tag is rendered).
@ -517,7 +526,27 @@ class OAuthProxy(OAuthProvider, ConsentMixin):
self._require_authorization_consent: bool | Literal["remember", "external"] = ( self._require_authorization_consent: bool | Literal["remember", "external"] = (
require_authorization_consent require_authorization_consent
) )
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 self._consent_csp_policy: str | None = consent_csp_policy
if self._consent_cookie_domain is not None:
logger.warning(
"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": if require_authorization_consent == "external":
logger.info( logger.info(
"Built-in consent screen disabled; consent is handled externally." "Built-in consent screen disabled; consent is handled externally."

View file

@ -234,6 +234,7 @@ class OIDCProxy(OAuthProxy):
token_endpoint_auth_method: str | None = None, token_endpoint_auth_method: str | None = None,
# Consent screen configuration # Consent screen configuration
require_authorization_consent: bool | Literal["remember", "external"] = True, require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_cookie_domain: str | None = None,
consent_csp_policy: str | None = None, consent_csp_policy: str | None = None,
forward_resource: bool = True, forward_resource: bool = True,
# Extra parameters # Extra parameters
@ -310,6 +311,11 @@ class OIDCProxy(OAuthProxy):
but the warning is suppressed as an operator acknowledgment that but the warning is suppressed as an operator acknowledgment that
equivalent protections are enforced externally. equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments. SECURITY WARNING: Only set to False for local development or testing environments.
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. consent_csp_policy: Content Security Policy for the consent page.
If None (default), uses the built-in CSP policy with appropriate directives. If None (default), uses the built-in CSP policy with appropriate directives.
If empty string "", disables CSP entirely (no meta tag is rendered). If empty string "", disables CSP entirely (no meta tag is rendered).
@ -430,6 +436,7 @@ class OIDCProxy(OAuthProxy):
"jwt_signing_key": jwt_signing_key, "jwt_signing_key": jwt_signing_key,
"token_endpoint_auth_method": token_endpoint_auth_method, "token_endpoint_auth_method": token_endpoint_auth_method,
"require_authorization_consent": require_authorization_consent, "require_authorization_consent": require_authorization_consent,
"consent_cookie_domain": consent_cookie_domain,
"consent_csp_policy": consent_csp_policy, "consent_csp_policy": consent_csp_policy,
"forward_resource": forward_resource, "forward_resource": forward_resource,
"fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds,

View file

@ -113,6 +113,7 @@ class AzureProvider(OAuthProxy):
client_storage: AsyncKeyValue | None = None, client_storage: AsyncKeyValue | None = None,
jwt_signing_key: str | bytes | None = None, jwt_signing_key: str | bytes | None = None,
require_authorization_consent: bool | Literal["remember", "external"] = True, require_authorization_consent: bool | Literal["remember", "external"] = True,
consent_cookie_domain: str | None = None,
consent_csp_policy: str | None = None, consent_csp_policy: str | None = None,
forward_resource: bool = True, forward_resource: bool = True,
fallback_refresh_token_expiry_seconds: int | None = None, 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 but the warning is suppressed as an operator acknowledgment that
equivalent protections are enforced externally. equivalent protections are enforced externally.
SECURITY WARNING: Only set to False for local development or testing environments. SECURITY WARNING: Only set to False for local development or testing environments.
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. http_client: Optional httpx2.AsyncClient for connection pooling in JWKS fetches.
When provided, the client is reused for JWT key fetches and the caller 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. 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, client_storage=client_storage,
jwt_signing_key=jwt_signing_key, jwt_signing_key=jwt_signing_key,
require_authorization_consent=require_authorization_consent, require_authorization_consent=require_authorization_consent,
consent_cookie_domain=consent_cookie_domain,
consent_csp_policy=consent_csp_policy, consent_csp_policy=consent_csp_policy,
forward_resource=forward_resource, forward_resource=forward_resource,
fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds, fallback_refresh_token_expiry_seconds=fallback_refresh_token_expiry_seconds,

View file

@ -66,6 +66,23 @@ class TestAzureProvider:
assert provider._redirect_path == "/auth/callback" assert provider._redirect_path == "/auth/callback"
# Azure provider defaults are set but we can't easily verify them without accessing internals # Azure provider defaults are set but we can't easily verify them without accessing internals
def test_explicit_consent_cookie_domain(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_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): def test_offline_access_automatically_included(self, memory_storage: MemoryStore):
"""Test that offline_access is automatically added to get refresh tokens.""" """Test that offline_access is automatically added to get refresh tokens."""
# Without specifying offline_access # Without specifying offline_access

View file

@ -64,6 +64,22 @@ def oauth_proxy_https_remember():
) )
@pytest.fixture
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",
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_domain="myserver.example",
)
async def _start_flow( async def _start_flow(
proxy: OAuthProxy, client_id: str, redirect: str proxy: OAuthProxy, client_id: str, redirect: str
) -> tuple[str, str]: ) -> tuple[str, str]:
@ -100,6 +116,143 @@ def _extract_csrf(html: str) -> str | None:
return m.group(1) if m else None return m.group(1) if m else None
class TestConsentCookieDomain:
def test_parent_domain_is_rejected(self):
with pytest.raises(
ValueError,
match="consent_cookie_domain must exactly match the hostname in 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="https://myserver.example",
client_storage=MemoryStore(),
jwt_signing_key="test-secret",
consent_cookie_domain="example",
)
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_with_cookie_domain,
"explicit-domain-client",
"http://localhost:6000/callback",
)
app = Starlette(routes=oauth_proxy_https_with_cookie_domain.get_routes())
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
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
assert "Domain=myserver.example" in set_cookie
response = client.post(
"/consent",
data={
"action": "approve",
"txn_id": txn_id,
"csrf_token": csrf,
},
follow_redirects=False,
)
assert response.status_code in (302, 303)
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_explicit_domain_does_not_accept_host_prefix(
self, oauth_proxy_https_with_cookie_domain
):
txn_id, _ = await _start_flow(
oauth_proxy_https_with_cookie_domain,
"wrong-prefix-client",
"http://localhost:6001/callback",
)
app = Starlette(routes=oauth_proxy_https_with_cookie_domain.get_routes())
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, base_url="https://myserver.example") 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_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_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: class TestConsentPageServerIcon:
"""Tests for server icon display in OAuth consent screen.""" """Tests for server icon display in OAuth consent screen."""

View file

@ -489,6 +489,30 @@ class TestOIDCProxyInitialization:
call_args = mock_get.call_args call_args = mock_get.call_args
assert call_args[1]["timeout_seconds"] == 12 assert call_args[1]["timeout_seconds"] == 12
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:
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_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): def test_token_verifier_initialization(self, valid_oidc_configuration_dict):
"""Test token verifier initialization.""" """Test token verifier initialization."""
with patch( with patch(