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:
@@ -12,6 +12,7 @@ import os
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import jwt
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
|
||||
@@ -39,6 +40,8 @@ class ProgressiveConsentTokenVerifier:
|
||||
nextcloud_host: Optional[str] = None,
|
||||
encryption_key: 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.
|
||||
@@ -50,6 +53,8 @@ class ProgressiveConsentTokenVerifier:
|
||||
nextcloud_host: Nextcloud server URL
|
||||
encryption_key: Fernet key for token encryption
|
||||
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.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.encryption_key = encryption_key or os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
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
|
||||
if token_broker:
|
||||
@@ -83,16 +100,38 @@ class ProgressiveConsentTokenVerifier:
|
||||
2. Token is not expired
|
||||
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:
|
||||
token: JWT access token from Flow 1
|
||||
token: Access token from Flow 1 (JWT or opaque)
|
||||
|
||||
Returns:
|
||||
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:
|
||||
# Decode without signature verification (IdP handles that)
|
||||
# In production, would verify signature with IdP public key
|
||||
payload = jwt.decode(token, options={"verify_signature": False})
|
||||
logger.info(f"Token payload decoded: {payload}")
|
||||
|
||||
# CRITICAL: Verify audience is for MCP server (Flow 1)
|
||||
audiences = payload.get("aud", [])
|
||||
@@ -115,7 +154,9 @@ class ProgressiveConsentTokenVerifier:
|
||||
# Check expiry
|
||||
exp = payload.get("exp", 0)
|
||||
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
|
||||
|
||||
# Extract user info
|
||||
@@ -124,6 +165,10 @@ class ProgressiveConsentTokenVerifier:
|
||||
scopes = payload.get("scope", "").split()
|
||||
exp = payload.get("exp", None)
|
||||
|
||||
logger.info(
|
||||
f"✅ Token validation successful! user={user_id}, scopes={scopes}"
|
||||
)
|
||||
|
||||
# Create AccessToken for MCP framework
|
||||
return AccessToken(
|
||||
token=token,
|
||||
@@ -134,10 +179,87 @@ class ProgressiveConsentTokenVerifier:
|
||||
)
|
||||
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.debug(f"Invalid token: {e}")
|
||||
logger.warning(f"❌ Invalid token (JWT decode failed): {e}")
|
||||
return None
|
||||
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
|
||||
|
||||
async def check_provisioning(self, user_id: str) -> bool:
|
||||
@@ -217,3 +339,5 @@ class ProgressiveConsentTokenVerifier:
|
||||
"""Clean up resources."""
|
||||
if self.token_broker:
|
||||
await self.token_broker.close()
|
||||
if self._http_client:
|
||||
await self._http_client.aclose()
|
||||
|
||||
Reference in New Issue
Block a user