Service account tokens (client_credentials grant) violate OAuth "act on-behalf-of" principles and have been moved to ADR-002's "Will Not Implement" section. ## Problem Discovery Testing revealed that service account tokens create Nextcloud user accounts (e.g., `service-account-nextcloud-mcp-server`) due to user_oidc's bearer provisioning feature. This violates core OAuth principles: - ❌ Creates stateful server identity in Nextcloud - ❌ All actions attributed to service account, not real user - ❌ Breaks audit trail and user attribution - ❌ Service account becomes "admin by another name" ## Changes ### Documentation (ADR-002) - Moved service account (old Tier 1) to "Will Not Implement" section - Added "OAuth Act On-Behalf-Of Principle" section - Renumbered tiers: - Tier 1: Impersonation (NOT IMPLEMENTED) - Tier 2: Delegation via token exchange (IMPLEMENTED) - Updated status to reflect rejection of service accounts ### Code Warnings - Added comprehensive warning to KeycloakOAuthClient.get_service_account_token() - Clarified VALID use: only as subject_token for RFC 8693 token exchange - Clarified INVALID use: direct API access with service account token ### Supporting Documentation - CLAUDE.md: Removed outdated "Tier 1" references, added rejection note - oauth-impersonation-findings.md: Added prominent update banner - audience-validation-setup.md: Updated tier numbers, added rejection note - tests/manual/test_token_exchange.py: Added warning comment ## Valid Patterns (ADR-002) ✅ Foreground operations: User's access token from MCP request ✅ Background operations: Token exchange (impersonation/delegation) ✅ Offline access: Refresh tokens with user consent ❌ Service accounts: Creates independent server identity (REJECTED) ## Alternative If service account pattern is truly needed, use BasicAuth mode instead of OAuth mode. OAuth mode MUST maintain "act on-behalf-of" semantics. Related: c12df98 (revert of service account test) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
18 KiB
Audience Validation Setup
Overview
This document explains the separate clients architecture for Keycloak → MCP Server → Nextcloud integration, following OAuth 2.0 best practices and RFC 8707 (Resource Indicators).
Architecture: Separate Clients Pattern
Keycloak Realm: nextcloud-mcp
├── Client: "nextcloud" (Resource Server)
│ └── Represents Nextcloud as a protected resource
│ └── Used by user_oidc for bearer token validation
│ └── Validates tokens with aud="nextcloud"
│
└── Client: "nextcloud-mcp-server" (OAuth Client)
└── MCP Server uses this to REQUEST tokens
└── Issues tokens with aud="nextcloud" (targeting resource)
└── Future: aud=["nextcloud", "other-service"]
Token Flow:
MCP Server (client: nextcloud-mcp-server)
↓ requests token from Keycloak
Token issued:
- aud: "nextcloud" (intended for Nextcloud resource)
- azp: "nextcloud-mcp-server" (requested by MCP Server)
- preferred_username: "admin" (on behalf of user)
↓ sent to Nextcloud API
Nextcloud user_oidc (client: nextcloud)
✓ validates aud matches configured client_id
Key Benefits:
- ✅ Proper OAuth separation: OAuth client ≠ resource server
- ✅ Future extensibility: MCP Server can request multi-resource tokens
- ✅ RFC 8707 compliance: Audience indicates intended resource
- ✅ Clear requester identification: azp claim identifies MCP Server
Token Claims
Tokens issued by the nextcloud-mcp-server client contain:
aud: "nextcloud"- Audience: Token intended for Nextcloud resource server (matches user_oidc client_id)azp: "nextcloud-mcp-server"- Authorized Party: Identifies MCP Server as the OAuth client that requested the tokenpreferred_username: "admin"- User identifier (Keycloak uses this for password grant;subfor authorization_code grant)scope: "openid profile email offline_access"- Requested scopes including offline access for background jobs
How user_oidc Validates:
- SelfEncodedValidator checks:
aud == user_oidc.client_id?- ✓ "nextcloud" == "nextcloud" → PASS
- Fast JWT verification with JWKS (no HTTP call to userinfo endpoint)
- User provisioned based on
preferred_usernameorsubclaim
For Background Jobs:
- MCP Server stores encrypted refresh tokens
- Refreshes access tokens when needed
- All tokens have
aud: "nextcloud"→ validated by user_oidc - No admin credentials required
Configuration
The configuration requires two separate clients in Keycloak:
nextcloud- Resource server client (for user_oidc validation)nextcloud-mcp-server- OAuth client (for MCP Server to request tokens)
1. Keycloak - Create Resource Server Client
First, create the nextcloud client that represents Nextcloud as a resource server:
Via Keycloak Admin API:
# Get admin token
ADMIN_TOKEN=$(curl -X POST "http://localhost:8888/realms/master/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=admin-cli" \
-d "username=admin" \
-d "password=admin" | jq -r '.access_token')
# Create 'nextcloud' resource server client
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "nextcloud",
"name": "Nextcloud Resource Server",
"description": "Resource server for Nextcloud APIs - used by user_oidc for bearer token validation",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "nextcloud-secret-change-in-production",
"bearerOnly": true,
"standardFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"publicClient": false
}'
Via Realm Export (keycloak/realm-export.json):
{
"clients": [
{
"clientId": "nextcloud",
"name": "Nextcloud Resource Server",
"enabled": true,
"bearerOnly": true,
"secret": "nextcloud-secret-change-in-production"
}
]
}
2. Keycloak - Create OAuth Client with Audience Mapper
Next, create the nextcloud-mcp-server client that MCP Server uses to request tokens:
Via Keycloak Admin API:
# Create 'nextcloud-mcp-server' OAuth client
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"clientId": "nextcloud-mcp-server",
"name": "Nextcloud MCP Server",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "mcp-secret-change-in-production",
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["http://localhost:*/callback"]
}'
# Get client internal ID
CLIENT_ID=$(curl "http://localhost:8888/admin/realms/nextcloud-mcp/clients" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq -r '.[] | select(.clientId=="nextcloud-mcp-server") | .id')
# Add audience mapper targeting 'nextcloud' resource
curl -X POST "http://localhost:8888/admin/realms/nextcloud-mcp/clients/$CLIENT_ID/protocol-mappers/models" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "audience-nextcloud",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.custom.audience": "nextcloud",
"access.token.claim": "true",
"id.token.claim": "false"
}
}'
Option B: Via Realm Export (for infrastructure-as-code)
Update keycloak/realm-export.json:
{
"clients": [
{
"clientId": "nextcloud-mcp-server",
"name": "Nextcloud MCP Server",
"protocolMappers": [
{
"name": "audience-nextcloud-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"
}
}
]
}
]
}
Then re-import realm or restart Keycloak.
Option C: Via Keycloak Admin UI
- Go to Keycloak Admin Console → Realm → Clients →
nextcloud-mcp-server - Click "Client scopes" tab
- Click "Add client scope" → "Create dedicated scope"
- Add protocol mapper: "Audience"
- Mapper Type:
Audience - Included Custom Audience:
nextcloud - Add to access token: ON
- Add to ID token: OFF
- Mapper Type:
3. Nextcloud user_oidc - Configure Resource Server Client
Configure user_oidc to use the nextcloud resource server client:
docker compose exec app php occ user_oidc:provider keycloak \
--clientid="nextcloud" \
--clientsecret="nextcloud-secret-change-in-production" \
--discoveryuri="http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration" \
--check-bearer=1 \
--bearer-provisioning=1 \
--unique-uid=1 \
--mapping-uid="sub" \
--mapping-display-name="name" \
--mapping-email="email"
Result: user_oidc validates tokens with aud="nextcloud" using SelfEncodedValidator (fast JWT verification).
3. Nextcloud user_oidc - Realm-Level Validation
Nextcloud's user_oidc app validates at realm level via userinfo endpoint:
- ✅ No configuration needed - works automatically
- ✅ Validates any token from Keycloak realm
- ✅ Audience check is optional (disabled by default)
Optional: Disable strict audience checking (if enabled):
docker compose exec app php occ config:app:set user_oidc \
selfencoded_bearer_validation_audience_check --value=false --type=boolean
Verification
1. Check Token Claims
# Get token from Keycloak
TOKEN=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=nextcloud-mcp-server" \
-d "client_secret=mcp-secret-change-in-production" \
-d "username=admin" \
-d "password=admin" | jq -r '.access_token')
# Decode JWT
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq '.'
# Should show:
{
"aud": "nextcloud", # ✓ Intended for Nextcloud
"azp": "nextcloud-mcp-server", # ✓ Requested by MCP Server
"iss": "http://localhost:8888/realms/nextcloud-mcp",
"scope": "openid email profile offline_access",
...
}
2. Test with Nextcloud API
# Token should be accepted
curl -H "Authorization: Bearer $TOKEN" \
"http://localhost:8080/ocs/v2.php/cloud/capabilities"
# Should return HTTP 200 OK
3. Test Audience Rejection
# Get token from different client (without audience mappers)
TOKEN_WRONG=$(curl -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=test-client-b" \
-d "client_secret=test-secret-b" \
-d "username=admin" \
-d "password=admin" | jq -r '.access_token')
# This token has NO audience claim - should be rejected by MCP server
# (But accepted by Nextcloud user_oidc which validates at realm level)
Token Flow Example
Successful Request (Background Job)
1. User authorizes MCP Client via OAuth
└─ MCP Server gets refresh token (stored encrypted)
2. Background worker needs to sync data
└─ MCP Server refreshes access token from Keycloak
└─ Token issued with aud: "nextcloud", azp: "nextcloud-mcp-server"
3. MCP Server → Nextcloud API (with token)
└─ user_oidc validates via userinfo endpoint ✓
└─ Nextcloud identifies:
- Token intended for Nextcloud (aud: "nextcloud")
- Request from MCP Server (azp: "nextcloud-mcp-server")
- On behalf of user (sub: "user-id")
4. Success! MCP Server can act on behalf of user in background.
Rejected Request
1. Attacker gets token for different client
└─ Token has aud: "other-service"
2. Attacker → Nextcloud API (with wrong token)
└─ user_oidc validates via userinfo endpoint
└─ Token validation fails (invalid/expired/wrong realm)
└─ HTTP 401 Unauthorized
3. Request blocked - token not valid for this realm/service
OAuth Flows and User Consent
When Does the User Grant Consent?
User consent happens during the Authorization Code Flow (production OAuth):
1. User clicks "Connect" in MCP Client (e.g., Claude Desktop)
2. MCP Client initiates OAuth flow by opening browser to Keycloak:
https://keycloak/realms/nextcloud-mcp/protocol/openid-connect/auth?
client_id=nextcloud-mcp-server&
redirect_uri=<mcp-client-redirect-uri>&
response_type=code&
scope=openid profile email offline_access
3. Keycloak shows login screen (if not logged in)
4. **Keycloak shows consent screen:**
"Nextcloud MCP Server wants to access your Nextcloud data on your behalf"
Requested permissions:
- Access your profile (openid, profile, email)
- Offline access (background operations with refresh tokens)
5. User clicks "Allow" → grants consent
6. Keycloak redirects back to MCP Client with authorization code
7. MCP Client exchanges code for tokens (receives access + refresh tokens)
8. MCP Client shares tokens with MCP Server via MCP protocol
9. MCP Server stores refresh token encrypted for background operations
Key Architecture Notes:
- MCP Server is a protected resource (requires OAuth to access)
- MCP Client (Claude Desktop) is the OAuth client that initiates the flow
- MCP Client handles the redirect and token exchange with Keycloak
- MCP Client shares refresh token with MCP Server so it can act on behalf of user in background
Key Points:
- ✅ Explicit user consent before any access
- ✅ Scopes displayed so user knows what's being requested
- ✅ Offline access must be explicitly granted (for background jobs)
- ✅ Revocable - user can revoke consent in Keycloak at any time
Grant Types
Our architecture supports multiple OAuth grant types:
1. Authorization Code + PKCE (Production)
Use case: Interactive login from MCP clients
Consent: Yes - explicit user authorization
Tokens: Access token + Refresh token (if offline_access granted)
Security: PKCE prevents authorization code interception
2. Password Grant (Testing Only)
Use case: Integration testing with docker-compose
Consent: No - username/password provided directly
Tokens: Access token + Refresh token
Security: NOT for production - exposes user credentials
3. Refresh Token Grant (Background Jobs)
Use case: MCP Server refreshing expired access tokens
Consent: No new consent - uses previously granted refresh token
Tokens: New access token (refresh token may rotate)
Security: Refresh tokens stored encrypted, rotated on use
Authentication Strategies for Background Jobs
Note on Service Account Tokens: Service account tokens (
client_credentialsgrant) were evaluated but rejected as they create Nextcloud user accounts (e.g.,service-account-{client_id}) which violates OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section for details.
Current Approach: Offline Access with Refresh Tokens
The MCP server uses offline_access scope to enable background operations:
How it works:
- User grants
offline_accessscope during OAuth consent - MCP Client receives refresh token from Keycloak
- MCP Client shares refresh token with MCP Server via MCP protocol
- MCP Server stores refresh token encrypted (see ADR-002)
- Background jobs exchange refresh token for fresh access tokens as needed
Benefits:
- ✅ Works today with Keycloak and all OIDC providers
- ✅ Standard OAuth pattern (RFC 6749)
- ✅ Explicit user consent to
offline_accessscope - ✅ MCP Server can act on behalf of user in background
Limitations:
- ⚠️ Requires secure token storage on MCP Server
- ⚠️ MCP Client must trust MCP Server with refresh token
- ⚠️ Weak audit trail - API requests appear to come from user directly
- ⚠️ No visibility that MCP Server is the actual actor
Token Exchange with Delegation (ADR-002 Tier 2 - Implemented)
RFC 8693 Delegation would provide better audit trail and security:
How it would work:
- User grants
may_act:nextcloud-mcp-serverscope during authentication - Subject token includes:
{ "may_act": { "client": "nextcloud-mcp-server" } } - MCP Server has its own service account token (actor_token)
- Background job requests token exchange:
subject_token(user's token with may_act claim)actor_token(mcp-server's service token)
- Keycloak validates actor matches may_act claim
- Returns delegated token:
{ "sub": "user", "act": "nextcloud-mcp-server" }
Benefits:
- ✅ Better audit trail - Nextcloud APIs see both user and actor
- ✅ No token storage needed (tokens generated on-demand)
- ✅ Fine-grained permissions via
may_actclaim - ✅ User explicitly consents to MCP Server acting on their behalf
- ✅ RFC 8693 compliant
Current Status:
- ❌ NOT implemented in Keycloak yet (Issue #38279)
- ❌ Would require custom implementation or waiting for upstream
- 📝 Proposal includes
actclaim andmay_actconsent mechanism
Why Not Available:
- Keycloak supports impersonation (changes
subclaim), but not delegation (actclaim) - Impersonation has poor audit trail (actor invisible)
- Delegation proposal is open but not implemented yet
Reference: See docs/ADR-002-vector-sync-authentication.md for detailed comparison of authentication tiers.
Security Benefits
- Intent Validation: Tokens explicitly declare Nextcloud as the intended recipient via
audclaim - Requester Identification: The
azpclaim identifies MCP Server as the requester - User Context: The
subclaim preserves user identity for audit and authorization - Background Jobs: Refresh tokens enable MCP Server to act on behalf of users without admin credentials
- OAuth Standards: Follows RFC 8707 (Resource Indicators) and RFC 6749 (OAuth 2.0)
Current Limitations:
- API requests from background jobs appear to come from user directly (no
actclaim yet) - See "Authentication Strategies for Background Jobs" section for future delegation support
Token Claims
Key Claims
aud: "nextcloud"- Audience: Token intended for Nextcloud APIsazp: "nextcloud-mcp-server"- Authorized Party: MCP Server requested the tokensub: "user-id"- Subject: User on whose behalf the request is madescope: "openid profile email offline_access"- Requested scopes including offline access for background jobs
Client Naming
The Keycloak client is named nextcloud-mcp-server to clarify:
- MCP Server uses this client to get tokens for Nextcloud
- MCP Clients (like Claude Desktop) connect to MCP Server via separate OAuth flows
- Not named "mcp-client" to avoid confusion about which component is the client
Troubleshooting
Token Has No Audience
Symptom: "aud": null in decoded JWT
Cause: Protocol mappers not configured
Solution: Add audience mappers via Keycloak Admin API (see Configuration section)
MCP Server Rejects Token
Symptom: HTTP 401 with "JWT validation failed"
Cause: Token audience doesn't match expected value
Solution:
- Check token has correct
audclaim - Verify MCP server expects correct audience value in code
- Check logs for specific JWT validation error
Nextcloud Rejects Token
Symptom: HTTP 401 from Nextcloud API
Cause: User not provisioned or token invalid
Solution:
- Check user_oidc provider is configured:
php occ user_oidc:provider keycloak - Check bearer validation enabled:
--check-bearer=1 - Test token with userinfo endpoint:
curl -H "Authorization: Bearer $TOKEN" http://keycloak/realms/.../userinfo
Related Documentation
- Multi-client validation:
docs/keycloak-multi-client-validation.md - ADR-002:
docs/ADR-002-vector-sync-authentication.md - OAuth setup:
docs/oauth-setup.md - Keycloak integration:
docs/keycloak-integration.md(if created)