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:
Chris Coutinho
2025-11-04 05:28:58 +01:00
parent 10dffd0c10
commit de99296779
6 changed files with 207 additions and 28 deletions
-11
View File
@@ -229,17 +229,6 @@
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"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",
"protocol": "openid-connect",
+46 -12
View File
@@ -300,14 +300,14 @@ async def load_oauth_client_credentials(
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
# Note: Nextcloud app scopes (notes:read, calendar:write, etc.) are for MCP *clients*
# that request access tokens. The MCP server itself only needs to authenticate
# as a client application, not request any Nextcloud resource access.
# MCP server DCR: Register with ALL supported scopes
# When we register as a resource server (with resource_url), the allowed_scopes
# represent what scopes are AVAILABLE for this resource, not what the server needs.
# 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
# by discovering all @require_scopes decorators on registered tools.
dcr_scopes = "openid profile email"
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators.
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"
# Add offline_access scope if refresh tokens are enabled
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"
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)
# 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()
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(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
@@ -344,6 +348,7 @@ async def load_oauth_client_credentials(
redirect_uris=redirect_uris,
scopes=dcr_scopes, # Use DCR-specific scopes (basic OIDC only)
token_type=token_type,
resource_url=resource_url, # RFC 9728 Protected Resource URL
)
logger.info(f"OAuth client ready: {client_info.client_id[:16]}...")
@@ -581,11 +586,15 @@ async def setup_oauth_config():
nextcloud_host=nextcloud_host,
encryption_key=encryption_key,
mcp_client_id=client_id,
introspection_uri=introspection_uri,
client_secret=client_secret,
)
logger.info(
"✓ 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)
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.
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
used as the audience claim in access tokens. This ensures tokens obtained
with the resource parameter match the audience validation in progressive_token_verifier.
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
This is used as the audience in access tokens via the resource parameter (RFC 8707).
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
# (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
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
# This provides a single source of truth based on @require_scopes decorators
supported_scopes = discover_all_scopes(mcp)
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,
"authorization_servers": [public_issuer_url],
"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"
)
# 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
app.add_middleware(
CORSMiddleware,
@@ -277,6 +277,27 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
response.raise_for_status()
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:
logger.error(f"Token exchange failed: {e}")
return HTMLResponse(
@@ -80,6 +80,7 @@ async def register_client(
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
resource_url: str | None = None,
) -> ClientInfo:
"""
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)
scopes: Space-separated list of scopes to request
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:
ClientInfo with registration details
@@ -112,6 +114,10 @@ async def register_client(
"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.debug(f"Registration endpoint: {registration_endpoint}")
@@ -303,6 +309,7 @@ async def ensure_oauth_client(
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
resource_url: str | None = None,
) -> ClientInfo:
"""
Ensure OAuth client exists in SQLite storage.
@@ -321,6 +328,7 @@ async def ensure_oauth_client(
redirect_uris: List of redirect URIs
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")
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
Returns:
ClientInfo with valid credentials
@@ -339,6 +347,8 @@ async def ensure_oauth_client(
# Register new client
logger.info("Registering new OAuth client...")
if resource_url:
logger.info(f" with resource_url: {resource_url}")
client_info = await register_client(
nextcloud_url=nextcloud_url,
registration_endpoint=registration_endpoint,
@@ -346,6 +356,7 @@ async def ensure_oauth_client(
redirect_uris=redirect_uris,
scopes=scopes,
token_type=token_type,
resource_url=resource_url,
)
# Save to SQLite storage
@@ -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()