chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Shared scaffolding the auth/hosting stories import (not teaching surface)."""
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Minimal in-process OAuth pieces for the auth stories.
|
||||
|
||||
A story-shaped subset; ``tests/interaction/auth`` keeps its own (richer) provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import httpx
|
||||
from pydantic import AnyHttpUrl
|
||||
|
||||
from mcp.server.auth.provider import (
|
||||
AccessToken,
|
||||
AuthorizationCode,
|
||||
AuthorizationParams,
|
||||
OAuthAuthorizationServerProvider,
|
||||
RefreshToken,
|
||||
construct_redirect_uri,
|
||||
)
|
||||
from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions
|
||||
from mcp.shared.auth import AuthorizationCodeResult, OAuthClientInformationFull, OAuthToken
|
||||
|
||||
BASE_URL = "http://127.0.0.1:8000"
|
||||
MCP_URL = f"{BASE_URL}/mcp"
|
||||
REDIRECT_URI = f"{BASE_URL}/oauth/callback"
|
||||
|
||||
|
||||
class InMemoryTokenStorage:
|
||||
"""A ``TokenStorage`` that keeps tokens and DCR client info on instance attributes."""
|
||||
|
||||
tokens: OAuthToken | None = None
|
||||
client_info: OAuthClientInformationFull | None = None
|
||||
|
||||
async def get_tokens(self) -> OAuthToken | None:
|
||||
return self.tokens
|
||||
|
||||
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||
self.tokens = tokens
|
||||
|
||||
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||
return self.client_info
|
||||
|
||||
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||
self.client_info = client_info
|
||||
|
||||
|
||||
class HeadlessOAuth:
|
||||
"""Completes the authorize redirect in-process via the bound ``httpx`` client."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.authorize_url: str | None = None
|
||||
self._http: httpx.AsyncClient | None = None
|
||||
self._result = AuthorizationCodeResult(code="", state=None)
|
||||
|
||||
def bind(self, http_client: httpx.AsyncClient) -> None:
|
||||
self._http = http_client
|
||||
|
||||
async def redirect_handler(self, authorization_url: str) -> None:
|
||||
assert self._http is not None
|
||||
self.authorize_url = authorization_url
|
||||
# ``auth=None`` is load-bearing: re-entering the locked auth flow would deadlock.
|
||||
response = await self._http.get(authorization_url, follow_redirects=False, auth=None)
|
||||
assert response.status_code == 302, f"authorize returned {response.status_code}: {response.text}"
|
||||
params = parse_qs(urlsplit(response.headers["location"]).query)
|
||||
self._result = AuthorizationCodeResult(code=params.get("code", [""])[0], state=params.get("state", [None])[0])
|
||||
|
||||
async def callback_handler(self) -> AuthorizationCodeResult:
|
||||
return self._result
|
||||
|
||||
|
||||
class InMemoryAuthorizationServerProvider(
|
||||
OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]
|
||||
):
|
||||
"""Minimal demo AS: DCR + authorize + auth-code exchange held in instance dicts.
|
||||
|
||||
``authorize`` auto-consents only when ``OAUTH_DEMO_AUTO_CONSENT=1``; otherwise it redirects
|
||||
with ``error=interaction_required`` so a manual run shows where a real browser would open.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.clients: dict[str, OAuthClientInformationFull] = {}
|
||||
self.codes: dict[str, AuthorizationCode] = {}
|
||||
self.access_tokens: dict[str, AccessToken] = {}
|
||||
|
||||
def mint_access_token(
|
||||
self, *, client_id: str, scopes: list[str], resource: str | None = None, subject: str | None = None
|
||||
) -> str:
|
||||
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()) + 3600,
|
||||
resource=resource,
|
||||
subject=subject,
|
||||
)
|
||||
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:
|
||||
target = str(params.redirect_uri)
|
||||
if os.environ.get("OAUTH_DEMO_AUTO_CONSENT") != "1":
|
||||
return construct_redirect_uri(target, error="interaction_required", state=params.state)
|
||||
assert client.client_id is not None
|
||||
code = AuthorizationCode(
|
||||
code=f"code_{secrets.token_hex(16)}",
|
||||
client_id=client.client_id,
|
||||
scopes=params.scopes or ["mcp"],
|
||||
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
|
||||
return construct_redirect_uri(target, code=code.code, state=params.state)
|
||||
|
||||
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:
|
||||
scopes = authorization_code.scopes
|
||||
access = self.mint_access_token(
|
||||
client_id=authorization_code.client_id, scopes=scopes, resource=authorization_code.resource
|
||||
)
|
||||
del self.codes[authorization_code.code]
|
||||
return OAuthToken(access_token=access, token_type="Bearer", expires_in=3600, scope=" ".join(scopes))
|
||||
|
||||
async def load_access_token(self, token: str) -> AccessToken | None:
|
||||
return self.access_tokens.get(token)
|
||||
|
||||
async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> RefreshToken | None:
|
||||
raise NotImplementedError
|
||||
|
||||
async def exchange_refresh_token(
|
||||
self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
|
||||
) -> OAuthToken:
|
||||
raise NotImplementedError
|
||||
|
||||
async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def auth_settings(
|
||||
*, required_scopes: list[str] | None = None, identity_assertion_enabled: bool = False
|
||||
) -> AuthSettings:
|
||||
"""``AuthSettings`` for the co-hosted demo AS+RS on the loopback origin, DCR enabled.
|
||||
|
||||
``identity_assertion_enabled`` passes through to the SEP-990 jwt-bearer grant flag.
|
||||
"""
|
||||
scopes = required_scopes or ["mcp"]
|
||||
return AuthSettings(
|
||||
issuer_url=AnyHttpUrl(BASE_URL),
|
||||
resource_server_url=AnyHttpUrl(MCP_URL),
|
||||
required_scopes=scopes,
|
||||
client_registration_options=ClientRegistrationOptions(enabled=True, valid_scopes=scopes, default_scopes=scopes),
|
||||
identity_assertion_enabled=identity_assertion_enabled,
|
||||
)
|
||||
Reference in New Issue
Block a user