Validate the authorization server metadata issuer on every discovery path (#3398)
This commit is contained in:
parent
d2290ca343
commit
d060b36e1d
3 changed files with 760 additions and 135 deletions
|
|
@ -9,6 +9,7 @@ Provides OAuth providers for machine-to-machine authentication flows:
|
|||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Literal
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx2
|
||||
|
|
@ -16,14 +17,50 @@ import jwt
|
|||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, TokenStorage
|
||||
from mcp.client.auth.oauth2 import OAuthContext
|
||||
from mcp.client.auth.utils import issuers_match
|
||||
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
|
||||
|
||||
|
||||
def _checked_issuer(issuer: str | None) -> str | None:
|
||||
if issuer is not None and urlparse(issuer).scheme not in ("http", "https"):
|
||||
raise ValueError(f"issuer must be the authorization server's http(s) issuer URL, got {issuer!r}")
|
||||
return issuer
|
||||
|
||||
|
||||
def _preferred_authorization_server(advertised: list[str], issuer: str | None) -> str:
|
||||
"""The advertised server matching the configured issuer if there is one, else the first."""
|
||||
return next(
|
||||
(server for server in advertised if issuer is not None and issuers_match(server, issuer)), advertised[0]
|
||||
)
|
||||
|
||||
|
||||
def _require_metadata_for_configured_issuer(context: OAuthContext, issuer: str | None) -> None:
|
||||
"""With an issuer configured, a token request is only built from metadata discovered for that issuer.
|
||||
|
||||
Anything else held is dropped along with the tokens, so the next request starts discovery afresh
|
||||
rather than refreshing against it.
|
||||
"""
|
||||
if issuer is None:
|
||||
return
|
||||
metadata = context.oauth_metadata
|
||||
if metadata is not None and issuers_match(str(metadata.issuer), issuer):
|
||||
return
|
||||
context.oauth_metadata = None
|
||||
context.clear_tokens()
|
||||
if metadata is None:
|
||||
raise OAuthFlowError(f"No authorization server metadata discovered for configured issuer {issuer}")
|
||||
raise OAuthFlowError(f"Authorization server metadata issuer mismatch: {metadata.issuer} != {issuer}")
|
||||
|
||||
|
||||
class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
||||
"""OAuth provider for client_credentials grant with client_id + client_secret.
|
||||
|
||||
This provider sets client_info directly, bypassing dynamic client registration.
|
||||
Use this when you already have client credentials (client_id and client_secret).
|
||||
Pass `issuer` to name the authorization server those credentials belong to: token
|
||||
requests are then only built from authorization server metadata for that issuer, and
|
||||
the flow stops if the MCP server leads anywhere else.
|
||||
|
||||
Example:
|
||||
```python
|
||||
|
|
@ -32,6 +69,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
|||
storage=my_token_storage,
|
||||
client_id="my-client-id",
|
||||
client_secret="my-client-secret",
|
||||
issuer="https://auth.example.com",
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
|
@ -44,6 +82,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
|||
client_secret: str,
|
||||
token_endpoint_auth_method: Literal["client_secret_basic", "client_secret_post"] = "client_secret_basic",
|
||||
scope: str | None = None,
|
||||
issuer: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize client_credentials OAuth provider.
|
||||
|
||||
|
|
@ -55,6 +94,11 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
|||
token_endpoint_auth_method: Authentication method for token endpoint.
|
||||
Either "client_secret_basic" (default) or "client_secret_post".
|
||||
scope: Optional space-separated list of scopes to request.
|
||||
issuer: The issuer identifier of the authorization server that issued
|
||||
`client_id` and `client_secret`. When set, token requests are only built from
|
||||
discovered authorization server metadata whose `issuer` is exactly this string;
|
||||
otherwise the flow stops with `OAuthFlowError`. When omitted, whichever
|
||||
authorization server discovery yields is used.
|
||||
"""
|
||||
# Build minimal client_metadata for the base class
|
||||
client_metadata = OAuthClientMetadata(
|
||||
|
|
@ -64,6 +108,7 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
|||
scope=scope,
|
||||
)
|
||||
super().__init__(server_url, client_metadata, storage, None, None)
|
||||
self._issuer = _checked_issuer(issuer)
|
||||
# Store client_info to be set during _initialize - no dynamic registration needed
|
||||
self._fixed_client_info = OAuthClientInformationFull(
|
||||
redirect_uris=None,
|
||||
|
|
@ -80,12 +125,17 @@ class ClientCredentialsOAuthProvider(OAuthClientProvider):
|
|||
self.context.client_info = self._fixed_client_info
|
||||
self._initialized = True
|
||||
|
||||
def _select_authorization_server(self, advertised: list[str]) -> str:
|
||||
return _preferred_authorization_server(advertised, self._issuer)
|
||||
|
||||
async def _perform_authorization(self) -> httpx2.Request:
|
||||
"""Perform client_credentials authorization."""
|
||||
return await self._exchange_token_client_credentials()
|
||||
|
||||
async def _exchange_token_client_credentials(self) -> httpx2.Request:
|
||||
"""Build token exchange request for client_credentials grant."""
|
||||
_require_metadata_for_configured_issuer(self.context, self._issuer)
|
||||
|
||||
token_data: dict[str, Any] = {
|
||||
"grant_type": "client_credentials",
|
||||
}
|
||||
|
|
@ -196,7 +246,10 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
|
||||
The JWT assertion's audience MUST be the authorization server's issuer identifier
|
||||
(per RFC 7523bis security updates). The `assertion_provider` callback receives
|
||||
this audience value and must return a JWT with that audience.
|
||||
this audience value and must return a JWT with that audience. Pass `issuer` to name
|
||||
the authorization server this client is registered with: an assertion is then only
|
||||
minted once metadata for that issuer has been discovered, and token requests are only
|
||||
built from that metadata.
|
||||
|
||||
**Option 1: Pre-built JWT via Workload Identity Federation**
|
||||
|
||||
|
|
@ -256,6 +309,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
client_id: str,
|
||||
assertion_provider: Callable[[str], Awaitable[str]],
|
||||
scope: str | None = None,
|
||||
issuer: str | None = None,
|
||||
) -> None:
|
||||
"""Initialize private_key_jwt OAuth provider.
|
||||
|
||||
|
|
@ -269,6 +323,11 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
`static_assertion_provider()` for pre-built JWTs, or provide your own
|
||||
callback for workload identity federation.
|
||||
scope: Optional space-separated list of scopes to request.
|
||||
issuer: The issuer identifier of the authorization server `client_id` is
|
||||
registered with. When set, an assertion is only minted, and token requests
|
||||
are only built, once authorization server metadata whose `issuer` is exactly this
|
||||
string has been discovered; otherwise the flow stops with `OAuthFlowError`.
|
||||
When omitted, whichever authorization server discovery yields is used.
|
||||
"""
|
||||
# Build minimal client_metadata for the base class
|
||||
client_metadata = OAuthClientMetadata(
|
||||
|
|
@ -279,6 +338,7 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
)
|
||||
super().__init__(server_url, client_metadata, storage, None, None)
|
||||
self._assertion_provider = assertion_provider
|
||||
self._issuer = _checked_issuer(issuer)
|
||||
# Store client_info to be set during _initialize - no dynamic registration needed
|
||||
self._fixed_client_info = OAuthClientInformationFull(
|
||||
redirect_uris=None,
|
||||
|
|
@ -294,6 +354,9 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
self.context.client_info = self._fixed_client_info
|
||||
self._initialized = True
|
||||
|
||||
def _select_authorization_server(self, advertised: list[str]) -> str:
|
||||
return _preferred_authorization_server(advertised, self._issuer)
|
||||
|
||||
async def _perform_authorization(self) -> httpx2.Request:
|
||||
"""Perform client_credentials authorization with private_key_jwt."""
|
||||
return await self._exchange_token_client_credentials()
|
||||
|
|
@ -314,6 +377,8 @@ class PrivateKeyJWTOAuthProvider(OAuthClientProvider):
|
|||
|
||||
async def _exchange_token_client_credentials(self) -> httpx2.Request:
|
||||
"""Build token exchange request for client_credentials grant with private_key_jwt."""
|
||||
_require_metadata_for_configured_issuer(self.context, self._issuer)
|
||||
|
||||
token_data: dict[str, Any] = {
|
||||
"grant_type": "client_credentials",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from urllib.parse import quote, urlencode, urljoin, urlparse
|
|||
import anyio
|
||||
import httpx2
|
||||
from mcp_types.version import is_version_at_least
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field, TypeAdapter, ValidationError
|
||||
|
||||
from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError
|
||||
from mcp.client.auth.utils import (
|
||||
|
|
@ -36,6 +36,7 @@ from mcp.client.auth.utils import (
|
|||
handle_registration_response,
|
||||
handle_token_response_scopes,
|
||||
is_valid_client_metadata_url,
|
||||
issuers_match,
|
||||
should_use_client_metadata_url,
|
||||
union_scopes,
|
||||
validate_authorization_response_iss,
|
||||
|
|
@ -276,6 +277,16 @@ class OAuthContext:
|
|||
return data, headers
|
||||
|
||||
|
||||
_ORIGIN_URL = TypeAdapter(AnyHttpUrl, config=ConfigDict(url_preserve_empty_path=True))
|
||||
|
||||
|
||||
def _origin_issuer(server_url: str) -> str:
|
||||
"""The resource server's origin as an issuer identifier: `scheme://authority`, rendered the way
|
||||
`OAuthMetadata.issuer` renders URLs (host case, default ports) so the two compare as strings."""
|
||||
parsed = urlparse(server_url)
|
||||
return str(_ORIGIN_URL.validate_python(f"{parsed.scheme}://{parsed.netloc}"))
|
||||
|
||||
|
||||
class OAuthClientProvider(httpx2.Auth):
|
||||
"""OAuth2 authentication for httpx2.
|
||||
|
||||
|
|
@ -577,6 +588,16 @@ class OAuthClientProvider(httpx2.Auth):
|
|||
if not check_resource_allowed(requested_resource=default_resource, configured_resource=prm_resource):
|
||||
raise OAuthFlowError(f"Protected resource {prm_resource} does not match expected {default_resource}")
|
||||
|
||||
def _select_authorization_server(self, advertised: list[str]) -> str:
|
||||
"""Which of the servers listed in protected resource metadata to use: the first (the list is never empty)."""
|
||||
return advertised[0]
|
||||
|
||||
def _expected_issuer(self) -> str:
|
||||
"""The issuer that authorization server metadata and client credentials must belong to: the
|
||||
PRM-advertised server, or on the legacy no-PRM path the resource server's origin, which is what
|
||||
the 2025-03-26 well-known URL is built from (RFC 8414 §3.3)."""
|
||||
return self.context.auth_server_url or _origin_issuer(self.context.server_url)
|
||||
|
||||
async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Request, httpx2.Response]:
|
||||
"""httpx2 auth flow integration."""
|
||||
async with self.context.lock:
|
||||
|
|
@ -600,107 +621,122 @@ class OAuthClientProvider(httpx2.Auth):
|
|||
|
||||
response = yield request
|
||||
|
||||
if response.status_code == 401:
|
||||
step_up = (
|
||||
response.status_code == 403 and extract_field_from_www_auth(response, "error") == "insufficient_scope"
|
||||
)
|
||||
|
||||
if response.status_code == 401 or step_up:
|
||||
# Perform full OAuth flow
|
||||
try:
|
||||
# OAuth flow must be inline due to generator constraints
|
||||
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
|
||||
# Read before discovery, which may clear the tokens: on a restart the stored
|
||||
# token's scope is the only record of what was granted (see Step 3).
|
||||
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
|
||||
|
||||
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
|
||||
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
|
||||
www_auth_resource_metadata_url, self.context.server_url
|
||||
)
|
||||
# OAuth flow must be inline due to generator constraints.
|
||||
# Steps 1-2 run on every 401. A scope step-up reuses the metadata discovered earlier
|
||||
# in this process, and discovers it first when none is held yet (for example when
|
||||
# tokens were loaded from storage), so re-authorization targets the right server.
|
||||
if response.status_code == 401 or self.context.oauth_metadata is None:
|
||||
www_auth_resource_metadata_url = extract_resource_metadata_from_www_auth(response)
|
||||
|
||||
for url in prm_discovery_urls: # pragma: no branch
|
||||
discovery_request = create_oauth_metadata_request(url)
|
||||
|
||||
discovery_response = yield discovery_request # sending request
|
||||
|
||||
prm = await handle_protected_resource_response(discovery_response)
|
||||
if prm:
|
||||
# Validate PRM resource matches server URL (RFC 8707)
|
||||
await self._validate_resource_match(prm)
|
||||
self.context.protected_resource_metadata = prm
|
||||
|
||||
# todo: try all authorization_servers to find the OASM
|
||||
assert (
|
||||
len(prm.authorization_servers) > 0
|
||||
) # this is always true as authorization_servers has a min length of 1
|
||||
|
||||
self.context.auth_server_url = str(prm.authorization_servers[0])
|
||||
break
|
||||
else:
|
||||
logger.debug(f"Protected resource metadata discovery failed: {url}")
|
||||
|
||||
# SEP-2352: stored credentials are bound to the issuer that registered them.
|
||||
# If the authorization server changed, drop them (and the old tokens) so the
|
||||
# flow re-registers instead of presenting another server's credentials.
|
||||
if (
|
||||
self.context.client_info is not None
|
||||
and self.context.auth_server_url is not None
|
||||
and not credentials_match_issuer(
|
||||
self.context.client_info, self.context.auth_server_url, self.context.client_metadata_url
|
||||
# Step 1: Discover protected resource metadata (SEP-985 with fallback support)
|
||||
prm_discovery_urls = build_protected_resource_metadata_discovery_urls(
|
||||
www_auth_resource_metadata_url, self.context.server_url
|
||||
)
|
||||
):
|
||||
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
|
||||
self.context.client_info = None
|
||||
self.context.clear_tokens()
|
||||
# Any cached AS metadata is for the old server; drop it so a failed
|
||||
# rediscovery cannot leak the old registration/token endpoints into Step 4.
|
||||
self.context.oauth_metadata = None
|
||||
|
||||
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
|
||||
self.context.auth_server_url, self.context.server_url
|
||||
)
|
||||
prm_request_failed: int | None = None
|
||||
for url in prm_discovery_urls:
|
||||
discovery_request = create_oauth_metadata_request(url)
|
||||
|
||||
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
|
||||
for url in asm_discovery_urls: # pragma: no branch
|
||||
oauth_metadata_request = create_oauth_metadata_request(url)
|
||||
oauth_metadata_response = yield oauth_metadata_request
|
||||
discovery_response = yield discovery_request # sending request
|
||||
|
||||
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
|
||||
if not ok:
|
||||
break
|
||||
if ok and asm:
|
||||
# SEP-2468: metadata issuer must match the discovery issuer
|
||||
if self.context.auth_server_url is not None:
|
||||
validate_metadata_issuer(asm, self.context.auth_server_url)
|
||||
self.context.oauth_metadata = asm
|
||||
break
|
||||
if discovery_response.status_code >= 500 or discovery_response.status_code == 429:
|
||||
prm_request_failed = discovery_response.status_code
|
||||
prm = await handle_protected_resource_response(discovery_response)
|
||||
if prm:
|
||||
# Validate PRM resource matches server URL (RFC 8707)
|
||||
await self._validate_resource_match(prm)
|
||||
self.context.protected_resource_metadata = prm
|
||||
|
||||
self.context.auth_server_url = self._select_authorization_server(
|
||||
[str(url) for url in prm.authorization_servers]
|
||||
)
|
||||
break
|
||||
else:
|
||||
logger.debug(f"Protected resource metadata discovery failed: {url}")
|
||||
else:
|
||||
logger.debug(f"OAuth metadata discovery failed: {url}")
|
||||
if prm_request_failed is not None:
|
||||
# A server error says nothing about whether the resource publishes
|
||||
# metadata, so it must not send the flow down the legacy path.
|
||||
raise OAuthFlowError(
|
||||
f"Protected resource metadata request failed: HTTP {prm_request_failed}"
|
||||
)
|
||||
|
||||
# SEP-2352: on the legacy no-PRM path the issuer is only known after ASM
|
||||
# discovery, so re-evaluate the binding here using the discovered metadata
|
||||
# issuer (mirroring the bound_issuer fallback in Step 4).
|
||||
if (
|
||||
self.context.client_info is not None
|
||||
and self.context.auth_server_url is None
|
||||
and self.context.oauth_metadata is not None
|
||||
and not credentials_match_issuer(
|
||||
self.context.client_info,
|
||||
str(self.context.oauth_metadata.issuer),
|
||||
self.context.client_metadata_url,
|
||||
expected_issuer = self._expected_issuer()
|
||||
|
||||
# SEP-2352: stored credentials are bound to the issuer that registered them.
|
||||
# Decided before any metadata is fetched: if the expected issuer is a different
|
||||
# server, drop them (and the old tokens) so the flow re-registers instead of
|
||||
# presenting another server's credentials.
|
||||
if self.context.client_info is not None and not credentials_match_issuer(
|
||||
self.context.client_info, expected_issuer, self.context.client_metadata_url
|
||||
):
|
||||
logger.debug(
|
||||
"Authorization server changed; discarding bound credentials and re-registering"
|
||||
)
|
||||
self.context.client_info = None
|
||||
self.context.clear_tokens()
|
||||
# Any cached AS metadata is for the old server; drop it so a failed
|
||||
# rediscovery cannot leak the old registration/token endpoints into Step 4.
|
||||
self.context.oauth_metadata = None
|
||||
|
||||
asm_discovery_urls = build_oauth_authorization_server_metadata_discovery_urls(
|
||||
self.context.auth_server_url, self.context.server_url
|
||||
)
|
||||
):
|
||||
logger.debug("Authorization server changed; discarding bound credentials and re-registering")
|
||||
self.context.client_info = None
|
||||
self.context.clear_tokens()
|
||||
|
||||
# Step 2: Discover OAuth Authorization Server Metadata (OASM) (with fallback for legacy servers)
|
||||
for url in asm_discovery_urls: # pragma: no branch
|
||||
oauth_metadata_request = create_oauth_metadata_request(url)
|
||||
oauth_metadata_response = yield oauth_metadata_request
|
||||
|
||||
ok, asm = await handle_auth_metadata_response(oauth_metadata_response)
|
||||
if not ok:
|
||||
break
|
||||
if ok and asm:
|
||||
# SEP-2468 / RFC 8414 §3.3: the metadata must name the expected issuer.
|
||||
# On the legacy path a root issuer rendered with its trailing slash
|
||||
# names the same origin.
|
||||
if self.context.auth_server_url is None and issuers_match(
|
||||
str(asm.issuer), expected_issuer
|
||||
):
|
||||
expected_issuer = str(asm.issuer)
|
||||
validate_metadata_issuer(asm, expected_issuer)
|
||||
self.context.oauth_metadata = asm
|
||||
break
|
||||
else:
|
||||
logger.debug(f"OAuth metadata discovery failed: {url}")
|
||||
|
||||
# Step 3: Apply scope selection strategy
|
||||
self.context.client_metadata.scope = get_client_metadata_scopes(
|
||||
challenged_scope = get_client_metadata_scopes(
|
||||
extract_scope_from_www_auth(response),
|
||||
self.context.protected_resource_metadata,
|
||||
self.context.oauth_metadata,
|
||||
self.context.client_metadata.grant_types,
|
||||
)
|
||||
if step_up:
|
||||
# SEP-2350: union previously requested scopes with the newly challenged ones so
|
||||
# escalating one operation keeps the others' grants, folding in the granted
|
||||
# scope read above since client_metadata.scope is not reloaded on a restart.
|
||||
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
|
||||
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
|
||||
else:
|
||||
self.context.client_metadata.scope = challenged_scope
|
||||
|
||||
# Step 4: Register client or use URL-based client ID (CIMD)
|
||||
if not self.context.client_info:
|
||||
# SEP-2352: the issuer to bind these credentials to, when known.
|
||||
discovered_issuer: str | None = None
|
||||
if self.context.oauth_metadata is not None:
|
||||
discovered_issuer = self.context.auth_server_url or str(self.context.oauth_metadata.issuer)
|
||||
# SEP-2352: the issuer to bind these credentials to, once metadata for it
|
||||
# was actually found.
|
||||
discovered_issuer = self._expected_issuer() if self.context.oauth_metadata is not None else None
|
||||
|
||||
if should_use_client_metadata_url(
|
||||
self.context.oauth_metadata, self.context.client_metadata_url
|
||||
|
|
@ -752,34 +788,3 @@ class OAuthClientProvider(httpx2.Auth):
|
|||
# Retry with new tokens
|
||||
self._add_auth_header(request)
|
||||
yield request
|
||||
elif response.status_code == 403:
|
||||
# Step 1: Extract error field from WWW-Authenticate header
|
||||
error = extract_field_from_www_auth(response, "error")
|
||||
|
||||
# Step 2: Check if we need to step-up authorization
|
||||
if error == "insufficient_scope": # pragma: no branch
|
||||
try:
|
||||
# Step 2a: Union previously requested scopes with the newly challenged
|
||||
# scopes (SEP-2350) so escalating one operation keeps the others' grants.
|
||||
# Fold in the stored token's scope too: on a restart the token is reloaded
|
||||
# but client_metadata.scope is not, so it would otherwise be the only basis.
|
||||
challenged_scope = get_client_metadata_scopes(
|
||||
extract_scope_from_www_auth(response),
|
||||
self.context.protected_resource_metadata,
|
||||
self.context.oauth_metadata,
|
||||
self.context.client_metadata.grant_types,
|
||||
)
|
||||
granted_scope = self.context.current_tokens.scope if self.context.current_tokens else None
|
||||
prior_scope = union_scopes(self.context.client_metadata.scope, granted_scope)
|
||||
self.context.client_metadata.scope = union_scopes(prior_scope, challenged_scope)
|
||||
|
||||
# Step 2b: Perform (re-)authorization and token exchange
|
||||
token_response = yield await self._perform_authorization()
|
||||
await self._handle_token_response(token_response)
|
||||
except Exception: # pragma: no cover
|
||||
logger.exception("OAuth flow error")
|
||||
raise
|
||||
|
||||
# Retry with new tokens
|
||||
self._add_auth_header(request)
|
||||
yield request
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ def extract_resource_metadata_from_www_auth(response: Response) -> str | None:
|
|||
Returns:
|
||||
Resource metadata URL if found in WWW-Authenticate header, None otherwise
|
||||
"""
|
||||
if not response or response.status_code != 401:
|
||||
if not response or response.status_code not in (401, 403):
|
||||
return None # pragma: no cover
|
||||
|
||||
return extract_field_from_www_auth(response, "resource_metadata")
|
||||
|
|
@ -343,7 +343,8 @@ def credentials_match_issuer(
|
|||
document is resolved by whichever server is in use — so it always matches; CIMD is identified
|
||||
by the client ID being the configured `client_metadata_url`, not by URL shape (a registration
|
||||
server may also issue URL-shaped IDs that are bound to it). Credentials with a recorded issuer
|
||||
match only when it equals `issuer` (simple string comparison). Credentials with no recorded
|
||||
match only when it equals `issuer` (simple string comparison; a root issuer with and without
|
||||
its trailing slash count as equal). Credentials with no recorded
|
||||
issuer (pre-registered, or stored before issuer binding existed) carry no binding to enforce
|
||||
and are left as-is.
|
||||
"""
|
||||
|
|
@ -351,7 +352,18 @@ def credentials_match_issuer(
|
|||
return True
|
||||
if client_info.issuer is None:
|
||||
return True
|
||||
return client_info.issuer == issuer
|
||||
return issuers_match(client_info.issuer, issuer)
|
||||
|
||||
|
||||
def issuers_match(a: str, b: str) -> bool:
|
||||
"""Simple string comparison of two issuer identifiers (RFC 8414 section 3.3), except that a root
|
||||
issuer with and without its trailing slash (`scheme://authority` and `scheme://authority/`) name
|
||||
the same server."""
|
||||
if a == b:
|
||||
return True
|
||||
shorter, longer = sorted((a, b), key=len)
|
||||
parsed = urlparse(shorter)
|
||||
return longer == f"{shorter}/" and shorter == f"{parsed.scheme}://{parsed.netloc}"
|
||||
|
||||
|
||||
def should_use_client_metadata_url(
|
||||
|
|
|
|||
Loading…
Reference in a new issue