Files
modelcontextprotocol--pytho…/tests/interaction/auth/_provider.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

223 lines
11 KiB
Python

"""An in-memory implementation of the SDK's OAuth authorization-server provider protocol.
The provider holds clients, authorization codes, refresh tokens and access tokens in plain
instance dicts so tests can inspect them; tokens are minted from `secrets.token_hex` so the
values are unique without being predictable. The behaviour mirrors what the SDK's authorization
handlers expect: `authorize` immediately mints a code and returns the redirect, `exchange_*`
issue and rotate tokens, and `load_*` are simple lookups. Only the parts the auth interaction
suite drives are implemented; methods the suite does not exercise raise `NotImplementedError`.
"""
import secrets
import time
from mcp.server.auth.provider import (
AccessToken,
AuthorizationCode,
AuthorizationParams,
IdentityAssertionParams,
OAuthAuthorizationServerProvider,
RefreshToken,
TokenError,
construct_redirect_uri,
)
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
from tests.interaction._connect import BASE_URL
_TOKEN_LIFETIME_SECONDS = 3600
# The only ID-JAG assertion the in-memory provider accepts; any other value is rejected with
# invalid_grant, standing in for the signature/policy validation a real AS performs.
VALID_ASSERTION = "valid-id-jag"
class InMemoryAuthorizationServerProvider(
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
):
"""An OAuth authorization-server provider backed by in-memory dicts.
Holds registered clients, issued codes, refresh tokens and access tokens as instance state
so tests can both drive the SDK's authorization handlers and inspect what was issued.
Knobs:
`default_scopes`: scopes granted when an authorize request supplies none.
`deny_authorize`: every authorize request returns an `error=access_denied` redirect.
`issue_expired_first`: the first issued token's `expires_in` is in the past so the
client immediately considers it expired and refreshes; the server-side
`AccessToken.expires_at` stays in the future so the bearer middleware accepts it
on the retry that completes the connect.
`fail_next_refresh`: the next refresh-token exchange raises `invalid_grant` once.
`reject_all_tokens`: `load_access_token` returns None for every token, so the bearer
middleware 401s every authenticated request.
"""
def __init__(
self,
*,
default_scopes: list[str] | None = None,
deny_authorize: bool = False,
issue_expired_first: bool = False,
fail_next_refresh: bool = False,
reject_all_tokens: bool = False,
issuer: str | None = None,
) -> None:
self._default_scopes = list(default_scopes) if default_scopes is not None else ["mcp"]
# The authorization-response iss must equal the AS metadata issuer the client recorded
# (RFC 9207 simple string comparison). `real_asm` builds the issuer from an AnyHttpUrl
# object, so it carries the trailing slash; the redirect iss matches it. Path-issuer
# tests pass the recorded issuer explicitly.
self._issuer = issuer if issuer is not None else f"{BASE_URL}/"
self._deny_authorize = deny_authorize
self._issue_expired_first = issue_expired_first
self._fail_next_refresh = fail_next_refresh
self._reject_all_tokens = reject_all_tokens
self._tokens_issued = 0
self.clients: dict[str, OAuthClientInformationFull] = {}
self.codes: dict[str, AuthorizationCode] = {}
self.refresh_tokens: dict[str, RefreshToken] = {}
self.access_tokens: dict[str, AccessToken] = {}
# The most recent jwt-bearer request the SDK handler passed to exchange_identity_assertion,
# for tests to assert what the client sent (None until the first exchange).
self.last_assertion_params: IdentityAssertionParams | None = None
def _next_expires_in(self) -> int:
self._tokens_issued += 1
if self._issue_expired_first and self._tokens_issued == 1:
return -_TOKEN_LIFETIME_SECONDS
return _TOKEN_LIFETIME_SECONDS
def mint_access_token(self, *, client_id: str, scopes: list[str], resource: str | None = None) -> str:
"""Mint and store an access token, returning its value.
Used by the auth-code and refresh exchanges and by the M2M `/token` shim. The
server-side `expires_at` is always in the future regardless of `issue_expired_first`,
which only affects what the client is told.
"""
access = f"access_{secrets.token_hex(16)}"
self.access_tokens[access] = AccessToken(
token=access,
client_id=client_id,
scopes=scopes,
expires_at=int(time.time()) + _TOKEN_LIFETIME_SECONDS,
resource=resource,
)
return access
async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
return self.clients.get(client_id)
async def register_client(self, client_info: OAuthClientInformationFull) -> None:
assert client_info.client_id is not None
self.clients[client_info.client_id] = client_info
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
"""Mint an authorization code immediately and return the redirect carrying it.
A real provider would interpose user consent here; the test provider grants
unconditionally so the headless redirect handler can complete the flow in-process.
When `deny_authorize` is set, returns an `error=access_denied` redirect instead.
"""
assert client.client_id is not None
if self._deny_authorize:
return construct_redirect_uri(
str(params.redirect_uri), error="access_denied", error_description="user denied", state=params.state
)
code = AuthorizationCode(
code=f"code_{secrets.token_hex(16)}",
client_id=client.client_id,
scopes=params.scopes or self._default_scopes,
expires_at=time.time() + 300,
code_challenge=params.code_challenge,
redirect_uri=params.redirect_uri,
redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly,
resource=params.resource,
)
self.codes[code.code] = code
# `iss` is RFC 9207's authorization-response issuer identifier — an extra parameter many
# real authorization servers send. Including it on every success redirect proves the
# client tolerates unrecognized callback parameters (RFC 6749 §4.1.2 MUST) by virtue of
# every flow test passing unchanged.
return construct_redirect_uri(str(params.redirect_uri), code=code.code, state=params.state, iss=self._issuer)
async def load_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: str
) -> AuthorizationCode | None:
return self.codes.get(authorization_code)
async def exchange_authorization_code(
self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
) -> OAuthToken:
"""Mint an access token and a refresh token for a valid authorization code, then consume the code."""
assert client.client_id is not None
access = self.mint_access_token(
client_id=client.client_id, scopes=authorization_code.scopes, resource=authorization_code.resource
)
refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[refresh] = RefreshToken(
token=refresh,
client_id=client.client_id,
scopes=authorization_code.scopes,
)
del self.codes[authorization_code.code]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(authorization_code.scopes),
refresh_token=refresh,
)
async def load_access_token(self, token: str) -> AccessToken | None:
if self._reject_all_tokens:
return None
return self.access_tokens.get(token)
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
return self.refresh_tokens.get(refresh_token)
async def exchange_refresh_token(
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
) -> OAuthToken:
"""Mint a new access token and rotate the refresh token, consuming the old one."""
assert client.client_id is not None
if self._fail_next_refresh:
self._fail_next_refresh = False
raise TokenError(error="invalid_grant", error_description="refresh denied by harness")
access = self.mint_access_token(client_id=client.client_id, scopes=scopes)
new_refresh = f"refresh_{secrets.token_hex(16)}"
self.refresh_tokens[new_refresh] = RefreshToken(token=new_refresh, client_id=client.client_id, scopes=scopes)
del self.refresh_tokens[refresh_token.token]
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
refresh_token=new_refresh,
)
async def exchange_identity_assertion(
self, client: OAuthClientInformationFull, params: IdentityAssertionParams
) -> OAuthToken:
"""Validate the ID-JAG assertion and mint an MCP access token (RFC 7523 jwt-bearer / SEP-990).
Records `params` for inspection and rejects any assertion other than `VALID_ASSERTION` with
invalid_grant (standing in for signature/policy validation). The granted scopes are exactly
those the client requested; a real provider would derive them from the validated ID-JAG.
"""
self.last_assertion_params = params
assert client.client_id is not None
if params.assertion != VALID_ASSERTION:
raise TokenError(error="invalid_grant", error_description="assertion is not valid")
scopes = params.scopes if params.scopes is not None else self._default_scopes
access = self.mint_access_token(client_id=client.client_id, scopes=scopes, resource=params.resource)
return OAuthToken(
access_token=access,
token_type="Bearer",
expires_in=self._next_expires_in(),
scope=" ".join(scopes),
)
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
"""Not exercised by this suite; revocation is out of scope for the interaction tests."""
raise NotImplementedError