mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 15:19:10 +02:00
Compare commits
2 commits
main
...
codex/azur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce58ceec7b |
||
|
|
eb804d26e3 |
13 changed files with 319 additions and 6 deletions
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue