from collections.abc import Awaitable, Callable from typing import Any from urllib.parse import urlparse from pydantic import AnyHttpUrl from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.responses import Response from starlette.routing import Route, request_response # type: ignore from starlette.types import ASGIApp from mcp.server.auth.handlers.authorize import AuthorizationHandler from mcp.server.auth.handlers.metadata import MetadataHandler, ProtectedResourceMetadataHandler from mcp.server.auth.handlers.register import RegistrationHandler from mcp.server.auth.handlers.revoke import RevocationHandler from mcp.server.auth.handlers.token import TokenHandler from mcp.server.auth.middleware.client_auth import ClientAuthenticator from mcp.server.auth.provider import OAuthAuthorizationServerProvider from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthMetadata, ProtectedResourceMetadata from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER def validate_issuer_url(url: AnyHttpUrl): """Validate that the issuer URL meets OAuth 2.0 requirements. Args: url: The issuer URL to validate. Raises: ValueError: If the issuer URL is invalid. """ # RFC 8414 requires HTTPS, but we allow loopback/localhost HTTP for testing if url.scheme != "https" and url.host not in ("localhost", "127.0.0.1", "[::1]"): raise ValueError("Issuer URL must be HTTPS") # No fragments or query parameters allowed if url.fragment: raise ValueError("Issuer URL must not have a fragment") if url.query: raise ValueError("Issuer URL must not have a query string") AUTHORIZATION_PATH = "/authorize" TOKEN_PATH = "/token" REGISTRATION_PATH = "/register" REVOCATION_PATH = "/revoke" # SEP-990: leg 2 uses the RFC 7523 jwt-bearer grant; support is advertised as the ID-JAG profile. ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag" def cors_middleware( handler: Callable[[Request], Response | Awaitable[Response]], allow_methods: list[str], ) -> ASGIApp: cors_app = CORSMiddleware( app=request_response(handler), allow_origins="*", allow_methods=allow_methods, allow_headers=[MCP_PROTOCOL_VERSION_HEADER], ) return cors_app def create_auth_routes( provider: OAuthAuthorizationServerProvider[Any, Any, Any], issuer_url: AnyHttpUrl, service_documentation_url: AnyHttpUrl | None = None, client_registration_options: ClientRegistrationOptions | None = None, revocation_options: RevocationOptions | None = None, identity_assertion_enabled: bool = False, ) -> list[Route]: validate_issuer_url(issuer_url) client_registration_options = client_registration_options or ClientRegistrationOptions() revocation_options = revocation_options or RevocationOptions() metadata = build_metadata( issuer_url, service_documentation_url, client_registration_options, revocation_options, supports_identity_assertion=identity_assertion_enabled, ) client_authenticator = ClientAuthenticator(provider) # Create routes # Allow CORS requests for endpoints meant to be hit by the OAuth client # (with the client secret). This is intended to support things like MCP Inspector, # where the client runs in a web browser. routes = [ Route( "/.well-known/oauth-authorization-server", endpoint=cors_middleware( MetadataHandler(metadata).handle, ["GET", "OPTIONS"], ), methods=["GET", "OPTIONS"], ), Route( AUTHORIZATION_PATH, # do not allow CORS for authorization endpoint; # clients should just redirect to this endpoint=AuthorizationHandler(provider).handle, methods=["GET", "POST"], ), Route( TOKEN_PATH, endpoint=cors_middleware( TokenHandler( provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled ).handle, ["POST", "OPTIONS"], ), methods=["POST", "OPTIONS"], ), ] if client_registration_options.enabled: # pragma: no branch registration_handler = RegistrationHandler( provider, options=client_registration_options, ) routes.append( Route( REGISTRATION_PATH, endpoint=cors_middleware( registration_handler.handle, ["POST", "OPTIONS"], ), methods=["POST", "OPTIONS"], ) ) if revocation_options.enabled: # pragma: no branch revocation_handler = RevocationHandler(provider, client_authenticator) routes.append( Route( REVOCATION_PATH, endpoint=cors_middleware( revocation_handler.handle, ["POST", "OPTIONS"], ), methods=["POST", "OPTIONS"], ) ) return routes def build_metadata( issuer_url: AnyHttpUrl, service_documentation_url: AnyHttpUrl | None, client_registration_options: ClientRegistrationOptions, revocation_options: RevocationOptions, supports_identity_assertion: bool = False, ) -> OAuthMetadata: authorization_url = AnyHttpUrl(str(issuer_url).rstrip("/") + AUTHORIZATION_PATH) token_url = AnyHttpUrl(str(issuer_url).rstrip("/") + TOKEN_PATH) grant_types_supported = ["authorization_code", "refresh_token"] # SEP-990 / ext-auth §6: support for the ID-JAG flow is advertised as a grant PROFILE, not as # the jwt-bearer grant type (which an AS might support for other purposes). authorization_grant_profiles_supported: list[str] | None = None if supports_identity_assertion: grant_types_supported.append(JWT_BEARER_GRANT_TYPE) authorization_grant_profiles_supported = [ID_JAG_GRANT_PROFILE] # Create metadata metadata = OAuthMetadata( issuer=issuer_url, authorization_endpoint=authorization_url, token_endpoint=token_url, scopes_supported=client_registration_options.valid_scopes, response_types_supported=["code"], response_modes_supported=None, grant_types_supported=grant_types_supported, token_endpoint_auth_methods_supported=["client_secret_post", "client_secret_basic"], token_endpoint_auth_signing_alg_values_supported=None, service_documentation=service_documentation_url, ui_locales_supported=None, op_policy_uri=None, op_tos_uri=None, introspection_endpoint=None, code_challenge_methods_supported=["S256"], authorization_grant_profiles_supported=authorization_grant_profiles_supported, ) # Add registration endpoint if supported if client_registration_options.enabled: # pragma: no branch metadata.registration_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REGISTRATION_PATH) # Add revocation endpoint if supported if revocation_options.enabled: # pragma: no branch metadata.revocation_endpoint = AnyHttpUrl(str(issuer_url).rstrip("/") + REVOCATION_PATH) metadata.revocation_endpoint_auth_methods_supported = ["client_secret_post", "client_secret_basic"] return metadata def build_resource_metadata_url(resource_server_url: AnyHttpUrl) -> AnyHttpUrl: """Build RFC 9728 compliant protected resource metadata URL. Inserts /.well-known/oauth-protected-resource between host and resource path as specified in RFC 9728 §3.1. Args: resource_server_url: The resource server URL (e.g., https://example.com/mcp) Returns: The metadata URL (e.g., https://example.com/.well-known/oauth-protected-resource/mcp) """ parsed = urlparse(str(resource_server_url)) # Handle trailing slash: if path is just "/", treat as empty resource_path = parsed.path if parsed.path != "/" else "" return AnyHttpUrl(f"{parsed.scheme}://{parsed.netloc}/.well-known/oauth-protected-resource{resource_path}") def create_protected_resource_routes( resource_url: AnyHttpUrl, authorization_servers: list[AnyHttpUrl], scopes_supported: list[str] | None = None, resource_name: str | None = None, resource_documentation: AnyHttpUrl | None = None, ) -> list[Route]: """Create routes for OAuth 2.0 Protected Resource Metadata (RFC 9728). Args: resource_url: The URL of this resource server authorization_servers: List of authorization servers that can issue tokens scopes_supported: Optional list of scopes supported by this resource resource_name: Optional human-readable name for this resource resource_documentation: Optional URL to documentation for this resource Returns: List of Starlette routes for protected resource metadata """ metadata = ProtectedResourceMetadata( resource=resource_url, authorization_servers=authorization_servers, scopes_supported=scopes_supported, resource_name=resource_name, resource_documentation=resource_documentation, # bearer_methods_supported defaults to ["header"] in the model ) handler = ProtectedResourceMetadataHandler(metadata) # RFC 9728 §3.1: Register route at /.well-known/oauth-protected-resource + resource path metadata_url = build_resource_metadata_url(resource_url) # Extract just the path part for route registration parsed = urlparse(str(metadata_url)) well_known_path = parsed.path return [ Route( well_known_path, endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), methods=["GET", "OPTIONS"], ) ]