Skip to content

Commit 849c67c

Browse files
cbcoutinhoclaude
andcommitted
fix: Complete Keycloak external IdP integration with all tests passing
This commit completes the Keycloak external IdP integration for the MCP server, implementing ADR-002 Tier 2 (External Identity Provider) with full Bearer token authentication support. Key Changes: 1. **Keycloak backchannel-dynamic configuration** - Added --hostname-strict=false and --hostname-backchannel-dynamic=true - Allows external issuer (localhost:8888) with internal endpoints (keycloak:8080) - Solves Docker networking issue where containers can't reach localhost 2. **CORSMiddleware Bearer token patch** - Created app-hooks/patches/cors-bearer-token.patch from upstream commit 8fb5e77db82 - Allows Bearer tokens to bypass CORS/CSRF checks (stateless authentication) - Applied via post-installation hook 20-apply-cors-bearer-token-patch.sh - Enables app-specific APIs (Notes, Calendar, etc.) to work with Bearer tokens 3. **Patch organization** - Moved patches to app-hooks/patches/ directory - Updated docker-compose.yml to mount entire app-hooks directory - Consolidated patch management for better maintainability 4. **Test improvements** - All 11 Keycloak integration tests passing - Tests validate OAuth token acquisition, MCP connectivity, token validation, tool execution, token persistence, user provisioning, scope filtering, and error handling Architecture: - Keycloak acts as external OAuth/OIDC identity provider - MCP server uses Keycloak tokens to access Nextcloud APIs - Nextcloud user_oidc app validates Bearer tokens from Keycloak - No admin credentials needed - all API access uses user's OAuth tokens Cache Note: - Discovery and JWKS caches must be cleared when switching Keycloak configurations - Use: docker compose exec redis redis-cli DEL "<cache-key>" - Or: docker compose exec app php occ user_oidc:provider keycloak --clientid nextcloud Related: - ADR-002: Vector sync background jobs authentication - Validates external IdP integration pattern - Demonstrates offline_access with refresh tokens (Tier 1 & 2) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent b3725dd commit 849c67c

14 files changed

