feat: implement RFC 8693 Standard Token Exchange for Keycloak

Configure Keycloak 26.4.2 realm to support Standard Token Exchange V2,
enabling the MCP server to exchange client tokens (aud: nextcloud-mcp-server)
for Nextcloud-scoped tokens (aud: nextcloud) via RFC 8693.

Changes:
- Remove duplicate audience workarounds from realm configuration
- Add token-exchange-nextcloud client scope with audience mapper
- Configure scope as default for nextcloud-mcp-server client
- Enable standard.token.exchange.enabled on both clients
- Add comprehensive integration tests (7 tests, all passing)

Token Exchange Flow:
1. Client obtains token with aud: [nextcloud-mcp-server, nextcloud]
2. Server exchanges to aud: nextcloud, azp: nextcloud-mcp-server
3. Exchanged token used for Nextcloud API calls
4. Each request gets fresh ephemeral token (stateless)

Key Implementation Details:
- Uses Keycloak 26.2+ scope-based authorization (no FGAP required)
- Target audiences must be in client's default/optional scopes
- Protocol mappers alone don't grant exchange permission
- Tokens expire after 300s (5 minutes)

Tests validate:
- Basic token exchange flow
- Nextcloud API integration (Capabilities, Notes)
- CRUD operations with exchanged tokens
- Multiple stateless exchanges from same client token
- Token claims preservation (aud, azp, sub)
- Scope configuration validation

See docs/ADR-004-progressive-consent.md for architecture details.

🤖 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 02:30:37 +01:00
co-authored by Claude
parent b20c9c6203
commit 0ff85dbe4f
2 changed files with 398 additions and 24 deletions
+18 -24
View File
@@ -166,13 +166,13 @@
{
"clientId": "nextcloud",
"name": "Nextcloud Resource Server",
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation",
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation and as token exchange target",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "nextcloud-secret-change-in-production",
"redirectUris": [],
"webOrigins": [],
"bearerOnly": true,
"bearerOnly": false,
"consentRequired": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
@@ -181,7 +181,10 @@
"publicClient": false,
"protocol": "openid-connect",
"attributes": {
"display.on.consent.screen": "false"
"display.on.consent.screen": "false",
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true",
"standard.token.exchange.enabled": "true"
},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1
@@ -220,18 +223,19 @@
"client_credentials.use_refresh_token": "false",
"display.on.consent.screen": "false",
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
"client.token.exchange.standard.enabled": "true",
"standard.token.exchange.enabled": "true"
},
"fullScopeAllowed": true,
"nodeReRegistrationTimeout": -1,
"protocolMappers": [
{
"name": "audience-nextcloud",
"name": "audience-mcp-server",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "nextcloud",
"included.custom.audience": "nextcloud-mcp-server",
"access.token.claim": "true",
"id.token.claim": "false"
}
@@ -308,13 +312,15 @@
"web-origins",
"profile",
"roles",
"email"
"email",
"token-exchange-nextcloud"
],
"optionalClientScopes": [
"address",
"phone",
"offline_access",
"microprofile-jwt",
"token-exchange-nextcloud",
"notes:read",
"notes:write",
"calendar:read",
@@ -685,27 +691,16 @@
}
},
{
"name": "audience",
"description": "Audience scope for token validation",
"name": "token-exchange-nextcloud",
"description": "Allows token exchange for nextcloud client",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "mcp-server-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "nextcloud-mcp-server",
"id.token.claim": "false",
"access.token.claim": "true"
}
},
{
"name": "nextcloud-audience",
"name": "nextcloud-audience-for-exchange",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
@@ -756,8 +751,7 @@
"profile",
"email",
"roles",
"web-origins",
"audience"
"web-origins"
],
"defaultOptionalClientScopes": [
"offline_access",