Files
mcp-nextcloud/docs/keycloak-multi-client-validation.md
Chris CoutinhoandClaude Opus 4.7 d21be6d5e1 docs: generalize OIDC framing to support multiple IdPs
Previous round narrowed the framing too far in the other direction —
made it sound like Nextcloud OIDC is *the* IdP. The MCP server
actually supports any OIDC-compliant provider (Nextcloud's built-in
OIDC, Keycloak, AWS Cognito, Auth0, etc.) selected via
`OIDC_DISCOVERY_URL`. `NEXTCLOUD_OIDC_CLIENT_ID/SECRET` are generic
OIDC client credentials despite the Nextcloud-flavored naming.

Code references:
- IdP discovery: app.py:607-668 (auto-detects integrated vs external
  by comparing discovered issuer to NEXTCLOUD_HOST)
- JWKS: unified_verifier.py:71-73 (dynamically discovered, not
  hard-coded to Nextcloud)
- IdP selection knob: OIDC_DISCOVERY_URL (config.py)

Changes:
- login-flow-v2.md: redraw "How It Works" diagram to show the IdP as
  a separate component; replace "Nextcloud OIDC" with "configurable
  IdP" framing throughout; add OIDC_DISCOVERY_URL to the env-var
  reference; clarify NEXTCLOUD_OIDC_CLIENT_ID/SECRET are generic OIDC
  creds; rename "OAuth Endpoints" subtitle to point at "the configured
  IdP".
- running.md: rewrite the OAuth Mode intro and Quick Start note to
  mention IdP configurability and OIDC_DISCOVERY_URL.
- configuration.md: update Best Practices "For Production" multi-user
  bullet to reference the IdP selector and generic-creds caveat.
- auth-flows.md: generalize Astrolabe-flow and Login Flow v2
  characteristics bullets — IdP and JWKS source are configurable.
- keycloak-multi-client-validation.md: REMOVE the "deprecated"
  banner I added in 35c115e. The doc covers active behavior in
  external-IdP mode (realm-level token validation by user_oidc),
  not retired direct-OAuth-to-Nextcloud architecture. Replaced with
  a scope note pointing at when this applies.

oauth-impersonation-findings.md keeps its deprecation banner — that
doc *is* about the rejected service-account / impersonation path
(ADR-002 Tier 2, "Will Not Implement"), so the deprecation framing
remains correct there.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 02:34:38 +02:00

9.2 KiB

Keycloak Multi-Client Token Validation

Applies to: External IdP mode (Keycloak / Cognito / etc.). When the MCP server is configured against an external OIDC provider via OIDC_DISCOVERY_URL, Nextcloud's user_oidc app validates incoming Bearer tokens at the realm level — see findings below. This is independent of Login Flow v2, which governs the per-user app-password leg.

Executive Summary

Question: Can Nextcloud's user_oidc app (configured with client A) validate bearer tokens from client B in the same Keycloak realm?

Answer: YES - user_oidc validates tokens at the realm level, not per-client.

Test Results

Setup

  • Keycloak Realm: nextcloud-mcp
  • Provider in user_oidc: Configured with mcp-client credentials
  • Test: Get token from test-client-b, validate via Nextcloud API

Result

# Token from test-client-b (client B)
$ TOKEN=$(curl -X POST ".../token" -d "client_id=test-client-b" ...)

# Validated successfully by Nextcloud (configured with mcp-client = client A)
$ curl -H "Authorization: Bearer $TOKEN" "http://nextcloud/ocs/.../capabilities"
HTTP/1.1 200 OK
{"ocs":{"meta":{"status":"ok"}}}

Token from client B validated successfully!

How It Works

Token Structure from Keycloak

Access Token (password grant):

{
  "iss": "http://keycloak/realms/nextcloud-mcp",
  "azp": "test-client-b",           // Authorized party = client B
  "typ": "Bearer",
  "exp": 1234567890,
  // NO "sub" claim
  // NO "aud" claim
  "scope": "openid profile email"
}

