f34366a260
Implements Keycloak as an external OIDC provider following ADR-002 architecture for background job authentication using offline_access. ## Features - Keycloak OAuth provider with PKCE and offline_access support - Refresh token storage with Fernet encryption - Token verifier for both JWT and opaque tokens - Multi-client validation (realm-level trust) - Sample configuration for Keycloak integration ## Implementation ### OAuth Provider (keycloak_oauth.py) - Authorization Code Flow with PKCE - Refresh token exchange - OIDC discovery endpoint support - Token validation with JWKS ### Token Storage (refresh_token_storage.py) - Encrypted storage using Fernet symmetric encryption - SQLite backend for persistence - Token rotation support - Per-user token management ### Token Verifier Updates - Support both JWT (self-encoded) and opaque tokens - JWKS-based JWT signature verification - Introspection endpoint fallback for opaque tokens - Scope extraction from both token types ### Configuration - .env.keycloak.sample: Example configuration with Keycloak URLs - docs/keycloak-multi-client-validation.md: Realm-level validation documentation - app-hooks/post-installation/10-install-user_oidc-app.sh: Updated dependencies ## Architecture Notes - MCP Server is a protected resource (requires OAuth) - MCP Client initiates OAuth flow and shares refresh tokens - Refresh tokens enable background operations without admin credentials - Supports future token exchange delegation when Keycloak implements it ## References - ADR-002: Vector Database Background Sync Authentication - RFC 6749: OAuth 2.0 (offline_access, refresh tokens) - RFC 7517: JSON Web Key (JWK) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
299 lines
8.9 KiB
Markdown
299 lines
8.9 KiB
Markdown
# Keycloak Multi-Client Token Validation
|
|
|
|
## 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
|
|
```bash
|
|
# 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):
|
|
```json
|
|
{
|
|
"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):
|
|
```json
|
|
{
|
|
"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
|
|
|
|
```php
|
|
// ~/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
|
|
|
|
```php
|
|
// ~/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
|
|
|
|
```bash
|
|
$ 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**
|
|
```bash
|
|
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)**
|
|
```python
|
|
# 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)**
|
|
```php
|
|
// config.php
|
|
'user_oidc' => [
|
|
'selfencoded_bearer_validation_audience_check' => false,
|
|
],
|
|
```
|
|
|
|
**Option 2: Rely on UserInfo Validation**
|
|
```php
|
|
// 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
|
|
|
|
```bash
|
|
#!/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`
|