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
223 lines
11 KiB
Python
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
|