Implement RFC 7523 JWT flows (#1247)
Co-authored-by: Yann Jouanin <yann.jouanin@valueandco.com>
This commit is contained in:
parent
db9e451551
commit
f161149680
10 changed files with 463 additions and 52 deletions
|
|
@ -73,6 +73,8 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Re
|
||||||
|
|
||||||
async def register_client(self, client_info: OAuthClientInformationFull):
|
async def register_client(self, client_info: OAuthClientInformationFull):
|
||||||
"""Register a new OAuth client."""
|
"""Register a new OAuth client."""
|
||||||
|
if not client_info.client_id:
|
||||||
|
raise ValueError("No client_id provided")
|
||||||
self.clients[client_info.client_id] = client_info
|
self.clients[client_info.client_id] = client_info
|
||||||
|
|
||||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||||
|
|
@ -209,6 +211,8 @@ class SimpleOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Re
|
||||||
"""Exchange authorization code for tokens."""
|
"""Exchange authorization code for tokens."""
|
||||||
if authorization_code.code not in self.auth_codes:
|
if authorization_code.code not in self.auth_codes:
|
||||||
raise ValueError("Invalid authorization code")
|
raise ValueError("Invalid authorization code")
|
||||||
|
if not client.client_id:
|
||||||
|
raise ValueError("No client_id provided")
|
||||||
|
|
||||||
# Generate MCP access token
|
# Generate MCP access token
|
||||||
mcp_token = f"mcp_{secrets.token_hex(32)}"
|
mcp_token = f"mcp_{secrets.token_hex(32)}"
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ dependencies = [
|
||||||
"uvicorn>=0.31.1; sys_platform != 'emscripten'",
|
"uvicorn>=0.31.1; sys_platform != 'emscripten'",
|
||||||
"jsonschema>=4.20.0",
|
"jsonschema>=4.20.0",
|
||||||
"pywin32>=310; sys_platform == 'win32'",
|
"pywin32>=310; sys_platform == 'win32'",
|
||||||
|
"pyjwt[crypto]>=2.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|
|
||||||
7
src/mcp/client/auth/__init__.py
Normal file
7
src/mcp/client/auth/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
"""
|
||||||
|
OAuth2 Authentication implementation for HTTPX.
|
||||||
|
|
||||||
|
Implements authorization code flow with PKCE and automatic token refresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from mcp.client.auth.oauth2 import * # noqa: F403
|
||||||
148
src/mcp/client/auth/extensions/client_credentials.py
Normal file
148
src/mcp/client/auth/extensions/client_credentials.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
import time
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import jwt
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from mcp.client.auth import OAuthClientProvider, OAuthFlowError, OAuthTokenError, TokenStorage
|
||||||
|
from mcp.shared.auth import OAuthClientMetadata
|
||||||
|
|
||||||
|
|
||||||
|
class JWTParameters(BaseModel):
|
||||||
|
"""JWT parameters."""
|
||||||
|
|
||||||
|
assertion: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="JWT assertion for JWT authentication. "
|
||||||
|
"Will be used instead of generating a new assertion if provided.",
|
||||||
|
)
|
||||||
|
|
||||||
|
issuer: str | None = Field(default=None, description="Issuer for JWT assertions.")
|
||||||
|
subject: str | None = Field(default=None, description="Subject identifier for JWT assertions.")
|
||||||
|
audience: str | None = Field(default=None, description="Audience for JWT assertions.")
|
||||||
|
claims: dict[str, Any] | None = Field(default=None, description="Additional claims for JWT assertions.")
|
||||||
|
jwt_signing_algorithm: str | None = Field(default="RS256", description="Algorithm for signing JWT assertions.")
|
||||||
|
jwt_signing_key: str | None = Field(default=None, description="Private key for JWT signing.")
|
||||||
|
jwt_lifetime_seconds: int = Field(default=300, description="Lifetime of generated JWT in seconds.")
|
||||||
|
|
||||||
|
def to_assertion(self, with_audience_fallback: str | None = None) -> str:
|
||||||
|
if self.assertion is not None:
|
||||||
|
# Prebuilt JWT (e.g. acquired out-of-band)
|
||||||
|
assertion = self.assertion
|
||||||
|
else:
|
||||||
|
if not self.jwt_signing_key:
|
||||||
|
raise OAuthFlowError("Missing signing key for JWT bearer grant")
|
||||||
|
if not self.issuer:
|
||||||
|
raise OAuthFlowError("Missing issuer for JWT bearer grant")
|
||||||
|
if not self.subject:
|
||||||
|
raise OAuthFlowError("Missing subject for JWT bearer grant")
|
||||||
|
|
||||||
|
audience = self.audience if self.audience else with_audience_fallback
|
||||||
|
if not audience:
|
||||||
|
raise OAuthFlowError("Missing audience for JWT bearer grant")
|
||||||
|
|
||||||
|
now = int(time.time())
|
||||||
|
claims: dict[str, Any] = {
|
||||||
|
"iss": self.issuer,
|
||||||
|
"sub": self.subject,
|
||||||
|
"aud": audience,
|
||||||
|
"exp": now + self.jwt_lifetime_seconds,
|
||||||
|
"iat": now,
|
||||||
|
"jti": str(uuid4()),
|
||||||
|
}
|
||||||
|
claims.update(self.claims or {})
|
||||||
|
|
||||||
|
assertion = jwt.encode(
|
||||||
|
claims,
|
||||||
|
self.jwt_signing_key,
|
||||||
|
algorithm=self.jwt_signing_algorithm or "RS256",
|
||||||
|
)
|
||||||
|
return assertion
|
||||||
|
|
||||||
|
|
||||||
|
class RFC7523OAuthClientProvider(OAuthClientProvider):
|
||||||
|
"""OAuth client provider for RFC7532 clients."""
|
||||||
|
|
||||||
|
jwt_parameters: JWTParameters | None = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_url: str,
|
||||||
|
client_metadata: OAuthClientMetadata,
|
||||||
|
storage: TokenStorage,
|
||||||
|
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
|
||||||
|
timeout: float = 300.0,
|
||||||
|
jwt_parameters: JWTParameters | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(server_url, client_metadata, storage, redirect_handler, callback_handler, timeout)
|
||||||
|
self.jwt_parameters = jwt_parameters
|
||||||
|
|
||||||
|
async def _exchange_token_authorization_code(
|
||||||
|
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = None
|
||||||
|
) -> httpx.Request:
|
||||||
|
"""Build token exchange request for authorization_code flow."""
|
||||||
|
token_data = token_data or {}
|
||||||
|
if self.context.client_metadata.token_endpoint_auth_method == "private_key_jwt":
|
||||||
|
self._add_client_authentication_jwt(token_data=token_data)
|
||||||
|
return await super()._exchange_token_authorization_code(auth_code, code_verifier, token_data=token_data)
|
||||||
|
|
||||||
|
async def _perform_authorization(self) -> httpx.Request:
|
||||||
|
"""Perform the authorization flow."""
|
||||||
|
if "urn:ietf:params:oauth:grant-type:jwt-bearer" in self.context.client_metadata.grant_types:
|
||||||
|
token_request = await self._exchange_token_jwt_bearer()
|
||||||
|
return token_request
|
||||||
|
else:
|
||||||
|
return await super()._perform_authorization()
|
||||||
|
|
||||||
|
def _add_client_authentication_jwt(self, *, token_data: dict[str, Any]):
|
||||||
|
"""Add JWT assertion for client authentication to token endpoint parameters."""
|
||||||
|
if not self.jwt_parameters:
|
||||||
|
raise OAuthTokenError("Missing JWT parameters for private_key_jwt flow")
|
||||||
|
if not self.context.oauth_metadata:
|
||||||
|
raise OAuthTokenError("Missing OAuth metadata for private_key_jwt flow")
|
||||||
|
|
||||||
|
# We need to set the audience to the issuer identifier of the authorization server
|
||||||
|
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
|
||||||
|
issuer = str(self.context.oauth_metadata.issuer)
|
||||||
|
assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
|
||||||
|
|
||||||
|
# When using private_key_jwt, in a client_credentials flow, we use RFC 7523 Section 2.2
|
||||||
|
token_data["client_assertion"] = assertion
|
||||||
|
token_data["client_assertion_type"] = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||||
|
# We need to set the audience to the resource server, the audience is difference from the one in claims
|
||||||
|
# it represents the resource server that will validate the token
|
||||||
|
token_data["audience"] = self.context.get_resource_url()
|
||||||
|
|
||||||
|
async def _exchange_token_jwt_bearer(self) -> httpx.Request:
|
||||||
|
"""Build token exchange request for JWT bearer grant."""
|
||||||
|
if not self.context.client_info:
|
||||||
|
raise OAuthFlowError("Missing client info")
|
||||||
|
if not self.jwt_parameters:
|
||||||
|
raise OAuthFlowError("Missing JWT parameters")
|
||||||
|
if not self.context.oauth_metadata:
|
||||||
|
raise OAuthTokenError("Missing OAuth metadata")
|
||||||
|
|
||||||
|
# We need to set the audience to the issuer identifier of the authorization server
|
||||||
|
# https://datatracker.ietf.org/doc/html/draft-ietf-oauth-rfc7523bis-01#name-updates-to-rfc-7523
|
||||||
|
issuer = str(self.context.oauth_metadata.issuer)
|
||||||
|
assertion = self.jwt_parameters.to_assertion(with_audience_fallback=issuer)
|
||||||
|
|
||||||
|
token_data = {
|
||||||
|
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||||
|
"assertion": assertion,
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.context.should_include_resource_param(self.context.protocol_version):
|
||||||
|
token_data["resource"] = self.context.get_resource_url()
|
||||||
|
|
||||||
|
if self.context.client_metadata.scope:
|
||||||
|
token_data["scope"] = self.context.client_metadata.scope
|
||||||
|
|
||||||
|
token_url = self._get_token_endpoint()
|
||||||
|
return httpx.Request(
|
||||||
|
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
634
src/mcp/client/auth/oauth2.py
Normal file
634
src/mcp/client/auth/oauth2.py
Normal file
|
|
@ -0,0 +1,634 @@
|
||||||
|
"""
|
||||||
|
OAuth2 Authentication implementation for HTTPX.
|
||||||
|
|
||||||
|
Implements authorization code flow with PKCE and automatic token refresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
import time
|
||||||
|
from collections.abc import AsyncGenerator, Awaitable, Callable
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Protocol
|
||||||
|
from urllib.parse import urlencode, urljoin, urlparse
|
||||||
|
|
||||||
|
import anyio
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, Field, ValidationError
|
||||||
|
|
||||||
|
from mcp.client.streamable_http import MCP_PROTOCOL_VERSION
|
||||||
|
from mcp.shared.auth import (
|
||||||
|
OAuthClientInformationFull,
|
||||||
|
OAuthClientMetadata,
|
||||||
|
OAuthMetadata,
|
||||||
|
OAuthToken,
|
||||||
|
ProtectedResourceMetadata,
|
||||||
|
)
|
||||||
|
from mcp.shared.auth_utils import check_resource_allowed, resource_url_from_server_url
|
||||||
|
from mcp.types import LATEST_PROTOCOL_VERSION
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthFlowError(Exception):
|
||||||
|
"""Base exception for OAuth flow errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthTokenError(OAuthFlowError):
|
||||||
|
"""Raised when token operations fail."""
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthRegistrationError(OAuthFlowError):
|
||||||
|
"""Raised when client registration fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class PKCEParameters(BaseModel):
|
||||||
|
"""PKCE (Proof Key for Code Exchange) parameters."""
|
||||||
|
|
||||||
|
code_verifier: str = Field(..., min_length=43, max_length=128)
|
||||||
|
code_challenge: str = Field(..., min_length=43, max_length=128)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def generate(cls) -> "PKCEParameters":
|
||||||
|
"""Generate new PKCE parameters."""
|
||||||
|
code_verifier = "".join(secrets.choice(string.ascii_letters + string.digits + "-._~") for _ in range(128))
|
||||||
|
digest = hashlib.sha256(code_verifier.encode()).digest()
|
||||||
|
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
|
||||||
|
return cls(code_verifier=code_verifier, code_challenge=code_challenge)
|
||||||
|
|
||||||
|
|
||||||
|
class TokenStorage(Protocol):
|
||||||
|
"""Protocol for token storage implementations."""
|
||||||
|
|
||||||
|
async def get_tokens(self) -> OAuthToken | None:
|
||||||
|
"""Get stored tokens."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def set_tokens(self, tokens: OAuthToken) -> None:
|
||||||
|
"""Store tokens."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def get_client_info(self) -> OAuthClientInformationFull | None:
|
||||||
|
"""Get stored client information."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
|
||||||
|
"""Store client information."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OAuthContext:
|
||||||
|
"""OAuth flow context."""
|
||||||
|
|
||||||
|
server_url: str
|
||||||
|
client_metadata: OAuthClientMetadata
|
||||||
|
storage: TokenStorage
|
||||||
|
redirect_handler: Callable[[str], Awaitable[None]] | None
|
||||||
|
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None
|
||||||
|
timeout: float = 300.0
|
||||||
|
|
||||||
|
# Discovered metadata
|
||||||
|
protected_resource_metadata: ProtectedResourceMetadata | None = None
|
||||||
|
oauth_metadata: OAuthMetadata | None = None
|
||||||
|
auth_server_url: str | None = None
|
||||||
|
protocol_version: str | None = None
|
||||||
|
|
||||||
|
# Client registration
|
||||||
|
client_info: OAuthClientInformationFull | None = None
|
||||||
|
|
||||||
|
# Token management
|
||||||
|
current_tokens: OAuthToken | None = None
|
||||||
|
token_expiry_time: float | None = None
|
||||||
|
|
||||||
|
# State
|
||||||
|
lock: anyio.Lock = field(default_factory=anyio.Lock)
|
||||||
|
|
||||||
|
# Discovery state for fallback support
|
||||||
|
discovery_base_url: str | None = None
|
||||||
|
discovery_pathname: str | None = None
|
||||||
|
|
||||||
|
def get_authorization_base_url(self, server_url: str) -> str:
|
||||||
|
"""Extract base URL by removing path component."""
|
||||||
|
parsed = urlparse(server_url)
|
||||||
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
|
||||||
|
def update_token_expiry(self, token: OAuthToken) -> None:
|
||||||
|
"""Update token expiry time."""
|
||||||
|
if token.expires_in:
|
||||||
|
self.token_expiry_time = time.time() + token.expires_in
|
||||||
|
else:
|
||||||
|
self.token_expiry_time = None
|
||||||
|
|
||||||
|
def is_token_valid(self) -> bool:
|
||||||
|
"""Check if current token is valid."""
|
||||||
|
return bool(
|
||||||
|
self.current_tokens
|
||||||
|
and self.current_tokens.access_token
|
||||||
|
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
|
||||||
|
)
|
||||||
|
|
||||||
|
def can_refresh_token(self) -> bool:
|
||||||
|
"""Check if token can be refreshed."""
|
||||||
|
return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info)
|
||||||
|
|
||||||
|
def clear_tokens(self) -> None:
|
||||||
|
"""Clear current tokens."""
|
||||||
|
self.current_tokens = None
|
||||||
|
self.token_expiry_time = None
|
||||||
|
|
||||||
|
def get_resource_url(self) -> str:
|
||||||
|
"""Get resource URL for RFC 8707.
|
||||||
|
|
||||||
|
Uses PRM resource if it's a valid parent, otherwise uses canonical server URL.
|
||||||
|
"""
|
||||||
|
resource = resource_url_from_server_url(self.server_url)
|
||||||
|
|
||||||
|
# If PRM provides a resource that's a valid parent, use it
|
||||||
|
if self.protected_resource_metadata and self.protected_resource_metadata.resource:
|
||||||
|
prm_resource = str(self.protected_resource_metadata.resource)
|
||||||
|
if check_resource_allowed(requested_resource=resource, configured_resource=prm_resource):
|
||||||
|
resource = prm_resource
|
||||||
|
|
||||||
|
return resource
|
||||||
|
|
||||||
|
def should_include_resource_param(self, protocol_version: str | None = None) -> bool:
|
||||||
|
"""Determine if the resource parameter should be included in OAuth requests.
|
||||||
|
|
||||||
|
Returns True if:
|
||||||
|
- Protected resource metadata is available, OR
|
||||||
|
- MCP-Protocol-Version header is 2025-06-18 or later
|
||||||
|
"""
|
||||||
|
# If we have protected resource metadata, include the resource param
|
||||||
|
if self.protected_resource_metadata is not None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# If no protocol version provided, don't include resource param
|
||||||
|
if not protocol_version:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if protocol version is 2025-06-18 or later
|
||||||
|
# Version format is YYYY-MM-DD, so string comparison works
|
||||||
|
return protocol_version >= "2025-06-18"
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthClientProvider(httpx.Auth):
|
||||||
|
"""
|
||||||
|
OAuth2 authentication for httpx.
|
||||||
|
Handles OAuth flow with automatic client registration and token storage.
|
||||||
|
"""
|
||||||
|
|
||||||
|
requires_response_body = True
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
server_url: str,
|
||||||
|
client_metadata: OAuthClientMetadata,
|
||||||
|
storage: TokenStorage,
|
||||||
|
redirect_handler: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
callback_handler: Callable[[], Awaitable[tuple[str, str | None]]] | None = None,
|
||||||
|
timeout: float = 300.0,
|
||||||
|
):
|
||||||
|
"""Initialize OAuth2 authentication."""
|
||||||
|
self.context = OAuthContext(
|
||||||
|
server_url=server_url,
|
||||||
|
client_metadata=client_metadata,
|
||||||
|
storage=storage,
|
||||||
|
redirect_handler=redirect_handler,
|
||||||
|
callback_handler=callback_handler,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
def _extract_field_from_www_auth(self, init_response: httpx.Response, field_name: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Extract field from WWW-Authenticate header.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Field value if found in WWW-Authenticate header, None otherwise
|
||||||
|
"""
|
||||||
|
www_auth_header = init_response.headers.get("WWW-Authenticate")
|
||||||
|
if not www_auth_header:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Pattern matches: field_name="value" or field_name=value (unquoted)
|
||||||
|
pattern = rf'{field_name}=(?:"([^"]+)"|([^\s,]+))'
|
||||||
|
match = re.search(pattern, www_auth_header)
|
||||||
|
|
||||||
|
if match:
|
||||||
|
# Return quoted value if present, otherwise unquoted value
|
||||||
|
return match.group(1) or match.group(2)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _extract_resource_metadata_from_www_auth(self, init_response: httpx.Response) -> str | None:
|
||||||
|
"""
|
||||||
|
Extract protected resource metadata URL from WWW-Authenticate header as per RFC9728.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Resource metadata URL if found in WWW-Authenticate header, None otherwise
|
||||||
|
"""
|
||||||
|
if not init_response or init_response.status_code != 401:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self._extract_field_from_www_auth(init_response, "resource_metadata")
|
||||||
|
|
||||||
|
def _extract_scope_from_www_auth(self, init_response: httpx.Response) -> str | None:
|
||||||
|
"""
|
||||||
|
Extract scope parameter from WWW-Authenticate header as per RFC6750.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Scope string if found in WWW-Authenticate header, None otherwise
|
||||||
|
"""
|
||||||
|
return self._extract_field_from_www_auth(init_response, "scope")
|
||||||
|
|
||||||
|
async def _discover_protected_resource(self, init_response: httpx.Response) -> httpx.Request:
|
||||||
|
# RFC9728: Try to extract resource_metadata URL from WWW-Authenticate header of the initial response
|
||||||
|
url = self._extract_resource_metadata_from_www_auth(init_response)
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
# Fallback to well-known discovery
|
||||||
|
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||||
|
url = urljoin(auth_base_url, "/.well-known/oauth-protected-resource")
|
||||||
|
|
||||||
|
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
|
||||||
|
|
||||||
|
async def _handle_protected_resource_response(self, response: httpx.Response) -> None:
|
||||||
|
"""Handle discovery response."""
|
||||||
|
if response.status_code == 200:
|
||||||
|
try:
|
||||||
|
content = await response.aread()
|
||||||
|
metadata = ProtectedResourceMetadata.model_validate_json(content)
|
||||||
|
self.context.protected_resource_metadata = metadata
|
||||||
|
if metadata.authorization_servers:
|
||||||
|
self.context.auth_server_url = str(metadata.authorization_servers[0])
|
||||||
|
|
||||||
|
except ValidationError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise OAuthFlowError(f"Protected Resource Metadata request failed: {response.status_code}")
|
||||||
|
|
||||||
|
def _select_scopes(self, init_response: httpx.Response) -> None:
|
||||||
|
"""Select scopes as outlined in the 'Scope Selection Strategy in the MCP spec."""
|
||||||
|
# Per MCP spec, scope selection priority order:
|
||||||
|
# 1. Use scope from WWW-Authenticate header (if provided)
|
||||||
|
# 2. Use all scopes from PRM scopes_supported (if available)
|
||||||
|
# 3. Omit scope parameter if neither is available
|
||||||
|
#
|
||||||
|
www_authenticate_scope = self._extract_scope_from_www_auth(init_response)
|
||||||
|
if www_authenticate_scope is not None:
|
||||||
|
# Priority 1: WWW-Authenticate header scope
|
||||||
|
self.context.client_metadata.scope = www_authenticate_scope
|
||||||
|
elif (
|
||||||
|
self.context.protected_resource_metadata is not None
|
||||||
|
and self.context.protected_resource_metadata.scopes_supported is not None
|
||||||
|
):
|
||||||
|
# Priority 2: PRM scopes_supported
|
||||||
|
self.context.client_metadata.scope = " ".join(self.context.protected_resource_metadata.scopes_supported)
|
||||||
|
else:
|
||||||
|
# Priority 3: Omit scope parameter
|
||||||
|
self.context.client_metadata.scope = None
|
||||||
|
|
||||||
|
def _get_discovery_urls(self) -> list[str]:
|
||||||
|
"""Generate ordered list of (url, type) tuples for discovery attempts."""
|
||||||
|
urls: list[str] = []
|
||||||
|
auth_server_url = self.context.auth_server_url or self.context.server_url
|
||||||
|
parsed = urlparse(auth_server_url)
|
||||||
|
base_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
|
||||||
|
# RFC 8414: Path-aware OAuth discovery
|
||||||
|
if parsed.path and parsed.path != "/":
|
||||||
|
oauth_path = f"/.well-known/oauth-authorization-server{parsed.path.rstrip('/')}"
|
||||||
|
urls.append(urljoin(base_url, oauth_path))
|
||||||
|
|
||||||
|
# OAuth root fallback
|
||||||
|
urls.append(urljoin(base_url, "/.well-known/oauth-authorization-server"))
|
||||||
|
|
||||||
|
# RFC 8414 section 5: Path-aware OIDC discovery
|
||||||
|
# See https://www.rfc-editor.org/rfc/rfc8414.html#section-5
|
||||||
|
if parsed.path and parsed.path != "/":
|
||||||
|
oidc_path = f"/.well-known/openid-configuration{parsed.path.rstrip('/')}"
|
||||||
|
urls.append(urljoin(base_url, oidc_path))
|
||||||
|
|
||||||
|
# OIDC 1.0 fallback (appends to full URL per OIDC spec)
|
||||||
|
oidc_fallback = f"{auth_server_url.rstrip('/')}/.well-known/openid-configuration"
|
||||||
|
urls.append(oidc_fallback)
|
||||||
|
|
||||||
|
return urls
|
||||||
|
|
||||||
|
async def _register_client(self) -> httpx.Request | None:
|
||||||
|
"""Build registration request or skip if already registered."""
|
||||||
|
if self.context.client_info:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.context.oauth_metadata and self.context.oauth_metadata.registration_endpoint:
|
||||||
|
registration_url = str(self.context.oauth_metadata.registration_endpoint)
|
||||||
|
else:
|
||||||
|
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||||
|
registration_url = urljoin(auth_base_url, "/register")
|
||||||
|
|
||||||
|
registration_data = self.context.client_metadata.model_dump(by_alias=True, mode="json", exclude_none=True)
|
||||||
|
|
||||||
|
return httpx.Request(
|
||||||
|
"POST", registration_url, json=registration_data, headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_registration_response(self, response: httpx.Response) -> None:
|
||||||
|
"""Handle registration response."""
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
await response.aread()
|
||||||
|
raise OAuthRegistrationError(f"Registration failed: {response.status_code} {response.text}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await response.aread()
|
||||||
|
client_info = OAuthClientInformationFull.model_validate_json(content)
|
||||||
|
self.context.client_info = client_info
|
||||||
|
await self.context.storage.set_client_info(client_info)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise OAuthRegistrationError(f"Invalid registration response: {e}")
|
||||||
|
|
||||||
|
async def _perform_authorization(self) -> httpx.Request:
|
||||||
|
"""Perform the authorization flow."""
|
||||||
|
auth_code, code_verifier = await self._perform_authorization_code_grant()
|
||||||
|
token_request = await self._exchange_token_authorization_code(auth_code, code_verifier)
|
||||||
|
return token_request
|
||||||
|
|
||||||
|
async def _perform_authorization_code_grant(self) -> tuple[str, str]:
|
||||||
|
"""Perform the authorization redirect and get auth code."""
|
||||||
|
if self.context.client_metadata.redirect_uris is None:
|
||||||
|
raise OAuthFlowError("No redirect URIs provided for authorization code grant")
|
||||||
|
if not self.context.redirect_handler:
|
||||||
|
raise OAuthFlowError("No redirect handler provided for authorization code grant")
|
||||||
|
if not self.context.callback_handler:
|
||||||
|
raise OAuthFlowError("No callback handler provided for authorization code grant")
|
||||||
|
|
||||||
|
if self.context.oauth_metadata and self.context.oauth_metadata.authorization_endpoint:
|
||||||
|
auth_endpoint = str(self.context.oauth_metadata.authorization_endpoint)
|
||||||
|
else:
|
||||||
|
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||||
|
auth_endpoint = urljoin(auth_base_url, "/authorize")
|
||||||
|
|
||||||
|
if not self.context.client_info:
|
||||||
|
raise OAuthFlowError("No client info available for authorization")
|
||||||
|
|
||||||
|
# Generate PKCE parameters
|
||||||
|
pkce_params = PKCEParameters.generate()
|
||||||
|
state = secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
auth_params = {
|
||||||
|
"response_type": "code",
|
||||||
|
"client_id": self.context.client_info.client_id,
|
||||||
|
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
|
||||||
|
"state": state,
|
||||||
|
"code_challenge": pkce_params.code_challenge,
|
||||||
|
"code_challenge_method": "S256",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only include resource param if conditions are met
|
||||||
|
if self.context.should_include_resource_param(self.context.protocol_version):
|
||||||
|
auth_params["resource"] = self.context.get_resource_url() # RFC 8707
|
||||||
|
|
||||||
|
if self.context.client_metadata.scope:
|
||||||
|
auth_params["scope"] = self.context.client_metadata.scope
|
||||||
|
|
||||||
|
authorization_url = f"{auth_endpoint}?{urlencode(auth_params)}"
|
||||||
|
await self.context.redirect_handler(authorization_url)
|
||||||
|
|
||||||
|
# Wait for callback
|
||||||
|
auth_code, returned_state = await self.context.callback_handler()
|
||||||
|
|
||||||
|
if returned_state is None or not secrets.compare_digest(returned_state, state):
|
||||||
|
raise OAuthFlowError(f"State parameter mismatch: {returned_state} != {state}")
|
||||||
|
|
||||||
|
if not auth_code:
|
||||||
|
raise OAuthFlowError("No authorization code received")
|
||||||
|
|
||||||
|
# Return auth code and code verifier for token exchange
|
||||||
|
return auth_code, pkce_params.code_verifier
|
||||||
|
|
||||||
|
def _get_token_endpoint(self) -> str:
|
||||||
|
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
||||||
|
token_url = str(self.context.oauth_metadata.token_endpoint)
|
||||||
|
else:
|
||||||
|
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||||
|
token_url = urljoin(auth_base_url, "/token")
|
||||||
|
return token_url
|
||||||
|
|
||||||
|
async def _exchange_token_authorization_code(
|
||||||
|
self, auth_code: str, code_verifier: str, *, token_data: dict[str, Any] | None = {}
|
||||||
|
) -> httpx.Request:
|
||||||
|
"""Build token exchange request for authorization_code flow."""
|
||||||
|
if self.context.client_metadata.redirect_uris is None:
|
||||||
|
raise OAuthFlowError("No redirect URIs provided for authorization code grant")
|
||||||
|
if not self.context.client_info:
|
||||||
|
raise OAuthFlowError("Missing client info")
|
||||||
|
|
||||||
|
token_url = self._get_token_endpoint()
|
||||||
|
token_data = token_data or {}
|
||||||
|
token_data.update(
|
||||||
|
{
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": auth_code,
|
||||||
|
"redirect_uri": str(self.context.client_metadata.redirect_uris[0]),
|
||||||
|
"client_id": self.context.client_info.client_id,
|
||||||
|
"code_verifier": code_verifier,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Only include resource param if conditions are met
|
||||||
|
if self.context.should_include_resource_param(self.context.protocol_version):
|
||||||
|
token_data["resource"] = self.context.get_resource_url() # RFC 8707
|
||||||
|
|
||||||
|
if self.context.client_info.client_secret:
|
||||||
|
token_data["client_secret"] = self.context.client_info.client_secret
|
||||||
|
|
||||||
|
return httpx.Request(
|
||||||
|
"POST", token_url, data=token_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_token_response(self, response: httpx.Response) -> None:
|
||||||
|
"""Handle token exchange response."""
|
||||||
|
if response.status_code != 200:
|
||||||
|
body = await response.aread()
|
||||||
|
body = body.decode("utf-8")
|
||||||
|
raise OAuthTokenError(f"Token exchange failed ({response.status_code}): {body}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await response.aread()
|
||||||
|
token_response = OAuthToken.model_validate_json(content)
|
||||||
|
|
||||||
|
# Validate scopes
|
||||||
|
if token_response.scope and self.context.client_metadata.scope:
|
||||||
|
requested_scopes = set(self.context.client_metadata.scope.split())
|
||||||
|
returned_scopes = set(token_response.scope.split())
|
||||||
|
unauthorized_scopes = returned_scopes - requested_scopes
|
||||||
|
if unauthorized_scopes:
|
||||||
|
raise OAuthTokenError(f"Server granted unauthorized scopes: {unauthorized_scopes}")
|
||||||
|
|
||||||
|
self.context.current_tokens = token_response
|
||||||
|
self.context.update_token_expiry(token_response)
|
||||||
|
await self.context.storage.set_tokens(token_response)
|
||||||
|
except ValidationError as e:
|
||||||
|
raise OAuthTokenError(f"Invalid token response: {e}")
|
||||||
|
|
||||||
|
async def _refresh_token(self) -> httpx.Request:
|
||||||
|
"""Build token refresh request."""
|
||||||
|
if not self.context.current_tokens or not self.context.current_tokens.refresh_token:
|
||||||
|
raise OAuthTokenError("No refresh token available")
|
||||||
|
|
||||||
|
if not self.context.client_info:
|
||||||
|
raise OAuthTokenError("No client info available")
|
||||||
|
|
||||||
|
if self.context.oauth_metadata and self.context.oauth_metadata.token_endpoint:
|
||||||
|
token_url = str(self.context.oauth_metadata.token_endpoint)
|
||||||
|
else:
|
||||||
|
auth_base_url = self.context.get_authorization_base_url(self.context.server_url)
|
||||||
|
token_url = urljoin(auth_base_url, "/token")
|
||||||
|
|
||||||
|
refresh_data = {
|
||||||
|
"grant_type": "refresh_token",
|
||||||
|
"refresh_token": self.context.current_tokens.refresh_token,
|
||||||
|
"client_id": self.context.client_info.client_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Only include resource param if conditions are met
|
||||||
|
if self.context.should_include_resource_param(self.context.protocol_version):
|
||||||
|
refresh_data["resource"] = self.context.get_resource_url() # RFC 8707
|
||||||
|
|
||||||
|
if self.context.client_info.client_secret:
|
||||||
|
refresh_data["client_secret"] = self.context.client_info.client_secret
|
||||||
|
|
||||||
|
return httpx.Request(
|
||||||
|
"POST", token_url, data=refresh_data, headers={"Content-Type": "application/x-www-form-urlencoded"}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_refresh_response(self, response: httpx.Response) -> bool:
|
||||||
|
"""Handle token refresh response. Returns True if successful."""
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(f"Token refresh failed: {response.status_code}")
|
||||||
|
self.context.clear_tokens()
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = await response.aread()
|
||||||
|
token_response = OAuthToken.model_validate_json(content)
|
||||||
|
|
||||||
|
self.context.current_tokens = token_response
|
||||||
|
self.context.update_token_expiry(token_response)
|
||||||
|
await self.context.storage.set_tokens(token_response)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except ValidationError:
|
||||||
|
logger.exception("Invalid refresh response")
|
||||||
|
self.context.clear_tokens()
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _initialize(self) -> None:
|
||||||
|
"""Load stored tokens and client info."""
|
||||||
|
self.context.current_tokens = await self.context.storage.get_tokens()
|
||||||
|
self.context.client_info = await self.context.storage.get_client_info()
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
def _add_auth_header(self, request: httpx.Request) -> None:
|
||||||
|
"""Add authorization header to request if we have valid tokens."""
|
||||||
|
if self.context.current_tokens and self.context.current_tokens.access_token:
|
||||||
|
request.headers["Authorization"] = f"Bearer {self.context.current_tokens.access_token}"
|
||||||
|
|
||||||
|
def _create_oauth_metadata_request(self, url: str) -> httpx.Request:
|
||||||
|
return httpx.Request("GET", url, headers={MCP_PROTOCOL_VERSION: LATEST_PROTOCOL_VERSION})
|
||||||
|
|
||||||
|
async def _handle_oauth_metadata_response(self, response: httpx.Response) -> None:
|
||||||
|
content = await response.aread()
|
||||||
|
metadata = OAuthMetadata.model_validate_json(content)
|
||||||
|
self.context.oauth_metadata = metadata
|
||||||
|
|
||||||
|
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
|
||||||
|
"""HTTPX auth flow integration."""
|
||||||
|
async with self.context.lock:
|
||||||
|
if not self._initialized:
|
||||||
|
await self._initialize()
|
||||||
|
|
||||||
|
# Capture protocol version from request headers
|
||||||
|
self.context.protocol_version = request.headers.get(MCP_PROTOCOL_VERSION)
|
||||||
|
|
||||||
|
if not self.context.is_token_valid() and self.context.can_refresh_token():
|
||||||
|
# Try to refresh token
|
||||||
|
refresh_request = await self._refresh_token()
|
||||||
|
refresh_response = yield refresh_request
|
||||||
|
|
||||||
|
if not await self._handle_refresh_response(refresh_response):
|
||||||
|
# Refresh failed, need full re-authentication
|
||||||
|
self._initialized = False
|
||||||
|
|
||||||
|
if self.context.is_token_valid():
|
||||||
|
self._add_auth_header(request)
|
||||||
|
|
||||||
|
response = yield request
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
# Perform full OAuth flow
|
||||||
|
try:
|
||||||
|
# OAuth flow must be inline due to generator constraints
|
||||||
|
# Step 1: Discover protected resource metadata (RFC9728 with WWW-Authenticate support)
|
||||||
|
discovery_request = await self._discover_protected_resource(response)
|
||||||
|
discovery_response = yield discovery_request
|
||||||
|
await self._handle_protected_resource_response(discovery_response)
|
||||||
|
|
||||||
|
# Step 2: Apply scope selection strategy
|
||||||
|
self._select_scopes(response)
|
||||||
|
|
||||||
|
# Step 3: Discover OAuth metadata (with fallback for legacy servers)
|
||||||
|
discovery_urls = self._get_discovery_urls()
|
||||||
|
for url in discovery_urls:
|
||||||
|
oauth_metadata_request = self._create_oauth_metadata_request(url)
|
||||||
|
oauth_metadata_response = yield oauth_metadata_request
|
||||||
|
|
||||||
|
if oauth_metadata_response.status_code == 200:
|
||||||
|
try:
|
||||||
|
await self._handle_oauth_metadata_response(oauth_metadata_response)
|
||||||
|
break
|
||||||
|
except ValidationError:
|
||||||
|
continue
|
||||||
|
elif oauth_metadata_response.status_code < 400 or oauth_metadata_response.status_code >= 500:
|
||||||
|
break # Non-4XX error, stop trying
|
||||||
|
|
||||||
|
# Step 4: Register client if needed
|
||||||
|
registration_request = await self._register_client()
|
||||||
|
if registration_request:
|
||||||
|
registration_response = yield registration_request
|
||||||
|
await self._handle_registration_response(registration_response)
|
||||||
|
|
||||||
|
# Step 5: Perform authorization and complete token exchange
|
||||||
|
token_response = yield await self._perform_authorization()
|
||||||
|
await self._handle_token_response(token_response)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("OAuth flow error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Retry with new tokens
|
||||||
|
self._add_auth_header(request)
|
||||||
|
yield request
|
||||||
|
elif response.status_code == 403:
|
||||||
|
# Step 1: Extract error field from WWW-Authenticate header
|
||||||
|
error = self._extract_field_from_www_auth(response, "error")
|
||||||
|
|
||||||
|
# Step 2: Check if we need to step-up authorization
|
||||||
|
if error == "insufficient_scope":
|
||||||
|
try:
|
||||||
|
# Step 2a: Update the required scopes
|
||||||
|
self._select_scopes(response)
|
||||||
|
|
||||||
|
# Step 2b: Perform (re-)authorization and token exchange
|
||||||
|
token_response = yield await self._perform_authorization()
|
||||||
|
await self._handle_token_response(token_response)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("OAuth flow error")
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Retry with new tokens
|
||||||
|
self._add_auth_header(request)
|
||||||
|
yield request
|
||||||
|
|
@ -41,13 +41,13 @@ class OAuthClientMetadata(BaseModel):
|
||||||
for the full specification.
|
for the full specification.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
redirect_uris: list[AnyUrl] = Field(..., min_length=1)
|
redirect_uris: list[AnyUrl] | None = Field(..., min_length=1)
|
||||||
# token_endpoint_auth_method: this implementation only supports none &
|
# supported auth methods for the token endpoint
|
||||||
# client_secret_post;
|
token_endpoint_auth_method: Literal["none", "client_secret_post", "private_key_jwt"] = "client_secret_post"
|
||||||
# ie: we do not support client_secret_basic
|
# supported grant_types of this implementation
|
||||||
token_endpoint_auth_method: Literal["none", "client_secret_post"] = "client_secret_post"
|
grant_types: list[
|
||||||
# grant_types: this implementation only supports authorization_code & refresh_token
|
Literal["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:jwt-bearer"] | str
|
||||||
grant_types: list[Literal["authorization_code", "refresh_token"] | str] = [
|
] = [
|
||||||
"authorization_code",
|
"authorization_code",
|
||||||
"refresh_token",
|
"refresh_token",
|
||||||
]
|
]
|
||||||
|
|
@ -82,10 +82,10 @@ class OAuthClientMetadata(BaseModel):
|
||||||
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
|
def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
|
||||||
if redirect_uri is not None:
|
if redirect_uri is not None:
|
||||||
# Validate redirect_uri against client's registered redirect URIs
|
# Validate redirect_uri against client's registered redirect URIs
|
||||||
if redirect_uri not in self.redirect_uris:
|
if self.redirect_uris is None or redirect_uri not in self.redirect_uris:
|
||||||
raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client")
|
raise InvalidRedirectUriError(f"Redirect URI '{redirect_uri}' not registered for client")
|
||||||
return redirect_uri
|
return redirect_uri
|
||||||
elif len(self.redirect_uris) == 1:
|
elif self.redirect_uris is not None and len(self.redirect_uris) == 1:
|
||||||
return self.redirect_uris[0]
|
return self.redirect_uris[0]
|
||||||
else:
|
else:
|
||||||
raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs")
|
raise InvalidRedirectUriError("redirect_uri must be specified when client has multiple registered URIs")
|
||||||
|
|
@ -97,7 +97,7 @@ class OAuthClientInformationFull(OAuthClientMetadata):
|
||||||
(client information plus metadata).
|
(client information plus metadata).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
client_id: str
|
client_id: str | None = None
|
||||||
client_secret: str | None = None
|
client_secret: str | None = None
|
||||||
client_id_issued_at: int | None = None
|
client_id_issued_at: int | None = None
|
||||||
client_secret_expires_at: int | None = None
|
client_secret_expires_at: int | None = None
|
||||||
|
|
|
||||||
163
tests/client/auth/extensions/test_client_credentials.py
Normal file
163
tests/client/auth/extensions/test_client_credentials.py
Normal file
|
|
@ -0,0 +1,163 @@
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from pydantic import AnyHttpUrl, AnyUrl
|
||||||
|
|
||||||
|
from mcp.client.auth.extensions.client_credentials import JWTParameters, RFC7523OAuthClientProvider
|
||||||
|
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata, OAuthMetadata, OAuthToken
|
||||||
|
|
||||||
|
|
||||||
|
class MockTokenStorage:
|
||||||
|
"""Mock token storage for testing."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._tokens: OAuthToken | None = None
|
||||||
|
self._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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_storage():
|
||||||
|
return MockTokenStorage()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_metadata():
|
||||||
|
return OAuthClientMetadata(
|
||||||
|
client_name="Test Client",
|
||||||
|
client_uri=AnyHttpUrl("https://example.com"),
|
||||||
|
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
||||||
|
scope="read write",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rfc7523_oauth_provider(client_metadata: OAuthClientMetadata, mock_storage: MockTokenStorage):
|
||||||
|
async def redirect_handler(url: str) -> None:
|
||||||
|
"""Mock redirect handler."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def callback_handler() -> tuple[str, str | None]:
|
||||||
|
"""Mock callback handler."""
|
||||||
|
return "test_auth_code", "test_state"
|
||||||
|
|
||||||
|
return RFC7523OAuthClientProvider(
|
||||||
|
server_url="https://api.example.com/v1/mcp",
|
||||||
|
client_metadata=client_metadata,
|
||||||
|
storage=mock_storage,
|
||||||
|
redirect_handler=redirect_handler,
|
||||||
|
callback_handler=callback_handler,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestOAuthFlowClientCredentials:
|
||||||
|
"""Test OAuth flow behavior for client credentials flows."""
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_token_exchange_request_jwt_predefined(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
|
||||||
|
"""Test token exchange request building with a predefined JWT assertion."""
|
||||||
|
# Set up required context
|
||||||
|
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
|
||||||
|
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
|
||||||
|
token_endpoint_auth_method="private_key_jwt",
|
||||||
|
redirect_uris=None,
|
||||||
|
scope="read write",
|
||||||
|
)
|
||||||
|
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
|
||||||
|
issuer=AnyHttpUrl("https://api.example.com"),
|
||||||
|
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
|
||||||
|
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
|
||||||
|
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
|
||||||
|
)
|
||||||
|
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
|
||||||
|
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
|
||||||
|
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
|
||||||
|
# https://www.jwt.io
|
||||||
|
assertion="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
|
||||||
|
)
|
||||||
|
|
||||||
|
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
|
||||||
|
|
||||||
|
assert request.method == "POST"
|
||||||
|
assert str(request.url) == "https://api.example.com/token"
|
||||||
|
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
|
# Check form data
|
||||||
|
content = urllib.parse.unquote_plus(request.content.decode())
|
||||||
|
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
|
||||||
|
assert "scope=read write" in content
|
||||||
|
assert "resource=https://api.example.com/v1/mcp" in content
|
||||||
|
assert (
|
||||||
|
"assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.KMUFsIDTnFmyG3nMiGM6H9FNFUROf3wh7SmqJp-QV30"
|
||||||
|
in content
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_token_exchange_request_jwt(self, rfc7523_oauth_provider: RFC7523OAuthClientProvider):
|
||||||
|
"""Test token exchange request building wiith a generated JWT assertion."""
|
||||||
|
# Set up required context
|
||||||
|
rfc7523_oauth_provider.context.client_info = OAuthClientInformationFull(
|
||||||
|
grant_types=["urn:ietf:params:oauth:grant-type:jwt-bearer"],
|
||||||
|
token_endpoint_auth_method="private_key_jwt",
|
||||||
|
redirect_uris=None,
|
||||||
|
scope="read write",
|
||||||
|
)
|
||||||
|
rfc7523_oauth_provider.context.oauth_metadata = OAuthMetadata(
|
||||||
|
issuer=AnyHttpUrl("https://api.example.com"),
|
||||||
|
authorization_endpoint=AnyHttpUrl("https://api.example.com/authorize"),
|
||||||
|
token_endpoint=AnyHttpUrl("https://api.example.com/token"),
|
||||||
|
registration_endpoint=AnyHttpUrl("https://api.example.com/register"),
|
||||||
|
)
|
||||||
|
rfc7523_oauth_provider.context.client_metadata = rfc7523_oauth_provider.context.client_info
|
||||||
|
rfc7523_oauth_provider.context.protocol_version = "2025-06-18"
|
||||||
|
rfc7523_oauth_provider.jwt_parameters = JWTParameters(
|
||||||
|
issuer="foo",
|
||||||
|
subject="1234567890",
|
||||||
|
claims={
|
||||||
|
"name": "John Doe",
|
||||||
|
"admin": True,
|
||||||
|
"iat": 1516239022,
|
||||||
|
},
|
||||||
|
jwt_signing_algorithm="HS256",
|
||||||
|
jwt_signing_key="a-string-secret-at-least-256-bits-long",
|
||||||
|
jwt_lifetime_seconds=300,
|
||||||
|
)
|
||||||
|
|
||||||
|
request = await rfc7523_oauth_provider._exchange_token_jwt_bearer()
|
||||||
|
|
||||||
|
assert request.method == "POST"
|
||||||
|
assert str(request.url) == "https://api.example.com/token"
|
||||||
|
assert request.headers["Content-Type"] == "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
|
# Check form data
|
||||||
|
content = urllib.parse.unquote_plus(request.content.decode()).split("&")
|
||||||
|
assert "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" in content
|
||||||
|
assert "scope=read write" in content
|
||||||
|
assert "resource=https://api.example.com/v1/mcp" in content
|
||||||
|
|
||||||
|
# Check assertion
|
||||||
|
assertion = next(param for param in content if param.startswith("assertion="))[len("assertion=") :]
|
||||||
|
claims = jwt.decode(
|
||||||
|
assertion,
|
||||||
|
key="a-string-secret-at-least-256-bits-long",
|
||||||
|
algorithms=["HS256"],
|
||||||
|
audience="https://api.example.com/",
|
||||||
|
subject="1234567890",
|
||||||
|
issuer="foo",
|
||||||
|
verify=True,
|
||||||
|
)
|
||||||
|
assert claims["name"] == "John Doe"
|
||||||
|
assert claims["admin"]
|
||||||
|
assert claims["iat"] == 1516239022
|
||||||
|
|
@ -391,7 +391,9 @@ class TestOAuthFallback:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the authorization process to minimize unnecessary state in this test
|
# Mock the authorization process to minimize unnecessary state in this test
|
||||||
oauth_provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
|
oauth_provider._perform_authorization_code_grant = mock.AsyncMock(
|
||||||
|
return_value=("test_auth_code", "test_code_verifier")
|
||||||
|
)
|
||||||
|
|
||||||
# Next request should fall back to legacy behavior and auth with the RS (mocked /authorize, next is /token)
|
# Next request should fall back to legacy behavior and auth with the RS (mocked /authorize, next is /token)
|
||||||
token_request = await auth_flow.asend(oauth_metadata_response_3)
|
token_request = await auth_flow.asend(oauth_metadata_response_3)
|
||||||
|
|
@ -513,7 +515,7 @@ class TestOAuthFallback:
|
||||||
assert request is None
|
assert request is None
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_token_exchange_request(self, oauth_provider: OAuthClientProvider):
|
async def test_token_exchange_request_authorization_code(self, oauth_provider: OAuthClientProvider):
|
||||||
"""Test token exchange request building."""
|
"""Test token exchange request building."""
|
||||||
# Set up required context
|
# Set up required context
|
||||||
oauth_provider.context.client_info = OAuthClientInformationFull(
|
oauth_provider.context.client_info = OAuthClientInformationFull(
|
||||||
|
|
@ -522,7 +524,7 @@ class TestOAuthFallback:
|
||||||
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
|
||||||
)
|
)
|
||||||
|
|
||||||
request = await oauth_provider._exchange_token("test_auth_code", "test_verifier")
|
request = await oauth_provider._exchange_token_authorization_code("test_auth_code", "test_verifier")
|
||||||
|
|
||||||
assert request.method == "POST"
|
assert request.method == "POST"
|
||||||
assert str(request.url) == "https://api.example.com/token"
|
assert str(request.url) == "https://api.example.com/token"
|
||||||
|
|
@ -576,7 +578,7 @@ class TestProtectedResourceMetadata:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test in token exchange
|
# Test in token exchange
|
||||||
request = await oauth_provider._exchange_token("test_code", "test_verifier")
|
request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier")
|
||||||
content = request.content.decode()
|
content = request.content.decode()
|
||||||
assert "resource=" in content
|
assert "resource=" in content
|
||||||
# Check URL-encoded resource parameter
|
# Check URL-encoded resource parameter
|
||||||
|
|
@ -607,7 +609,7 @@ class TestProtectedResourceMetadata:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test in token exchange
|
# Test in token exchange
|
||||||
request = await oauth_provider._exchange_token("test_code", "test_verifier")
|
request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier")
|
||||||
content = request.content.decode()
|
content = request.content.decode()
|
||||||
assert "resource=" not in content
|
assert "resource=" not in content
|
||||||
|
|
||||||
|
|
@ -637,7 +639,7 @@ class TestProtectedResourceMetadata:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Test in token exchange
|
# Test in token exchange
|
||||||
request = await oauth_provider._exchange_token("test_code", "test_verifier")
|
request = await oauth_provider._exchange_token_authorization_code("test_code", "test_verifier")
|
||||||
content = request.content.decode()
|
content = request.content.decode()
|
||||||
assert "resource=" in content
|
assert "resource=" in content
|
||||||
|
|
||||||
|
|
@ -710,7 +712,7 @@ class TestAuthFlow:
|
||||||
pass # Expected
|
pass # Expected
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvider):
|
async def test_auth_flow_with_no_tokens(self, oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage):
|
||||||
"""Test auth flow when no tokens are available, triggering the full OAuth flow."""
|
"""Test auth flow when no tokens are available, triggering the full OAuth flow."""
|
||||||
# Ensure no tokens are stored
|
# Ensure no tokens are stored
|
||||||
oauth_provider.context.current_tokens = None
|
oauth_provider.context.current_tokens = None
|
||||||
|
|
@ -779,7 +781,9 @@ class TestAuthFlow:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mock the authorization process
|
# Mock the authorization process
|
||||||
oauth_provider._perform_authorization = mock.AsyncMock(return_value=("test_auth_code", "test_code_verifier"))
|
oauth_provider._perform_authorization_code_grant = mock.AsyncMock(
|
||||||
|
return_value=("test_auth_code", "test_code_verifier")
|
||||||
|
)
|
||||||
|
|
||||||
# Next request should be to exchange token
|
# Next request should be to exchange token
|
||||||
token_request = await auth_flow.asend(registration_response)
|
token_request = await auth_flow.asend(registration_response)
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,13 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
|
||||||
return self.clients.get(client_id)
|
return self.clients.get(client_id)
|
||||||
|
|
||||||
async def register_client(self, client_info: OAuthClientInformationFull):
|
async def register_client(self, client_info: OAuthClientInformationFull):
|
||||||
|
assert client_info.client_id is not None
|
||||||
self.clients[client_info.client_id] = client_info
|
self.clients[client_info.client_id] = client_info
|
||||||
|
|
||||||
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
|
||||||
# toy authorize implementation which just immediately generates an authorization
|
# toy authorize implementation which just immediately generates an authorization
|
||||||
# code and completes the redirect
|
# code and completes the redirect
|
||||||
|
assert client.client_id is not None
|
||||||
code = AuthorizationCode(
|
code = AuthorizationCode(
|
||||||
code=f"code_{int(time.time())}",
|
code=f"code_{int(time.time())}",
|
||||||
client_id=client.client_id,
|
client_id=client.client_id,
|
||||||
|
|
@ -72,6 +74,7 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
|
||||||
refresh_token = f"refresh_{secrets.token_hex(32)}"
|
refresh_token = f"refresh_{secrets.token_hex(32)}"
|
||||||
|
|
||||||
# Store the tokens
|
# Store the tokens
|
||||||
|
assert client.client_id is not None
|
||||||
self.tokens[access_token] = AccessToken(
|
self.tokens[access_token] = AccessToken(
|
||||||
token=access_token,
|
token=access_token,
|
||||||
client_id=client.client_id,
|
client_id=client.client_id,
|
||||||
|
|
@ -133,6 +136,7 @@ class MockOAuthProvider(OAuthAuthorizationServerProvider[AuthorizationCode, Refr
|
||||||
new_refresh_token = f"refresh_{secrets.token_hex(32)}"
|
new_refresh_token = f"refresh_{secrets.token_hex(32)}"
|
||||||
|
|
||||||
# Store the new tokens
|
# Store the new tokens
|
||||||
|
assert client.client_id is not None
|
||||||
self.tokens[new_access_token] = AccessToken(
|
self.tokens[new_access_token] = AccessToken(
|
||||||
token=new_access_token,
|
token=new_access_token,
|
||||||
client_id=client.client_id,
|
client_id=client.client_id,
|
||||||
|
|
|
||||||
63
uv.lock
generated
63
uv.lock
generated
|
|
@ -321,6 +321,53 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cryptography"
|
||||||
|
version = "45.0.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/8b/34394337abe4566848a2bd49b26bcd4b07fd466afd3e8cce4cb79a390869/cryptography-45.0.5-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:206210d03c1193f4e1ff681d22885181d47efa1ab3018766a7b32a7b3d6e6afd", size = 3575762, upload-time = "2025-07-02T13:05:53.166Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/5d/a19441c1e89afb0f173ac13178606ca6fab0d3bd3ebc29e9ed1318b507fc/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c648025b6840fe62e57107e0a25f604db740e728bd67da4f6f060f03017d5097", size = 4140906, upload-time = "2025-07-02T13:05:55.914Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/db/daceb259982a3c2da4e619f45b5bfdec0e922a23de213b2636e78ef0919b/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:b8fa8b0a35a9982a3c60ec79905ba5bb090fc0b9addcfd3dc2dd04267e45f25e", size = 4374411, upload-time = "2025-07-02T13:05:57.814Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/35/5d06ad06402fc522c8bf7eab73422d05e789b4e38fe3206a85e3d6966c11/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:14d96584701a887763384f3c47f0ca7c1cce322aa1c31172680eb596b890ec30", size = 4140942, upload-time = "2025-07-02T13:06:00.137Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/65/79/020a5413347e44c382ef1f7f7e7a66817cd6273e3e6b5a72d18177b08b2f/cryptography-45.0.5-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:57c816dfbd1659a367831baca4b775b2a5b43c003daf52e9d57e1d30bc2e1b0e", size = 4374079, upload-time = "2025-07-02T13:06:02.043Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/c5/c0e07d84a9a2a8a0ed4f865e58f37c71af3eab7d5e094ff1b21f3f3af3bc/cryptography-45.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:b9e38e0a83cd51e07f5a48ff9691cae95a79bea28fe4ded168a8e5c6c77e819d", size = 3321362, upload-time = "2025-07-02T13:06:04.463Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c0/71/9bdbcfd58d6ff5084687fe722c58ac718ebedbc98b9f8f93781354e6d286/cryptography-45.0.5-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:8c4a6ff8a30e9e3d38ac0539e9a9e02540ab3f827a3394f8852432f6b0ea152e", size = 3587878, upload-time = "2025-07-02T13:06:06.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/63/83516cfb87f4a8756eaa4203f93b283fda23d210fc14e1e594bd5f20edb6/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bd4c45986472694e5121084c6ebbd112aa919a25e783b87eb95953c9573906d6", size = 4152447, upload-time = "2025-07-02T13:06:08.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/11/d2823d2a5a0bd5802b3565437add16f5c8ce1f0778bf3822f89ad2740a38/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:982518cd64c54fcada9d7e5cf28eabd3ee76bd03ab18e08a48cad7e8b6f31b18", size = 4386778, upload-time = "2025-07-02T13:06:10.263Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/38/6bf177ca6bce4fe14704ab3e93627c5b0ca05242261a2e43ef3168472540/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:12e55281d993a793b0e883066f590c1ae1e802e3acb67f8b442e721e475e6463", size = 4151627, upload-time = "2025-07-02T13:06:13.097Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/6a/69fc67e5266bff68a91bcb81dff8fb0aba4d79a78521a08812048913e16f/cryptography-45.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:5aa1e32983d4443e310f726ee4b071ab7569f58eedfdd65e9675484a4eb67bd1", size = 4385593, upload-time = "2025-07-02T13:06:15.689Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f6/34/31a1604c9a9ade0fdab61eb48570e09a796f4d9836121266447b0eaf7feb/cryptography-45.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e357286c1b76403dd384d938f93c46b2b058ed4dfcdce64a770f0537ed3feb6f", size = 3331106, upload-time = "2025-07-02T13:06:18.058Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssselect2"
|
name = "cssselect2"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
|
|
@ -614,6 +661,7 @@ dependencies = [
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
|
{ name = "pyjwt", extra = ["crypto"] },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
{ name = "pywin32", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "sse-starlette" },
|
{ name = "sse-starlette" },
|
||||||
|
|
@ -661,6 +709,7 @@ requires-dist = [
|
||||||
{ name = "jsonschema", specifier = ">=4.20.0" },
|
{ name = "jsonschema", specifier = ">=4.20.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.11.0,<3.0.0" },
|
{ name = "pydantic", specifier = ">=2.11.0,<3.0.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.5.2" },
|
{ name = "pydantic-settings", specifier = ">=2.5.2" },
|
||||||
|
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" },
|
||||||
{ name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" },
|
||||||
{ name = "python-multipart", specifier = ">=0.0.9" },
|
{ name = "python-multipart", specifier = ">=0.0.9" },
|
||||||
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" },
|
{ name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=310" },
|
||||||
|
|
@ -1487,6 +1536,20 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyjwt"
|
||||||
|
version = "2.10.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
crypto = [
|
||||||
|
{ name = "cryptography" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pymdown-extensions"
|
name = "pymdown-extensions"
|
||||||
version = "10.16.1"
|
version = "10.16.1"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue