docs: Implement separate clients architecture for Keycloak integration
Implements proper OAuth 2.0 separation following RFC 8707 best practices with distinct resource server and OAuth client configurations. ## Architecture Changes - Create separate "nextcloud" bearer-only client (resource server) - Configure "nextcloud-mcp-server" OAuth client with audience mapper - Audience mapper targets "nextcloud" resource server - Token flow: aud="nextcloud", azp="nextcloud-mcp-server" ## Benefits - Proper OAuth client vs resource server separation - Support for future multi-resource tokens: aud=["nextcloud", "other-service"] - RFC 8707 Resource Indicators compliance - Clear requester identification via azp claim ## Documentation Updates - Correct OAuth flow: MCP Client initiates, handles redirect, shares tokens - Explain MCP Server as protected resource architecture - Document offline_access with refresh tokens (Tier 1, current) - Document token exchange with delegation (Tier 2, future when Keycloak adds support) - Reference Keycloak issue #38279 for delegation status ## Files - keycloak/realm-export.json: Add separate clients configuration - app-hooks/post-installation/15-setup-keycloak-provider.sh: Setup user_oidc with "nextcloud" client - docs/audience-validation-setup.md: Comprehensive documentation with corrected OAuth flow and delegation comparison - docker-compose.yml: Fix Keycloak healthcheck (bash TCP instead of curl) - scripts/test_separate_clients.sh: Verification script for architecture 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+100
@@ -0,0 +1,100 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Configure user_oidc to accept bearer tokens from Keycloak
|
||||||
|
#
|
||||||
|
# This script sets up Keycloak as an external OIDC provider for Nextcloud.
|
||||||
|
# It enables bearer token validation, allowing the MCP server to use Keycloak
|
||||||
|
# tokens to access Nextcloud APIs without admin credentials.
|
||||||
|
#
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "Configuring user_oidc provider for Keycloak..."
|
||||||
|
echo "===================================================================="
|
||||||
|
|
||||||
|
# Wait for Keycloak to be ready and realm to be available
|
||||||
|
echo "Waiting for Keycloak realm to be available..."
|
||||||
|
MAX_RETRIES=30
|
||||||
|
RETRY_COUNT=0
|
||||||
|
|
||||||
|
while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do
|
||||||
|
if curl -sf http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null 2>&1; then
|
||||||
|
echo "✓ Keycloak realm is ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo " Waiting for Keycloak... (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)"
|
||||||
|
sleep 5
|
||||||
|
RETRY_COUNT=$((RETRY_COUNT + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ $RETRY_COUNT -eq $MAX_RETRIES ]; then
|
||||||
|
echo "⚠ Warning: Keycloak not available after $MAX_RETRIES attempts"
|
||||||
|
echo " Keycloak provider will not be configured"
|
||||||
|
echo " You can configure it manually using:"
|
||||||
|
echo " docker compose exec app php occ user_oidc:provider keycloak \\"
|
||||||
|
echo " --clientid='nextcloud' \\"
|
||||||
|
echo " --clientsecret='nextcloud-secret-change-in-production' \\"
|
||||||
|
echo " --discoveryuri='http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration' \\"
|
||||||
|
echo " --check-bearer=1 \\"
|
||||||
|
echo " --bearer-provisioning=1 \\"
|
||||||
|
echo " --unique-uid=1"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if provider already exists
|
||||||
|
if php /var/www/html/occ user_oidc:provider keycloak 2>/dev/null | grep -q "Identifier"; then
|
||||||
|
echo " Keycloak provider already exists, updating configuration..."
|
||||||
|
|
||||||
|
# Update existing provider
|
||||||
|
php /var/www/html/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" \
|
||||||
|
--scope="openid profile email offline_access"
|
||||||
|
|
||||||
|
echo "✓ Updated Keycloak provider configuration"
|
||||||
|
else
|
||||||
|
echo " Creating new Keycloak provider..."
|
||||||
|
|
||||||
|
# Create new provider
|
||||||
|
php /var/www/html/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" \
|
||||||
|
--scope="openid profile email offline_access"
|
||||||
|
|
||||||
|
echo "✓ Created Keycloak provider"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Display provider details
|
||||||
|
echo ""
|
||||||
|
echo "Keycloak provider configuration:"
|
||||||
|
php /var/www/html/occ user_oidc:provider keycloak
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "✓ Keycloak provider configured successfully"
|
||||||
|
echo "===================================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Key features enabled:"
|
||||||
|
echo " • Bearer token validation (--check-bearer=1)"
|
||||||
|
echo " • Automatic user provisioning (--bearer-provisioning=1)"
|
||||||
|
echo " • Unique user IDs (--unique-uid=1)"
|
||||||
|
echo " • Offline access scope (for refresh tokens)"
|
||||||
|
echo ""
|
||||||
|
echo "MCP server can now use Keycloak tokens to access Nextcloud APIs"
|
||||||
|
echo "without admin credentials (ADR-002 architecture)."
|
||||||
|
echo ""
|
||||||
+46
-1
@@ -96,14 +96,59 @@ services:
|
|||||||
|
|
||||||
keycloak:
|
keycloak:
|
||||||
image: quay.io/keycloak/keycloak:26.4.2
|
image: quay.io/keycloak/keycloak:26.4.2
|
||||||
command: ["start-dev"]
|
command: ["start-dev", "--import-realm"]
|
||||||
ports:
|
ports:
|
||||||
- 127.0.0.1:8888:8080
|
- 127.0.0.1:8888:8080
|
||||||
environment:
|
environment:
|
||||||
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
- KC_BOOTSTRAP_ADMIN_USERNAME=admin
|
||||||
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
- KC_BOOTSTRAP_ADMIN_PASSWORD=admin
|
||||||
|
volumes:
|
||||||
|
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm.json:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "exec 3<>/dev/tcp/localhost/8080 && echo -e 'GET /realms/nextcloud-mcp HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && cat <&3 | grep -q 'HTTP/1.1 200'"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 30
|
||||||
|
|
||||||
|
mcp-keycloak:
|
||||||
|
build: .
|
||||||
|
command: ["--transport", "streamable-http", "--oauth", "--port", "8002"]
|
||||||
|
restart: always
|
||||||
|
depends_on:
|
||||||
|
keycloak:
|
||||||
|
condition: service_healthy
|
||||||
|
app:
|
||||||
|
condition: service_started
|
||||||
|
ports:
|
||||||
|
- 127.0.0.1:8002:8002
|
||||||
|
environment:
|
||||||
|
# OAuth Provider: Keycloak
|
||||||
|
- OAUTH_PROVIDER=keycloak
|
||||||
|
- KEYCLOAK_URL=http://keycloak:8080
|
||||||
|
- KEYCLOAK_REALM=nextcloud-mcp
|
||||||
|
- KEYCLOAK_CLIENT_ID=nextcloud-mcp-server
|
||||||
|
- KEYCLOAK_CLIENT_SECRET=mcp-secret-change-in-production
|
||||||
|
- KEYCLOAK_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
|
||||||
|
|
||||||
|
# Nextcloud API endpoint (for accessing APIs with validated token)
|
||||||
|
- NEXTCLOUD_HOST=http://app:80
|
||||||
|
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
|
||||||
|
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888
|
||||||
|
|
||||||
|
# Refresh token storage (ADR-002 Tier 1)
|
||||||
|
- ENABLE_OFFLINE_ACCESS=true
|
||||||
|
- TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY:-}
|
||||||
|
- TOKEN_STORAGE_DB=/app/data/tokens.db
|
||||||
|
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/keycloak_oauth_client.json
|
||||||
|
|
||||||
|
# NO admin credentials - using Keycloak OAuth only!
|
||||||
|
volumes:
|
||||||
|
- keycloak-tokens:/app/data
|
||||||
|
- keycloak-oauth-storage:/app/.oauth
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
nextcloud:
|
nextcloud:
|
||||||
db:
|
db:
|
||||||
oauth-client-storage:
|
oauth-client-storage:
|
||||||
|
keycloak-tokens:
|
||||||
|
keycloak-oauth-storage:
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
# 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 token
|
||||||
|
- **`preferred_username: "admin"`** - User identifier (Keycloak uses this for password grant; `sub` for authorization_code grant)
|
||||||
|
- **`scope: "openid profile email offline_access"`** - Requested scopes including offline access for background jobs
|
||||||
|
|
||||||
|
**How user_oidc Validates**:
|
||||||
|
1. SelfEncodedValidator checks: `aud == user_oidc.client_id`?
|
||||||
|
- ✓ "nextcloud" == "nextcloud" → PASS
|
||||||
|
2. Fast JWT verification with JWKS (no HTTP call to userinfo endpoint)
|
||||||
|
3. User provisioned based on `preferred_username` or `sub` claim
|
||||||
|
|
||||||
|
**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:
|
||||||
|
|
||||||
|
1. **`nextcloud`** - Resource server client (for user_oidc validation)
|
||||||
|
2. **`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:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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`):
|
||||||
|
|
||||||
|
```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:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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`:
|
||||||
|
|
||||||
|
```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**
|
||||||
|
|
||||||
|
1. Go to Keycloak Admin Console → Realm → Clients → `nextcloud-mcp-server`
|
||||||
|
2. Click "Client scopes" tab
|
||||||
|
3. Click "Add client scope" → "Create dedicated scope"
|
||||||
|
4. Add protocol mapper: "Audience"
|
||||||
|
- Mapper Type: `Audience`
|
||||||
|
- Included Custom Audience: `nextcloud`
|
||||||
|
- Add to access token: ON
|
||||||
|
- Add to ID token: OFF
|
||||||
|
|
||||||
|
### 3. Nextcloud user_oidc - Configure Resource Server Client
|
||||||
|
|
||||||
|
Configure user_oidc to use the `nextcloud` resource server client:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 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
|
||||||
|
|
||||||
|
### Current Approach: Offline Access with Refresh Tokens (Tier 1)
|
||||||
|
|
||||||
|
The MCP server currently uses **offline_access** scope to enable background operations:
|
||||||
|
|
||||||
|
**How it works:**
|
||||||
|
1. User grants `offline_access` scope during OAuth consent
|
||||||
|
2. MCP Client receives refresh token from Keycloak
|
||||||
|
3. MCP Client shares refresh token with MCP Server via MCP protocol
|
||||||
|
4. MCP Server stores refresh token encrypted (see ADR-002)
|
||||||
|
5. 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_access` scope
|
||||||
|
- ✅ 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
|
||||||
|
|
||||||
|
### Future Enhancement: Token Exchange with Delegation (Tier 2)
|
||||||
|
|
||||||
|
**RFC 8693 Delegation** would provide better audit trail and security:
|
||||||
|
|
||||||
|
**How it would work:**
|
||||||
|
1. User grants `may_act:nextcloud-mcp-server` scope during authentication
|
||||||
|
2. Subject token includes: `{ "may_act": { "client": "nextcloud-mcp-server" } }`
|
||||||
|
3. MCP Server has its own service account token (actor_token)
|
||||||
|
4. Background job requests token exchange:
|
||||||
|
- `subject_token` (user's token with may_act claim)
|
||||||
|
- `actor_token` (mcp-server's service token)
|
||||||
|
5. Keycloak validates actor matches may_act claim
|
||||||
|
6. 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_act` claim
|
||||||
|
- ✅ User explicitly consents to MCP Server acting on their behalf
|
||||||
|
- ✅ RFC 8693 compliant
|
||||||
|
|
||||||
|
**Current Status:**
|
||||||
|
- ❌ **NOT implemented in Keycloak yet** ([Issue #38279](https://github.com/keycloak/keycloak/issues/38279))
|
||||||
|
- ❌ Would require custom implementation or waiting for upstream
|
||||||
|
- 📝 Proposal includes `act` claim and `may_act` consent mechanism
|
||||||
|
|
||||||
|
**Why Not Available:**
|
||||||
|
- Keycloak supports **impersonation** (changes `sub` claim), but not **delegation** (`act` claim)
|
||||||
|
- 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
|
||||||
|
|
||||||
|
1. **Intent Validation**: Tokens explicitly declare Nextcloud as the intended recipient via `aud` claim
|
||||||
|
2. **Requester Identification**: The `azp` claim identifies MCP Server as the requester
|
||||||
|
3. **User Context**: The `sub` claim preserves user identity for audit and authorization
|
||||||
|
4. **Background Jobs**: Refresh tokens enable MCP Server to act on behalf of users without admin credentials
|
||||||
|
5. **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 `act` claim yet)
|
||||||
|
- See "Authentication Strategies for Background Jobs" section for future delegation support
|
||||||
|
|
||||||
|
## Token Claims
|
||||||
|
|
||||||
|
### Key Claims
|
||||||
|
|
||||||
|
- **`aud: "nextcloud"`** - Audience: Token intended for Nextcloud APIs
|
||||||
|
- **`azp: "nextcloud-mcp-server"`** - Authorized Party: MCP Server requested the token
|
||||||
|
- **`sub: "user-id"`** - Subject: User on whose behalf the request is made
|
||||||
|
- **`scope: "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**:
|
||||||
|
1. Check token has correct `aud` claim
|
||||||
|
2. Verify MCP server expects correct audience value in code
|
||||||
|
3. Check logs for specific JWT validation error
|
||||||
|
|
||||||
|
### Nextcloud Rejects Token
|
||||||
|
|
||||||
|
**Symptom**: HTTP 401 from Nextcloud API
|
||||||
|
|
||||||
|
**Cause**: User not provisioned or token invalid
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
1. Check user_oidc provider is configured: `php occ user_oidc:provider keycloak`
|
||||||
|
2. Check bearer validation enabled: `--check-bearer=1`
|
||||||
|
3. 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)
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [RFC 8707 - Resource Indicators for OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc8707)
|
||||||
|
- [OIDC Core - ID Token aud claim](https://openid.net/specs/openid-connect-core-1_0.html#IDToken)
|
||||||
|
- [Keycloak Audience Protocol Mappers](https://www.keycloak.org/docs/latest/server_admin/#_audience)
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
{
|
||||||
|
"id": "nextcloud-mcp",
|
||||||
|
"realm": "nextcloud-mcp",
|
||||||
|
"notBefore": 0,
|
||||||
|
"defaultSignatureAlgorithm": "RS256",
|
||||||
|
"revokeRefreshToken": false,
|
||||||
|
"refreshTokenMaxReuse": 0,
|
||||||
|
"accessTokenLifespan": 300,
|
||||||
|
"accessTokenLifespanForImplicitFlow": 900,
|
||||||
|
"ssoSessionIdleTimeout": 1800,
|
||||||
|
"ssoSessionMaxLifespan": 36000,
|
||||||
|
"offlineSessionIdleTimeout": 2592000,
|
||||||
|
"offlineSessionMaxLifespanEnabled": false,
|
||||||
|
"offlineSessionMaxLifespan": 5184000,
|
||||||
|
"accessCodeLifespan": 60,
|
||||||
|
"accessCodeLifespanUserAction": 300,
|
||||||
|
"accessCodeLifespanLogin": 1800,
|
||||||
|
"enabled": true,
|
||||||
|
"sslRequired": "external",
|
||||||
|
"registrationAllowed": false,
|
||||||
|
"loginWithEmailAllowed": true,
|
||||||
|
"duplicateEmailsAllowed": false,
|
||||||
|
"resetPasswordAllowed": false,
|
||||||
|
"editUsernameAllowed": false,
|
||||||
|
"bruteForceProtected": false,
|
||||||
|
"roles": {
|
||||||
|
"realm": [
|
||||||
|
{
|
||||||
|
"name": "offline_access",
|
||||||
|
"description": "${role_offline-access}",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "uma_authorization",
|
||||||
|
"description": "${role_uma_authorization}",
|
||||||
|
"composite": false,
|
||||||
|
"clientRole": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "default-roles-nextcloud-mcp",
|
||||||
|
"description": "${role_default-roles}",
|
||||||
|
"composite": true,
|
||||||
|
"composites": {
|
||||||
|
"realm": ["offline_access", "uma_authorization"]
|
||||||
|
},
|
||||||
|
"clientRole": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"enabled": true,
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"emailVerified": true,
|
||||||
|
"firstName": "Admin",
|
||||||
|
"lastName": "User",
|
||||||
|
"credentials": [
|
||||||
|
{
|
||||||
|
"type": "password",
|
||||||
|
"value": "admin",
|
||||||
|
"temporary": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"realmRoles": ["default-roles-nextcloud-mcp", "offline_access"],
|
||||||
|
"attributes": {
|
||||||
|
"quota": ["1073741824"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clients": [
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud",
|
||||||
|
"name": "Nextcloud Resource Server",
|
||||||
|
"description": "Resource server for Nextcloud APIs - used by user_oidc app for bearer token validation",
|
||||||
|
"enabled": true,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "nextcloud-secret-change-in-production",
|
||||||
|
"redirectUris": [],
|
||||||
|
"webOrigins": [],
|
||||||
|
"bearerOnly": true,
|
||||||
|
"consentRequired": false,
|
||||||
|
"standardFlowEnabled": false,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"publicClient": false,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"nodeReRegistrationTimeout": -1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "nextcloud-mcp-server",
|
||||||
|
"name": "Nextcloud MCP Server",
|
||||||
|
"enabled": true,
|
||||||
|
"clientAuthenticatorType": "client-secret",
|
||||||
|
"secret": "mcp-secret-change-in-production",
|
||||||
|
"redirectUris": [
|
||||||
|
"http://localhost:*",
|
||||||
|
"http://127.0.0.1:*",
|
||||||
|
"http://localhost:*/callback",
|
||||||
|
"http://127.0.0.1:*/callback"
|
||||||
|
],
|
||||||
|
"webOrigins": ["+"],
|
||||||
|
"bearerOnly": false,
|
||||||
|
"consentRequired": false,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"implicitFlowEnabled": false,
|
||||||
|
"directAccessGrantsEnabled": true,
|
||||||
|
"serviceAccountsEnabled": false,
|
||||||
|
"publicClient": false,
|
||||||
|
"frontchannelLogout": false,
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"pkce.code.challenge.method": "S256",
|
||||||
|
"use.refresh.tokens": "true",
|
||||||
|
"backchannel.logout.session.required": "true",
|
||||||
|
"backchannel.logout.url": "http://app:80/index.php/apps/user_oidc/backchannel-logout/keycloak",
|
||||||
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
|
"oidc.ciba.grant.enabled": "false",
|
||||||
|
"client_credentials.use_refresh_token": "false",
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"fullScopeAllowed": true,
|
||||||
|
"nodeReRegistrationTimeout": -1,
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "full name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-full-name-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "email",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "preferred_username",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "username",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "preferred_username",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "quota",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "quota",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "quota",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"defaultClientScopes": ["web-origins", "profile", "roles", "email"],
|
||||||
|
"optionalClientScopes": ["address", "phone", "offline_access", "microprofile-jwt"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clientScopes": [
|
||||||
|
{
|
||||||
|
"name": "offline_access",
|
||||||
|
"description": "OpenID Connect built-in scope: offline_access",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"consent.screen.text": "${offlineAccessScopeConsentText}",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "profile",
|
||||||
|
"description": "OpenID Connect built-in scope: profile",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "full name",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-full-name-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"description": "OpenID Connect built-in scope: email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "true",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "email",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "email",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email",
|
||||||
|
"jsonType.label": "String"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "email verified",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-property-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"userinfo.token.claim": "true",
|
||||||
|
"user.attribute": "emailVerified",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "email_verified",
|
||||||
|
"jsonType.label": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "roles",
|
||||||
|
"description": "OpenID Connect scope for add user roles to the access token",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "false",
|
||||||
|
"display.on.consent.screen": "true"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "realm roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-realm-role-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "foo",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "realm_access.roles",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"multivalued": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "client roles",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-client-role-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "foo",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"claim.name": "resource_access.${client_id}.roles",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"multivalued": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "web-origins",
|
||||||
|
"description": "OpenID Connect scope for add allowed web origins to the access token",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"attributes": {
|
||||||
|
"include.in.token.scope": "false",
|
||||||
|
"display.on.consent.screen": "false"
|
||||||
|
},
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "allowed web origins",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-allowed-origins-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Executable
+90
@@ -0,0 +1,90 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "=== Testing Separate Clients Architecture ==="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check both clients exist in Keycloak
|
||||||
|
echo "1. Verifying Keycloak clients..."
|
||||||
|
docker compose exec -T app curl -s http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration > /dev/null && echo "✓ Keycloak realm available"
|
||||||
|
|
||||||
|
# Check user_oidc provider configuration
|
||||||
|
echo ""
|
||||||
|
echo "2. Checking user_oidc provider..."
|
||||||
|
PROVIDER_INFO=$(docker compose exec -T app php occ user_oidc:provider keycloak)
|
||||||
|
echo "$PROVIDER_INFO" | grep -q "nextcloud" && echo "✓ user_oidc configured with 'nextcloud' client"
|
||||||
|
|
||||||
|
# Get token from nextcloud-mcp-server client
|
||||||
|
echo ""
|
||||||
|
echo "3. Getting token from 'nextcloud-mcp-server' client..."
|
||||||
|
TOKEN=$(curl -s -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" \
|
||||||
|
-d "scope=openid profile email offline_access" | jq -r '.access_token')
|
||||||
|
|
||||||
|
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||||
|
echo "✗ Failed to get token"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✓ Got token from nextcloud-mcp-server client"
|
||||||
|
|
||||||
|
# Check token claims
|
||||||
|
echo ""
|
||||||
|
echo "4. Inspecting token claims..."
|
||||||
|
CLAIMS=$(echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | jq '{aud, azp, iss, preferred_username}')
|
||||||
|
echo "$CLAIMS"
|
||||||
|
|
||||||
|
AUD=$(echo "$CLAIMS" | jq -r '.aud')
|
||||||
|
AZP=$(echo "$CLAIMS" | jq -r '.azp')
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Architecture validation:"
|
||||||
|
if [ "$AUD" = "nextcloud" ]; then
|
||||||
|
echo " ✓ aud='nextcloud' - Token intended for Nextcloud resource server"
|
||||||
|
else
|
||||||
|
echo " ✗ FAILED: aud='$AUD', expected 'nextcloud'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$AZP" = "nextcloud-mcp-server" ]; then
|
||||||
|
echo " ✓ azp='nextcloud-mcp-server' - Token requested by MCP Server client"
|
||||||
|
else
|
||||||
|
echo " ✗ FAILED: azp='$AZP', expected 'nextcloud-mcp-server'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Test with Nextcloud API
|
||||||
|
echo ""
|
||||||
|
echo "5. Testing token with Nextcloud API..."
|
||||||
|
HTTP_CODE=$(curl -s -w "%{http_code}" -o /tmp/nc_response.json \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
"http://localhost:8080/ocs/v2.php/cloud/capabilities?format=json")
|
||||||
|
|
||||||
|
echo "HTTP Status: $HTTP_CODE"
|
||||||
|
|
||||||
|
if [ "$HTTP_CODE" = "200" ]; then
|
||||||
|
echo "✓ Token validated successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "===================================================================="
|
||||||
|
echo "SUCCESS: Separate Clients Architecture Working!"
|
||||||
|
echo "===================================================================="
|
||||||
|
echo ""
|
||||||
|
echo "Summary:"
|
||||||
|
echo " - MCP Server client: 'nextcloud-mcp-server' (requests tokens)"
|
||||||
|
echo " - Resource server: 'nextcloud' (validates tokens via user_oidc)"
|
||||||
|
echo " - Token audience: 'nextcloud' (proper resource targeting)"
|
||||||
|
echo " - Token azp: 'nextcloud-mcp-server' (identifies requester)"
|
||||||
|
echo ""
|
||||||
|
echo "This architecture supports:"
|
||||||
|
echo " - Future multi-resource tokens: aud=['nextcloud', 'other-service']"
|
||||||
|
echo " - Clear separation of OAuth client vs resource server"
|
||||||
|
echo " - RFC 8707 Resource Indicators compliance"
|
||||||
|
else
|
||||||
|
echo "✗ Token validation failed"
|
||||||
|
cat /tmp/nc_response.json
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
Reference in New Issue
Block a user