From 723eb57060429b67385ca18b5cee7be8db59c1e0 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Tue, 4 Nov 2025 08:34:51 +0100 Subject: [PATCH] feat: enable authorization services for token exchange in Keycloak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Configure Keycloak authorization policies to allow nextcloud-mcp-server to exchange tokens for nextcloud audience. This enables RFC 8693 token exchange flow between the MCP client and Nextcloud. Changes: - Enable service accounts and authorization services for nextcloud client - Add token-exchange resource with scope-based permissions - Create client policy allowing nextcloud-mcp-server and nextcloud - Add token-exchange-permission with affirmative decision strategy - Add mcp-server-audience mapper to nextcloud-mcp-server client - Simplify audience validation to accept tokens with MCP client ID The authorization policy enables tokens issued to nextcloud-mcp-server to be exchanged for tokens with nextcloud audience, validated via both clients being included in the allow-nextcloud-mcp-server-to-exchange policy. All 7 token exchange integration tests pass, confirming: - Basic token exchange with correct audience claims - Nextcloud API access with exchanged tokens - Stateless multiple exchange operations - Full CRUD operations on Notes API - Proper claim preservation (sub, azp, aud) - Default scope configuration - TokenExchangeService implementation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- keycloak/realm-export.json | 65 ++++++++++++++++++- .../auth/progressive_token_verifier.py | 22 +++---- 2 files changed, 73 insertions(+), 14 deletions(-) diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 88a6928..27cd8b1 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -177,7 +177,8 @@ "standardFlowEnabled": false, "implicitFlowEnabled": false, "directAccessGrantsEnabled": false, - "serviceAccountsEnabled": false, + "serviceAccountsEnabled": true, + "authorizationServicesEnabled": true, "publicClient": false, "protocol": "openid-connect", "attributes": { @@ -186,6 +187,56 @@ "client.token.exchange.standard.enabled": "true", "standard.token.exchange.enabled": "true" }, + "authorizationSettings": { + "allowRemoteResourceManagement": true, + "policyEnforcementMode": "ENFORCING", + "resources": [ + { + "name": "token-exchange", + "type": "urn:keycloak:token-exchange", + "ownerManagedAccess": false, + "displayName": "Token Exchange", + "attributes": {}, + "uris": [], + "scopes": [ + { + "name": "token-exchange" + } + ] + } + ], + "policies": [ + { + "name": "allow-nextcloud-mcp-server-to-exchange", + "description": "", + "type": "client", + "logic": "POSITIVE", + "decisionStrategy": "UNANIMOUS", + "config": { + "clients": "[\"nextcloud-mcp-server\",\"nextcloud\"]" + } + }, + { + "name": "token-exchange-permission", + "description": "", + "type": "scope", + "logic": "POSITIVE", + "decisionStrategy": "AFFIRMATIVE", + "config": { + "resources": "[\"token-exchange\"]", + "scopes": "[\"token-exchange\"]", + "applyPolicies": "[\"allow-nextcloud-mcp-server-to-exchange\"]" + } + } + ], + "scopes": [ + { + "name": "token-exchange", + "displayName": "Token Exchange" + } + ], + "decisionStrategy": "UNANIMOUS" + }, "fullScopeAllowed": true, "nodeReRegistrationTimeout": -1 }, @@ -229,6 +280,18 @@ "fullScopeAllowed": true, "nodeReRegistrationTimeout": -1, "protocolMappers": [ + { + "name": "mcp-server-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "nextcloud-mcp-server", + "access.token.claim": "true", + "id.token.claim": "false", + "introspection.token.claim": "true" + } + }, { "name": "nextcloud-audience", "protocol": "openid-connect", diff --git a/nextcloud_mcp_server/auth/progressive_token_verifier.py b/nextcloud_mcp_server/auth/progressive_token_verifier.py index 20db676..ff83d5d 100644 --- a/nextcloud_mcp_server/auth/progressive_token_verifier.py +++ b/nextcloud_mcp_server/auth/progressive_token_verifier.py @@ -140,27 +140,23 @@ class ProgressiveConsentTokenVerifier: # Audience validation: # - Accept tokens with no audience (will validate via introspection if needed) - # - Accept tokens with MCP client ID in audience - # - Reject tokens with "nextcloud" audience (wrong flow) + # - Accept tokens with MCP client ID in audience (regardless of other audiences) + # - Reject tokens without MCP client ID (if audience is present) if audiences: - # Check if this is a Nextcloud token (wrong flow) - if "nextcloud" in audiences: + # Check if MCP client ID is in the audience + if self.mcp_client_id in audiences: + logger.debug( + f"Token has audience {audiences} - MCP client ID present" + ) + else: logger.warning( f"Token rejected: wrong audience {audiences}, expected {self.mcp_client_id} or no audience" ) logger.error( - "Received Nextcloud token in MCP context - " + "Token does not include MCP client ID in audience - " "client may be using wrong token" ) return None - - # If audience is present but doesn't match, log warning but continue - # (token might use resource URL instead of client ID) - if self.mcp_client_id not in audiences: - logger.info( - f"Token has audience {audiences}, expected {self.mcp_client_id}. " - "Accepting token with non-standard audience (may use resource URL)." - ) else: logger.info( "Token has no audience claim - accepting for MCP server validation"