ID Token (for comparison):

{
  "iss": "http://keycloak/realms/nextcloud-mcp",
  "aud": "test-client-b",            // Audience = client B
  "sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
  "azp": "test-client-b"
}

Key Observation: Access tokens from Keycloak's password grant do not contain sub or aud claims!

Validation Flow in user_oidc

From source code analysis (~/Software/user_oidc/lib/User/Backend.php):

1. Request with Bearer token arrives
   ↓
2. user_oidc loops through providers with checkBearer=true
   ↓
3. Try SelfEncodedValidator (JWT/JWKS validation):
   - Validates JWT signature using Keycloak's JWKS
   - Tries to extract 'sub' claim → FAILS (no sub in access token)
   ↓
4. Fallback to UserInfoValidator:
   - Calls Keycloak userinfo endpoint with bearer token
   - Keycloak validates token server-side
   - Returns userinfo with 'sub' claim
   → SUCCESS!
   ↓
5. User identified, request authorized

Why This Works

Realm-Level Trust:

  • Keycloak's userinfo endpoint validates ANY valid token from the realm
  • It doesn't matter which client issued the token
  • The token is validated by Keycloak itself (via userinfo call)

No Audience Check:

  • Access tokens have no aud claim
  • SelfEncodedValidator's audience check is bypassed (no audience to validate)
  • UserInfoValidator doesn't check audience (delegates to Keycloak)

Client Credentials Role:

  • The configured client_id/client_secret in user_oidc are NOT used for bearer token validation
  • They're only used for OAuth login flows (authorization code exchange)
  • Userinfo endpoint doesn't require client authentication

Source Code Evidence

SelfEncodedValidator - Audience Check

// ~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php:64-76

$checkAudience = !isset($oidcSystemConfig['selfencoded_bearer_validation_audience_check'])
    || !in_array($oidcSystemConfig['selfencoded_bearer_validation_audience_check'],
         [false, 'false', 0, '0'], true);

if ($checkAudience) {
    $tokenAudience = $payload->aud ?? null;

    if ((is_string($tokenAudience) && $tokenAudience !== $providerClientId)
        || (is_array($tokenAudience) && !in_array($providerClientId, $tokenAudience))) {
        $this->logger->debug('Audience does not match client ID');
        return null;  // REJECT
    }
}

// If $tokenAudience is null (our case), both conditions are false → validation continues

UserInfoValidator - No Client Auth

// ~/Software/user_oidc/lib/Service/OIDCService.php:28-45

public function userinfo(Provider $provider, string $accessToken): array {
    $url = $this->discoveryService->obtainDiscovery($provider)['userinfo_endpoint'];

    // Bearer token passed directly - NO client credentials used
    $options = ['headers' => ['Authorization' => 'Bearer ' . $accessToken]];

    return json_decode($this->clientService->get($url, [], $options), true);
}

Keycloak Userinfo Response

$ curl -H "Authorization: Bearer $TOKEN_FROM_CLIENT_B" \
    "http://keycloak/realms/nextcloud-mcp/protocol/openid-connect/userinfo"

{
  "sub": "923da741-7ebe-4cf9-baf2-37fcf2ecc95d",
  "email_verified": true,
  "name": "Admin User",
  "email": "admin@example.com"
}

Keycloak validates the token regardless of which client issued it, as long as it's from the same realm.

Implications for Your Architecture

Desired Architecture

MCP Server (client A) ← DCR with Keycloak
MCP Clients (client B, C, D...) ← DCR with Keycloak
Nextcloud user_oidc ← configured once with any client from realm

What This Means

