"""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