From de992967792ac023a0d9c62a196385332b4722b9 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 4 Nov 2025 05:28:58 +0100 Subject: [PATCH] feat: implement scope-based audience mapping and RFC 9728 support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- keycloak/realm-export.json | 11 -- nextcloud_mcp_server/app.py | 58 ++++++-- .../auth/browser_oauth_routes.py | 21 +++ .../auth/client_registration.py | 11 ++ .../auth/progressive_token_verifier.py | 132 +++++++++++++++++- third_party/oidc | 2 +- 6 files changed, 207 insertions(+), 28 deletions(-) diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 4d4f8b1..1cb5fca 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -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", diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 8c332c3..01bba3e 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -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, diff --git a/nextcloud_mcp_server/auth/browser_oauth_routes.py b/nextcloud_mcp_server/auth/browser_oauth_routes.py index fb4a165..fc4a0ce 100644 --- a/nextcloud_mcp_server/auth/browser_oauth_routes.py +++ b/nextcloud_mcp_server/auth/browser_oauth_routes.py @@ -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""" + + + Login Failed + +

Login Failed

+

Failed to exchange authorization code for tokens

+

HTTP {e.response.status_code}: {error_body}

+ + + """, + status_code=500, + ) except Exception as e: logger.error(f"Token exchange failed: {e}") return HTMLResponse( diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index 81ea4cf..44451a9 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -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 diff --git a/nextcloud_mcp_server/auth/progressive_token_verifier.py b/nextcloud_mcp_server/auth/progressive_token_verifier.py index d556b42..4238595 100644 --- a/nextcloud_mcp_server/auth/progressive_token_verifier.py +++ b/nextcloud_mcp_server/auth/progressive_token_verifier.py @@ -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() diff --git a/third_party/oidc b/third_party/oidc index 712df7b..2ae0f2a 160000 --- a/third_party/oidc +++ b/third_party/oidc @@ -1 +1 @@ -Subproject commit 712df7b705d6709f2372a3de1117a6d67d631268 +Subproject commit 2ae0f2aed96ce1e16f445f80735b322630805ee6