You can do exactly what you want!

  1. Configure user_oidc once with any client from the Keycloak realm (e.g., a dedicated nextcloud-validator client)

  2. MCP Server registers via DCR as a unique client (e.g., mcp-server-abc123)

    • Gets its own client credentials
    • Issues tokens with azp: "mcp-server-abc123"
    • These tokens will be validated by user_oidc!
  3. MCP Clients also use DCR (each gets unique identity)

    • Client A: client-123
    • Client B: client-456
    • Tokens from all clients validated by user_oidc!
  4. Tokens from ANY client in the realm can access Nextcloud APIs

    • user_oidc validates via Keycloak userinfo endpoint
    • Realm-level trust (not per-client)

Configuration

Step 1: Configure user_oidc Provider

php occ user_oidc:provider keycloak-realm \
    --clientid="nextcloud-validator" \
    --clientsecret="***" \
    --discoveryuri="https://keycloak/realms/my-realm/.well-known/openid-configuration" \
    --check-bearer=1 \
    --bearer-provisioning=1

Step 2: MCP Server Registers with Keycloak (DCR)

# MCP server startup
registration_response = await keycloak_client.register_client(
    client_name="MCP Server Instance",
    redirect_uris=["http://mcp-server/oauth/callback"]
)
# Store: client_id, client_secret

Step 3: Issue Tokens to Users

  • Users authenticate via Keycloak
  • MCP server gets tokens issued to its client_id
  • These tokens validated by user_oidc!

Step 4: Background Operations (ADR-002)

  • Store user refresh tokens (encrypted)
  • Refresh access tokens as needed
  • All tokens validated by user_oidc regardless of issuing client

Important Notes

Token Grant Types Matter

Password Grant (what we tested):

  • Access tokens have NO sub or aud
  • Forces validation via userinfo endpoint
  • Works with any client in realm

Authorization Code Grant (production):

  • Tokens MAY include aud claim
  • Need to verify behavior with real OAuth flows
  • May require disabling audience check

Recommendation for Production

Option 1: Disable Audience Check (Simplest)

// config.php
'user_oidc' => [
    'selfencoded_bearer_validation_audience_check' => false,
],

Option 2: Rely on UserInfo Validation

// config.php
'user_oidc' => [
    'userinfo_bearer_validation' => true,  // Enable userinfo validation
],

Option 3: Configure Keycloak to Not Include aud in Access Tokens

  • Keep default behavior (works as tested)
  • Tokens validated via userinfo endpoint

Testing Script

#!/bin/bash
# Test multi-client validation

# Create second client in Keycloak
curl -X POST "http://keycloak/admin/realms/my-realm/clients" \
    -H "Authorization: Bearer $ADMIN_TOKEN" \
    -d '{
        "clientId": "test-client-b",
        "secret": "test-secret-b",
        "standardFlowEnabled": true,
        "directAccessGrantsEnabled": true
    }'

# Get token from client B
TOKEN=$(curl -X POST "http://keycloak/realms/my-realm/protocol/openid-connect/token" \
    -d "grant_type=password" \
    -d "client_id=test-client-b" \
    -d "client_secret=test-secret-b" \
    -d "username=testuser" \
    -d "password=password" | jq -r '.access_token')

# Test with Nextcloud (configured with client A)
curl -H "Authorization: Bearer $TOKEN" \
    "http://nextcloud/ocs/v2.php/cloud/capabilities"

# Should return 200 OK!

Conclusion

Your proposed architecture is fully supported!

  • user_oidc configured once with ANY client from Keycloak realm
  • MCP server registers dynamically via DCR
  • MCP clients also register dynamically
  • ALL tokens from realm validated successfully
  • No per-client configuration needed

The key insight: user_oidc validates tokens at the realm level (via Keycloak's userinfo endpoint), not at the client level.

References

  • Source code: ~/Software/user_oidc/lib/User/Backend.php:260-343
  • SelfEncodedValidator: ~/Software/user_oidc/lib/User/Validator/SelfEncodedValidator.php
  • UserInfoValidator: ~/Software/user_oidc/lib/User/Validator/UserInfoValidator.php
  • Test setup: docker-compose.yml (mcp-keycloak service)
  • Configuration: .env.keycloak.sample