+547
-143
lines changed
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
2+
index 4453f5a7d4b..f1ca9b48d21 100644
3+
--- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
4+
+++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
5+
@@ -73,6 +73,13 @@ class CORSMiddleware extends Middleware {
6+
$user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null;
7+
$pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null;
8+
9+
+ // Allow Bearer token authentication for CORS requests
10+
+ // Bearer tokens are stateless and don't require CSRF protection
11+
+ $authorizationHeader = $this->request->getHeader('Authorization');
12+
+ if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
13+
+ return;
14+
+ }
15+
+
16+
// Allow to use the current session if a CSRF token is provided
17+
if ($this->request->passesCSRFCheck()) {
18+
return;

app-hooks/post-installation/10-install-user_oidc-app.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ php /var/www/html/occ config:system:set user_oidc httpclient.allowselfsigned --v
1515
# This enables user_oidc to fetch JWKS from internal Keycloak container
1616
php /var/www/html/occ config:system:set allow_local_remote_servers --value=true --type=boolean
1717

18-
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
18+
patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/patches/0001-Fix-Bearer-token-authentication-causing-session-logo.patch
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/bin/bash
2+
#
3+
# Apply upstream CORSMiddleware Bearer token authentication patch
4+
#
5+
# This patch allows Bearer tokens to bypass CORS/CSRF checks, fixing
6+
# authentication issues with app-specific APIs (Notes, Calendar, etc.)
7+
# when using OAuth/OIDC Bearer tokens.
8+
#
9+
# Upstream PR: https://github.com/nextcloud/server/pull/XXXXX
10+
# Commit: 8fb5e77db82 (fix(cors): Allow Bearer token authentication)
11+
#
12+
13+
set -e
14+
15+
PATCH_FILE="/docker-entrypoint-hooks.d/patches/cors-bearer-token.patch"
16+
TARGET_FILE="/var/www/html/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php"
17+
18+
echo "===================================================================="
19+
echo "Applying CORSMiddleware Bearer token authentication patch..."
20+
echo "===================================================================="
21+
22+
# Check if patch file exists
23+
if [ ! -f "$PATCH_FILE" ]; then
24+
echo "⚠ Warning: Patch file not found: $PATCH_FILE"
25+
echo " Skipping CORS Bearer token patch"
26+
exit 0
27+
fi
28+
29+
# Check if target file exists
30+
if [ ! -f "$TARGET_FILE" ]; then
31+
echo "⚠ Warning: Target file not found: $TARGET_FILE"
32+
echo " Skipping CORS Bearer token patch"
33+
exit 0
34+
fi
35+
36+
# Check if already patched
37+
if grep -q "Allow Bearer token authentication for CORS requests" "$TARGET_FILE"; then
38+
echo "✓ CORSMiddleware already patched for Bearer token support"
39+
exit 0
40+
fi
41+
42+
echo "Applying patch to CORSMiddleware.php..."
43+
44+
# Apply the patch
45+
cd /var/www/html
46+
if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then
47+
patch -p1 < "$PATCH_FILE"
48+
echo "✓ Patch applied successfully"
49+
else
50+
echo "⚠ Warning: Patch failed to apply (may already be applied or file changed)"
51+
echo " This is expected if using a Nextcloud version that already includes the fix"
52+
exit 0
53+
fi
54+
55+
echo ""
56+
echo "===================================================================="
57+
echo "✓ CORSMiddleware Bearer token patch applied"
58+
echo "===================================================================="
59+
echo ""
60+
echo "Benefits:"
61+
echo " • Bearer tokens now work with app-specific APIs (Notes, Calendar, etc.)"
62+
echo " • OAuth/OIDC authentication works without CORS errors"
63+
echo " • Stateless API authentication is properly supported"
64+
echo ""

docker-compose.yml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ services:
3030
- db
3131
volumes:
3232
- nextcloud:/var/www/html
33-
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
33+
- ./app-hooks:/docker-entrypoint-hooks.d:ro
3434
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
3535
# The post-installation hook will register /opt/apps as an additional app directory
36-
- ./third_party/oidc:/opt/apps/oidc:ro
36+
- ./third_party:/opt/apps:ro
3737
environment:
3838
- NEXTCLOUD_TRUSTED_DOMAINS=app
3939
- NEXTCLOUD_ADMIN_USER=admin
@@ -43,11 +43,11 @@ services:
4343
- MYSQL_USER=nextcloud
4444
- MYSQL_HOST=db
4545
- REDIS_HOST=redis
46-
healthcheck:
47-
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
48-
interval: 10s
49-
timeout: 30s
50-
retries: 30
46+
#healthcheck:
47+
#test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
48+
#interval: 10s
49+
#timeout: 30s
50+
#retries: 30
5151

5252
recipes:
5353
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
@@ -115,6 +115,7 @@ services:
115115
- "start-dev"
116116
- "--import-realm"
117117
- "--hostname=http://localhost:8888"
118+
- "--hostname-strict=false"
118119
- "--hostname-backchannel-dynamic=true"
119120
ports:
120121
- 127.0.0.1:8888:8080

env.sample

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,19 @@ NEXTCLOUD_HOST=
88
# - Requires Nextcloud OIDC app installed and configured
99
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
1010
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
11+
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
1112
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
1213
NEXTCLOUD_OIDC_CLIENT_ID=
1314
NEXTCLOUD_OIDC_CLIENT_SECRET=
14-
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
1515
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
1616

17+
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
18+
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
19+
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
20+
#TOKEN_ENCRYPTION_KEY=
21+
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
22+
#TOKEN_STORAGE_DB=/app/data/tokens.db
23+
1724
# Option 2: Basic Authentication (LEGACY - Less Secure)
1825
# - Requires username and password
1926
# - Credentials stored in environment variables

keycloak/realm-export.json

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,10 @@
4545
"description": "${role_default-roles}",
4646
"composite": true,
4747
"composites": {
48-
"realm": ["offline_access", "uma_authorization"]
48+
"realm": [
49+
"offline_access",
50+
"uma_authorization"
51+
]
4952
},
5053
"clientRole": false
5154
}
@@ -66,9 +69,14 @@
6669
"temporary": false
6770
}
6871
],
69-
"realmRoles": ["default-roles-nextcloud-mcp", "offline_access"],
72+
"realmRoles": [
73+
"default-roles-nextcloud-mcp",
74+
"offline_access"
75+
],
7076
"attributes": {
71-
"quota": ["1073741824"]
77+
"quota": [
78+
"1073741824"
79+
]
7280
}
7381
}
7482
],
@@ -108,7 +116,9 @@
108116
"http://localhost:*/callback",
109117
"http://127.0.0.1:*/callback"
110118
],
111-
"webOrigins": ["+"],
119+
"webOrigins": [
120+
"+"
121+
],
112122
"bearerOnly": false,
113123
"consentRequired": false,
114124
"standardFlowEnabled": true,
@@ -212,7 +222,12 @@
212222
}
213223
}
214224
],
215-
"defaultClientScopes": ["web-origins", "profile", "roles", "email"],
225+
"defaultClientScopes": [
226+
"web-origins",
227+
"profile",
228+
"roles",
229+
"email"
230+
],
216231
"optionalClientScopes": [
217232
"address",
218233
"phone",
@@ -268,6 +283,48 @@
268283
"access.token.claim": "true",
269284
"userinfo.token.claim": "true"
270285
}
286+
},
287+
{
288+
"name": "username",
289+
"protocol": "openid-connect",
290+
"protocolMapper": "oidc-usermodel-property-mapper",
291+
"consentRequired": false,
292+
"config": {
293+
"userinfo.token.claim": "true",
294+
"user.attribute": "username",
295+
"id.token.claim": "true",
296+
"access.token.claim": "true",
297+
"claim.name": "preferred_username",
298+
"jsonType.label": "String"
299+
}
300+
},
301+
{
302+
"name": "given name",
303+
"protocol": "openid-connect",
304+
"protocolMapper": "oidc-usermodel-property-mapper",
305+
"consentRequired": false,
306+
"config": {
307+
"userinfo.token.claim": "true",
308+
"user.attribute": "firstName",
309+
"id.token.claim": "true",
310+
"access.token.claim": "true",
311+
"claim.name": "given_name",
312+
"jsonType.label": "String"
313+
}
314+
},
315+
{
316+
"name": "family name",
317+
"protocol": "openid-connect",
318+
"protocolMapper": "oidc-usermodel-property-mapper",
319+
"consentRequired": false,
320+
"config": {
321+
"userinfo.token.claim": "true",
322+
"user.attribute": "lastName",
323+
"id.token.claim": "true",
324+
"access.token.claim": "true",
325+
"claim.name": "family_name",
326+
"jsonType.label": "String"
327+
}
271328
}
272329
]
273330
},
@@ -544,6 +601,101 @@
544601
"display.on.consent.screen": "true",
545602
"consent.screen.text": "Create, update, and delete tasks"
546603
}
604+
},
605+
{
606+
"name": "audience",
607+
"description": "Audience scope for token validation",
608+
"protocol": "openid-connect",
609+
"attributes": {
610+
"include.in.token.scope": "true",
611+
"display.on.consent.screen": "false"
612+
},
613+
"protocolMappers": [
614+
{
615+
"name": "mcp-server-audience",
616+
"protocol": "openid-connect",
617+
"protocolMapper": "oidc-audience-mapper",
618+
"consentRequired": false,
619+
"config": {
620+
"included.client.audience": "nextcloud-mcp-server",
621+
"id.token.claim": "false",
622+
"access.token.claim": "true"
623+
}
624+
},
625+
{
626+
"name": "nextcloud-audience",
627+
"protocol": "openid-connect",
628+
"protocolMapper": "oidc-audience-mapper",
629+
"consentRequired": false,
630+
"config": {
631+
"included.client.audience": "nextcloud",
632+
"id.token.claim": "false",
633+
"access.token.claim": "true"
634+
}
635+
}
636+
]
547637
}
638+
],
639+
"components": {
640+
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
641+
{
642+
"name": "Trusted Hosts",
643+
"providerId": "trusted-hosts",
644+
"subType": "anonymous",
645+
"subComponents": {},
646+
"config": {
647+
"trusted-hosts": [
648+
"localhost",
649+
"127.0.0.1",
650+
"172.19.0.1"
651+
],
652+
"host-sending-registration-request-must-match": [
653+
"false"
654+
],
655+
"client-uris-must-match": [
656+
"true"
657+
]
658+
}
659+
},
660+
{
661+
"name": "Max Clients",
662+
"providerId": "max-clients",
663+
"subType": "anonymous",
664+
"subComponents": {},
665+
"config": {
666+
"max-clients": [
667+
"200"
668+
]
669+
}
670+
}
671+
]
672+
},
673+
"defaultDefaultClientScopes": [
674+
"profile",
675+
"email",
676+
"roles",
677+
"web-origins",
678+
"audience"
679+
],
680+
"defaultOptionalClientScopes": [
681+
"offline_access",
682+
"notes:read",
683+
"notes:write",
684+
"calendar:read",
685+
"calendar:write",
686+
"contacts:read",
687+
"contacts:write",
688+
"cookbook:read",
689+
"cookbook:write",
690+
"deck:read",
691+
"deck:write",
692+
"tables:read",
693+
"tables:write",
694+
"files:read",
695+
"files:write",
696+
"sharing:read",
697+
"sharing:write",
698+
"todo:read",
699+
"todo:write"
548700
]
549701
}

0 commit comments

Comments
 (0)