feat: implement scope-based audience mapping and RFC 9728 support
This commit removes hardcoded Keycloak audience mappers and implements
dynamic audience assignment based on OAuth client scopes and RFC 8707
resource indicators.
## MCP Server Changes
### Protected Resource Metadata (app.py)
- Change resource field from client_id to URL (RFC 9728 compliance)
- Use `{mcp_server_url}/mcp` as resource identifier
- Update DCR registration to include all Nextcloud API scopes
- Add resource_url parameter to client registration
### Client Registration (auth/client_registration.py)
- Add resource_url parameter to register_client()
- Pass resource_url to DCR endpoint
- Support RFC 9728 resource metadata
### Browser OAuth Routes (auth/browser_oauth_routes.py)
- Enhanced error logging for token exchange failures
- Log HTTP status code and response body for debugging
- Improved error messages for OAuth provisioning issues
### Token Verifier (auth/progressive_token_verifier.py)
- Add introspection_uri and client_secret parameters
- Initialize HTTP client for introspection requests
- Enable opaque token validation support
## Keycloak Configuration
### realm-export.json
- **Remove** hardcoded `audience-mcp-server` protocol mapper
- Audience now determined by client scopes:
- External clients: RFC 8707 resource parameter → `aud: {resource_url}`
- MCP Server: `token-exchange-nextcloud` scope → `aud: "nextcloud"`
### OIDC App (third_party/oidc)
- Updated submodule with RFC 9728 support
- Added resource_url database field
- Enhanced introspection authorization logic
## Architecture
Two separate audience flows:
1. **Gemini CLI → MCP Server**
- Client requests: `resource=http://localhost:8002/mcp`
- Token audience: `aud: "http://localhost:8002/mcp"`
- MCP server validates via progressive_token_verifier
2. **MCP Server → Nextcloud APIs**
- MCP server includes: `scope=token-exchange-nextcloud`
- Token audience: `aud: "nextcloud"` (via scope mapper)
- Nextcloud user_oidc validates via SelfEncodedValidator
## Benefits
- ✅ RFC 8707 compliant (resource indicators)
- ✅ RFC 9728 compliant (protected resource metadata)
- ✅ Dynamic audience based on OAuth context
- ✅ Fixes Gemini CLI authentication failures
- ✅ Maintains Nextcloud API access for background jobs
- ✅ Clear security boundaries between flows
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -229,17 +229,6 @@
|
|||||||
"fullScopeAllowed": true,
|
"fullScopeAllowed": true,
|
||||||
"nodeReRegistrationTimeout": -1,
|
"nodeReRegistrationTimeout": -1,
|
||||||
"protocolMappers": [
|
"protocolMappers": [
|
||||||
{
|
|
||||||
"name": "audience-mcp-server",
|
|
||||||
"protocol": "openid-connect",
|
|
||||||
"protocolMapper": "oidc-audience-mapper",
|
|
||||||
"consentRequired": false,
|
|
||||||
"config": {
|
|
||||||
"included.custom.audience": "nextcloud-mcp-server",
|
|
||||||
"access.token.claim": "true",
|
|
||||||
"id.token.claim": "false"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "sub",
|
"name": "sub",
|
||||||
"protocol": "openid-connect",
|
"protocol": "openid-connect",
|
||||||
|
|||||||
+46
-12
@@ -300,14 +300,14 @@ async def load_oauth_client_credentials(
|
|||||||
f"{mcp_server_url}/oauth/login-callback", # Browser OAuth flow for /user/page
|
f"{mcp_server_url}/oauth/login-callback", # Browser OAuth flow for /user/page
|
||||||
]
|
]
|
||||||
|
|
||||||
# MCP server DCR: Only request basic OIDC scopes for the server's own authentication
|
# MCP server DCR: Register with ALL supported scopes
|
||||||
# Note: Nextcloud app scopes (notes:read, calendar:write, etc.) are for MCP *clients*
|
# When we register as a resource server (with resource_url), the allowed_scopes
|
||||||
# that request access tokens. The MCP server itself only needs to authenticate
|
# represent what scopes are AVAILABLE for this resource, not what the server needs.
|
||||||
# as a client application, not request any Nextcloud resource access.
|
# External clients will request tokens with resource=http://localhost:8001/mcp
|
||||||
|
# and the authorization server will limit them to these allowed scopes.
|
||||||
#
|
#
|
||||||
# The PRM endpoint will advertise the full list of supported scopes dynamically
|
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
|
||||||
# by discovering all @require_scopes decorators on registered tools.
|
dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write"
|
||||||
dcr_scopes = "openid profile email"
|
|
||||||
|
|
||||||
# Add offline_access scope if refresh tokens are enabled
|
# Add offline_access scope if refresh tokens are enabled
|
||||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||||
@@ -319,7 +319,7 @@ async def load_oauth_client_credentials(
|
|||||||
dcr_scopes = f"{dcr_scopes} offline_access"
|
dcr_scopes = f"{dcr_scopes} offline_access"
|
||||||
logger.info("✓ offline_access scope enabled for refresh tokens")
|
logger.info("✓ offline_access scope enabled for refresh tokens")
|
||||||
|
|
||||||
logger.info(f"MCP server DCR scopes: {dcr_scopes}")
|
logger.info(f"MCP server DCR scopes (resource server): {dcr_scopes}")
|
||||||
|
|
||||||
# Get token type from environment (Bearer or jwt)
|
# Get token type from environment (Bearer or jwt)
|
||||||
# Note: Must be lowercase "jwt" to match OIDC app's check
|
# Note: Must be lowercase "jwt" to match OIDC app's check
|
||||||
@@ -336,6 +336,10 @@ async def load_oauth_client_credentials(
|
|||||||
storage = RefreshTokenStorage.from_env()
|
storage = RefreshTokenStorage.from_env()
|
||||||
await storage.initialize()
|
await storage.initialize()
|
||||||
|
|
||||||
|
# RFC 9728: resource_url must be a URL for the protected resource
|
||||||
|
# This URL is used by token introspection to match tokens to this client
|
||||||
|
resource_url = f"{mcp_server_url}/mcp"
|
||||||
|
|
||||||
client_info = await ensure_oauth_client(
|
client_info = await ensure_oauth_client(
|
||||||
nextcloud_url=nextcloud_host,
|
nextcloud_url=nextcloud_host,
|
||||||
registration_endpoint=registration_endpoint,
|
registration_endpoint=registration_endpoint,
|
||||||
@@ -344,6 +348,7 @@ async def load_oauth_client_credentials(
|
|||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
scopes=dcr_scopes, # Use DCR-specific scopes (basic OIDC only)
|
scopes=dcr_scopes, # Use DCR-specific scopes (basic OIDC only)
|
||||||
token_type=token_type,
|
token_type=token_type,
|
||||||
|
resource_url=resource_url, # RFC 9728 Protected Resource URL
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
|
||||||
@@ -581,11 +586,15 @@ async def setup_oauth_config():
|
|||||||
nextcloud_host=nextcloud_host,
|
nextcloud_host=nextcloud_host,
|
||||||
encryption_key=encryption_key,
|
encryption_key=encryption_key,
|
||||||
mcp_client_id=client_id,
|
mcp_client_id=client_id,
|
||||||
|
introspection_uri=introspection_uri,
|
||||||
|
client_secret=client_secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"✓ Progressive Consent verifier configured - enforcing audience separation"
|
"✓ Progressive Consent verifier configured - enforcing audience separation"
|
||||||
)
|
)
|
||||||
|
if introspection_uri:
|
||||||
|
logger.info("✓ Opaque token introspection enabled (RFC 7662)")
|
||||||
|
|
||||||
# Create OAuth client for server-initiated flows (e.g., token exchange, background workers)
|
# Create OAuth client for server-initiated flows (e.g., token exchange, background workers)
|
||||||
oauth_client = None
|
oauth_client = None
|
||||||
@@ -931,9 +940,9 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
Dynamically discovers supported scopes from registered MCP tools.
|
Dynamically discovers supported scopes from registered MCP tools.
|
||||||
This ensures the advertised scopes always match the actual tool requirements.
|
This ensures the advertised scopes always match the actual tool requirements.
|
||||||
|
|
||||||
The 'resource' field is set to the MCP server's OAuth client ID, which is
|
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
|
||||||
used as the audience claim in access tokens. This ensures tokens obtained
|
This is used as the audience in access tokens via the resource parameter (RFC 8707).
|
||||||
with the resource parameter match the audience validation in progressive_token_verifier.
|
The introspection controller matches this URL to the MCP server's client via resource_url field.
|
||||||
"""
|
"""
|
||||||
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
# Use PUBLIC_ISSUER_URL for authorization server since external clients
|
||||||
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
# (like Claude) need the publicly accessible URL, not internal Docker URLs
|
||||||
@@ -942,13 +951,20 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
|
||||||
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
|
||||||
|
|
||||||
|
# RFC 9728 requires resource to be a URL (not a client ID)
|
||||||
|
# Use the MCP server's public URL
|
||||||
|
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
|
||||||
|
if not mcp_server_url:
|
||||||
|
# Fallback to constructing from host and port
|
||||||
|
mcp_server_url = f"http://localhost:{os.getenv('PORT', '8000')}"
|
||||||
|
|
||||||
# Dynamically discover all scopes from registered tools
|
# Dynamically discover all scopes from registered tools
|
||||||
# This provides a single source of truth based on @require_scopes decorators
|
# This provides a single source of truth based on @require_scopes decorators
|
||||||
supported_scopes = discover_all_scopes(mcp)
|
supported_scopes = discover_all_scopes(mcp)
|
||||||
|
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{
|
{
|
||||||
"resource": client_id, # MCP server's OAuth client ID (for audience validation)
|
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
|
||||||
"scopes_supported": supported_scopes,
|
"scopes_supported": supported_scopes,
|
||||||
"authorization_servers": [public_issuer_url],
|
"authorization_servers": [public_issuer_url],
|
||||||
"bearer_methods_supported": ["header"],
|
"bearer_methods_supported": ["header"],
|
||||||
@@ -1040,6 +1056,24 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
|||||||
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
|
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Add debugging middleware to log Authorization headers
|
||||||
|
@app.middleware("http")
|
||||||
|
async def log_auth_headers(request, call_next):
|
||||||
|
auth_header = request.headers.get("authorization")
|
||||||
|
if request.url.path.startswith("/mcp"):
|
||||||
|
if auth_header:
|
||||||
|
# Log first 50 chars of token for debugging
|
||||||
|
token_preview = (
|
||||||
|
auth_header[:50] + "..." if len(auth_header) > 50 else auth_header
|
||||||
|
)
|
||||||
|
logger.info(f"🔑 /mcp request with Authorization: {token_preview}")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"⚠️ /mcp request WITHOUT Authorization header from {request.client}"
|
||||||
|
)
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
# Add CORS middleware to allow browser-based clients like MCP Inspector
|
# Add CORS middleware to allow browser-based clients like MCP Inspector
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -277,6 +277,27 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
token_data = response.json()
|
token_data = response.json()
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_body = (
|
||||||
|
e.response.text if hasattr(e.response, "text") else str(e.response.content)
|
||||||
|
)
|
||||||
|
logger.error(
|
||||||
|
f"Token exchange failed: HTTP {e.response.status_code} - {error_body}"
|
||||||
|
)
|
||||||
|
return HTMLResponse(
|
||||||
|
f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Login Failed</title></head>
|
||||||
|
<body>
|
||||||
|
<h1>Login Failed</h1>
|
||||||
|
<p>Failed to exchange authorization code for tokens</p>
|
||||||
|
<p>HTTP {e.response.status_code}: {error_body}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
""",
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Token exchange failed: {e}")
|
logger.error(f"Token exchange failed: {e}")
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ async def register_client(
|
|||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
scopes: str = "openid profile email",
|
scopes: str = "openid profile email",
|
||||||
token_type: str = "Bearer",
|
token_type: str = "Bearer",
|
||||||
|
resource_url: str | None = None,
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
Register a new OAuth client with Nextcloud OIDC using dynamic client registration.
|
||||||
@@ -91,6 +92,7 @@ async def register_client(
|
|||||||
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback)
|
||||||
scopes: Space-separated list of scopes to request
|
scopes: Space-separated list of scopes to request
|
||||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||||
|
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClientInfo with registration details
|
ClientInfo with registration details
|
||||||
@@ -112,6 +114,10 @@ async def register_client(
|
|||||||
"token_type": token_type,
|
"token_type": token_type,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Add resource_url if provided (RFC 9728)
|
||||||
|
if resource_url:
|
||||||
|
client_metadata["resource_url"] = resource_url
|
||||||
|
|
||||||
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
logger.info(f"Registering OAuth client with Nextcloud: {client_name}")
|
||||||
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
logger.debug(f"Registration endpoint: {registration_endpoint}")
|
||||||
|
|
||||||
@@ -303,6 +309,7 @@ async def ensure_oauth_client(
|
|||||||
redirect_uris: list[str] | None = None,
|
redirect_uris: list[str] | None = None,
|
||||||
scopes: str = "openid profile email",
|
scopes: str = "openid profile email",
|
||||||
token_type: str = "Bearer",
|
token_type: str = "Bearer",
|
||||||
|
resource_url: str | None = None,
|
||||||
) -> ClientInfo:
|
) -> ClientInfo:
|
||||||
"""
|
"""
|
||||||
Ensure OAuth client exists in SQLite storage.
|
Ensure OAuth client exists in SQLite storage.
|
||||||
@@ -321,6 +328,7 @@ async def ensure_oauth_client(
|
|||||||
redirect_uris: List of redirect URIs
|
redirect_uris: List of redirect URIs
|
||||||
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
scopes: Space-separated list of scopes to request (default: "openid profile email")
|
||||||
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT")
|
||||||
|
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
ClientInfo with valid credentials
|
ClientInfo with valid credentials
|
||||||
@@ -339,6 +347,8 @@ async def ensure_oauth_client(
|
|||||||
|
|
||||||
# Register new client
|
# Register new client
|
||||||
logger.info("Registering new OAuth client...")
|
logger.info("Registering new OAuth client...")
|
||||||
|
if resource_url:
|
||||||
|
logger.info(f" with resource_url: {resource_url}")
|
||||||
client_info = await register_client(
|
client_info = await register_client(
|
||||||
nextcloud_url=nextcloud_url,
|
nextcloud_url=nextcloud_url,
|
||||||
registration_endpoint=registration_endpoint,
|
registration_endpoint=registration_endpoint,
|
||||||
@@ -346,6 +356,7 @@ async def ensure_oauth_client(
|
|||||||
redirect_uris=redirect_uris,
|
redirect_uris=redirect_uris,
|
||||||
scopes=scopes,
|
scopes=scopes,
|
||||||
token_type=token_type,
|
token_type=token_type,
|
||||||
|
resource_url=resource_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to SQLite storage
|
# Save to SQLite storage
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import os
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
from mcp.server.auth.provider import AccessToken
|
from mcp.server.auth.provider import AccessToken
|
||||||
|
|
||||||
@@ -39,6 +40,8 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
nextcloud_host: Optional[str] = None,
|
nextcloud_host: Optional[str] = None,
|
||||||
encryption_key: Optional[str] = None,
|
encryption_key: Optional[str] = None,
|
||||||
mcp_client_id: Optional[str] = None,
|
mcp_client_id: Optional[str] = None,
|
||||||
|
introspection_uri: Optional[str] = None,
|
||||||
|
client_secret: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Initialize the Progressive Consent token verifier.
|
Initialize the Progressive Consent token verifier.
|
||||||
@@ -50,6 +53,8 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
nextcloud_host: Nextcloud server URL
|
nextcloud_host: Nextcloud server URL
|
||||||
encryption_key: Fernet key for token encryption
|
encryption_key: Fernet key for token encryption
|
||||||
mcp_client_id: MCP server OAuth client ID for audience validation
|
mcp_client_id: MCP server OAuth client ID for audience validation
|
||||||
|
introspection_uri: OAuth introspection endpoint URL (for opaque tokens)
|
||||||
|
client_secret: OAuth client secret (required for introspection)
|
||||||
"""
|
"""
|
||||||
self.storage = token_storage
|
self.storage = token_storage
|
||||||
self.oidc_discovery_url = oidc_discovery_url or os.getenv(
|
self.oidc_discovery_url = oidc_discovery_url or os.getenv(
|
||||||
@@ -59,6 +64,18 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
self.nextcloud_host = nextcloud_host or os.getenv("NEXTCLOUD_HOST")
|
self.nextcloud_host = nextcloud_host or os.getenv("NEXTCLOUD_HOST")
|
||||||
self.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY")
|
self.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||||
self.mcp_client_id = mcp_client_id or os.getenv("OIDC_CLIENT_ID")
|
self.mcp_client_id = mcp_client_id or os.getenv("OIDC_CLIENT_ID")
|
||||||
|
self.introspection_uri = introspection_uri
|
||||||
|
self.client_secret = client_secret or os.getenv("OIDC_CLIENT_SECRET")
|
||||||
|
|
||||||
|
# HTTP client for introspection requests
|
||||||
|
self._http_client: Optional[httpx.AsyncClient] = None
|
||||||
|
if self.introspection_uri and self.mcp_client_id and self.client_secret:
|
||||||
|
self._http_client = httpx.AsyncClient(timeout=10.0)
|
||||||
|
logger.info(f"Introspection support enabled: {introspection_uri}")
|
||||||
|
elif self.introspection_uri:
|
||||||
|
logger.warning(
|
||||||
|
"Introspection URI provided but missing client credentials - introspection disabled"
|
||||||
|
)
|
||||||
|
|
||||||
# Create token broker if not provided
|
# Create token broker if not provided
|
||||||
if token_broker:
|
if token_broker:
|
||||||
@@ -83,16 +100,38 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
2. Token is not expired
|
2. Token is not expired
|
||||||
3. Token has valid signature (if verification enabled)
|
3. Token has valid signature (if verification enabled)
|
||||||
|
|
||||||
|
Supports both JWT and opaque tokens:
|
||||||
|
- JWT tokens: Decoded directly from payload
|
||||||
|
- Opaque tokens: Validated via introspection endpoint (RFC 7662)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: JWT access token from Flow 1
|
token: Access token from Flow 1 (JWT or opaque)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AccessToken if valid, None otherwise
|
AccessToken if valid, None otherwise
|
||||||
"""
|
"""
|
||||||
|
logger.info("🔐 verify_token called - attempting to validate token")
|
||||||
|
logger.info(f"Token (first 50 chars): {token[:50]}...")
|
||||||
|
logger.info(f"Expected MCP client ID: {self.mcp_client_id}")
|
||||||
|
|
||||||
|
# Check if token is JWT format (has 3 parts separated by dots)
|
||||||
|
is_jwt = "." in token and token.count(".") == 2
|
||||||
|
logger.info(f"Token format: {'JWT' if is_jwt else 'opaque'}")
|
||||||
|
|
||||||
|
if is_jwt:
|
||||||
|
# Try JWT verification
|
||||||
|
return await self._verify_jwt_token(token)
|
||||||
|
else:
|
||||||
|
# Fall back to introspection for opaque tokens
|
||||||
|
return await self._verify_opaque_token(token)
|
||||||
|
|
||||||
|
async def _verify_jwt_token(self, token: str) -> Optional[AccessToken]:
|
||||||
|
"""Verify JWT token by decoding payload."""
|
||||||
try:
|
try:
|
||||||
# Decode without signature verification (IdP handles that)
|
# Decode without signature verification (IdP handles that)
|
||||||
# In production, would verify signature with IdP public key
|
# In production, would verify signature with IdP public key
|
||||||
payload = jwt.decode(token, options={"verify_signature": False})
|
payload = jwt.decode(token, options={"verify_signature": False})
|
||||||
|
logger.info(f"Token payload decoded: {payload}")
|
||||||
|
|
||||||
# CRITICAL: Verify audience is for MCP server (Flow 1)
|
# CRITICAL: Verify audience is for MCP server (Flow 1)
|
||||||
audiences = payload.get("aud", [])
|
audiences = payload.get("aud", [])
|
||||||
@@ -115,7 +154,9 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
# Check expiry
|
# Check expiry
|
||||||
exp = payload.get("exp", 0)
|
exp = payload.get("exp", 0)
|
||||||
if exp < datetime.now(timezone.utc).timestamp():
|
if exp < datetime.now(timezone.utc).timestamp():
|
||||||
logger.debug("Token expired")
|
logger.warning(
|
||||||
|
f"❌ Token expired: exp={exp}, now={datetime.now(timezone.utc).timestamp()}"
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Extract user info
|
# Extract user info
|
||||||
@@ -124,6 +165,10 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
scopes = payload.get("scope", "").split()
|
scopes = payload.get("scope", "").split()
|
||||||
exp = payload.get("exp", None)
|
exp = payload.get("exp", None)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"✅ Token validation successful! user={user_id}, scopes={scopes}"
|
||||||
|
)
|
||||||
|
|
||||||
# Create AccessToken for MCP framework
|
# Create AccessToken for MCP framework
|
||||||
return AccessToken(
|
return AccessToken(
|
||||||
token=token,
|
token=token,
|
||||||
@@ -134,10 +179,87 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
)
|
)
|
||||||
|
|
||||||
except jwt.InvalidTokenError as e:
|
except jwt.InvalidTokenError as e:
|
||||||
logger.debug(f"Invalid token: {e}")
|
logger.warning(f"❌ Invalid token (JWT decode failed): {e}")
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Token verification failed: {e}")
|
logger.error(f"❌ Token verification failed with exception: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _verify_opaque_token(self, token: str) -> Optional[AccessToken]:
|
||||||
|
"""
|
||||||
|
Verify opaque token via introspection endpoint (RFC 7662).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
token: Opaque access token
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AccessToken if active and valid, None otherwise
|
||||||
|
"""
|
||||||
|
if not self._http_client or not self.introspection_uri:
|
||||||
|
logger.error(
|
||||||
|
"❌ Cannot verify opaque token - introspection not configured. "
|
||||||
|
"Set introspection_uri and client credentials."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Introspecting token at {self.introspection_uri}")
|
||||||
|
|
||||||
|
# Call introspection endpoint (requires client authentication)
|
||||||
|
response = await self._http_client.post(
|
||||||
|
self.introspection_uri,
|
||||||
|
data={"token": token},
|
||||||
|
auth=(self.mcp_client_id, self.client_secret),
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.warning(
|
||||||
|
f"❌ Introspection failed: HTTP {response.status_code} - {response.text[:200]}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
introspection_data = response.json()
|
||||||
|
logger.info(f"Introspection response: {introspection_data}")
|
||||||
|
|
||||||
|
# Check if token is active
|
||||||
|
if not introspection_data.get("active", False):
|
||||||
|
logger.warning("❌ Token introspection returned active=false")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract user info
|
||||||
|
user_id = introspection_data.get("sub") or introspection_data.get(
|
||||||
|
"username"
|
||||||
|
)
|
||||||
|
if not user_id:
|
||||||
|
logger.error("❌ No username found in introspection response")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Extract scopes (space-separated string)
|
||||||
|
scope_string = introspection_data.get("scope", "")
|
||||||
|
scopes = scope_string.split() if scope_string else []
|
||||||
|
|
||||||
|
# Extract client ID and expiration
|
||||||
|
client_id = introspection_data.get("client_id", "unknown")
|
||||||
|
exp = introspection_data.get("exp")
|
||||||
|
|
||||||
|
logger.info(f"✅ Opaque token validated! user={user_id}, scopes={scopes}")
|
||||||
|
|
||||||
|
return AccessToken(
|
||||||
|
token=token,
|
||||||
|
client_id=client_id,
|
||||||
|
scopes=scopes,
|
||||||
|
expires_at=int(exp) if exp else None,
|
||||||
|
resource=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
logger.error("❌ Timeout while introspecting token")
|
||||||
|
return None
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"❌ Network error during introspection: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Introspection failed with exception: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def check_provisioning(self, user_id: str) -> bool:
|
async def check_provisioning(self, user_id: str) -> bool:
|
||||||
@@ -217,3 +339,5 @@ class ProgressiveConsentTokenVerifier:
|
|||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
if self.token_broker:
|
if self.token_broker:
|
||||||
await self.token_broker.close()
|
await self.token_broker.close()
|
||||||
|
if self._http_client:
|
||||||
|
await self._http_client.aclose()
|
||||||
|
|||||||
Vendored
+1
-1
Submodule third_party/oidc updated: 712df7b705...2ae0f2aed9
Reference in New Issue
Block a user