Compare commits

..

2 Commits

Author SHA1 Message Date
Chris Coutinho 4a3b80cb98 fix: Update DCR token_type tests for OIDC app changes
The Nextcloud OIDC app has updated token_type parameter values:
- Changed from "Bearer" → "opaque" for opaque tokens
- Changed from "JWT" → "jwt" for JWT tokens

Updated test_dcr_token_type.py to use lowercase token_type values:
- token_type="jwt" for JWT-formatted tokens
- token_type="opaque" for opaque/bearer tokens

This fixes test failures where tests were using the old "Bearer" and
"JWT" (uppercase) values which are no longer recognized by the OIDC app.

Fixes test: test_dcr_respects_bearer_token_type

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 22:30:58 +01:00
Chris Coutinho fc3ab8d0ac docs: Add Ollama embeddings capacity analysis and investigation
Documents Ollama embedding service evaluation for ADR-003 semantic search
implementation, including performance benchmarks and capacity analysis.

## Documentation

### Ollama Capacity Analysis
- Performance metrics for ollama.internal.coutinho.io
- Model: nomic-embed-text:latest
- Embedding generation benchmarks (single, batch, parallel)
- Latency analysis and throughput measurements
- Resource usage and capacity recommendations

### Ollama Embeddings Investigation
- Evaluation of Ollama for semantic search use case
- Comparison with other embedding providers
- Integration considerations with ADR-003 architecture
- Deployment scenarios and operational requirements

## Key Findings

 Ollama instance operational and performing well
 Reasonable latency for small-medium workloads
 Good parallelism support
 Suitable for development and small production deployments

## References

- ADR-003: Vector Database Semantic Search
- Ollama API documentation
- nomic-embed-text model specifications

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 03:07:44 +01:00
46 changed files with 2785 additions and 8161 deletions
-138
View File
@@ -1,138 +0,0 @@
# Keycloak OAuth Configuration for Nextcloud MCP Server
#
# This configuration uses Keycloak as the OAuth/OIDC identity provider
# while still accessing Nextcloud APIs. Nextcloud's user_oidc app validates
# Keycloak bearer tokens and provisions users automatically.
#
# Architecture: Client → Keycloak (OAuth) → MCP Server → Nextcloud (user_oidc validates) → APIs
#
# This enables ADR-002 authentication patterns without admin credentials!
# ==============================================================================
# OAUTH PROVIDER SELECTION
# ==============================================================================
# OAuth provider: "keycloak" or "nextcloud" (default)
OAUTH_PROVIDER=keycloak
# ==============================================================================
# KEYCLOAK CONFIGURATION
# ==============================================================================
# Keycloak base URL (accessible from MCP server container)
KEYCLOAK_URL=http://keycloak:8080
# Keycloak realm name
KEYCLOAK_REALM=nextcloud-mcp
# OAuth client credentials (from Keycloak realm export or manual configuration)
KEYCLOAK_CLIENT_ID=nextcloud-mcp-server
KEYCLOAK_CLIENT_SECRET=mcp-secret-change-in-production
# OIDC discovery URL (auto-constructed from URL + realm, or specify explicitly)
KEYCLOAK_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
# ==============================================================================
# NEXTCLOUD CONFIGURATION
# ==============================================================================
# Nextcloud URL (accessible from MCP server container)
# Used for API access - Keycloak tokens are validated by user_oidc app
NEXTCLOUD_HOST=http://app:80
# MCP server URL (for OAuth redirect URIs)
# This is the publicly accessible URL that OAuth clients connect to
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8002
# Public Keycloak issuer URL (accessible from OAuth clients)
# If clients access Keycloak via a different URL than the internal one,
# set this to the public URL for OAuth flows
NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8888
# ==============================================================================
# REFRESH TOKEN STORAGE (ADR-002 Tier 1: Offline Access)
# ==============================================================================
# Enable offline_access scope to get refresh tokens
ENABLE_OFFLINE_ACCESS=true
# Encryption key for storing refresh tokens (generate with instructions below)
# IMPORTANT: Keep this secret! Tokens are encrypted at rest using this key.
#
# Generate a key:
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
#
# Example (DO NOT use this in production!):
# TOKEN_ENCRYPTION_KEY=your-base64-encoded-fernet-key-here
# Path to SQLite database for token storage
TOKEN_STORAGE_DB=/app/data/tokens.db
# ==============================================================================
# DOCKER COMPOSE NOTES
# ==============================================================================
# When running via docker-compose, the mcp-keycloak service is pre-configured
# with these environment variables. See docker-compose.yml for the full config.
#
# Start services:
# docker-compose up -d keycloak app mcp-keycloak
#
# View logs:
# docker-compose logs -f mcp-keycloak
#
# Check Keycloak realm:
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
#
# Check user_oidc provider:
# docker compose exec app php occ user_oidc:provider keycloak
# ==============================================================================
# KEYCLOAK SETUP VERIFICATION
# ==============================================================================
# 1. Verify Keycloak is running and realm is imported:
# curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
#
# 2. Verify Nextcloud user_oidc provider is configured:
# docker compose exec app php occ user_oidc:provider keycloak
#
# 3. Test OAuth flow manually:
# - Get token from Keycloak:
# 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" \
# -d "scope=openid profile email offline_access"
#
# - Use token with Nextcloud API:
# curl -H "Authorization: Bearer <access_token>" \
# http://localhost:8080/ocs/v2.php/cloud/capabilities
#
# 4. Connect MCP client to server:
# - Point your MCP client to http://localhost:8002
# - Complete OAuth flow via Keycloak (credentials: admin/admin)
# - Client should receive access token and be able to call MCP tools
# ==============================================================================
# TROUBLESHOOTING
# ==============================================================================
# If OAuth flow fails:
# - Check that Keycloak is accessible: curl http://localhost:8888
# - Check that user_oidc provider is configured: docker compose exec app php occ user_oidc:provider keycloak
# - Check MCP server logs: docker-compose logs mcp-keycloak
# - Verify redirect URIs match in Keycloak client configuration
#
# If token validation fails:
# - Verify user_oidc has bearer validation enabled (--check-bearer=1)
# - Check Nextcloud logs: docker compose exec app tail -f /var/www/html/data/nextcloud.log
# - Verify Keycloak discovery URL is accessible from Nextcloud container:
# docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
#
# If offline_access/refresh tokens not working:
# - Verify TOKEN_ENCRYPTION_KEY is set and valid
# - Check token storage database: ls -lah /app/data/tokens.db (inside container)
# - Check that offline_access scope is requested in realm configuration
-22
View File
@@ -1,25 +1,3 @@
## v0.23.0 (2025-11-03)
### Feat
- Auto-configure impersonation role in Keycloak realm import
- Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
- Add Keycloak external IdP integration with custom scopes
- Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
- Add Keycloak OAuth provider support with refresh token storage
### Fix
- Complete Keycloak external IdP integration with all tests passing
- Complete Keycloak external IdP integration with all tests passing
- Update DCR token_type tests for OIDC app changes
### Refactor
- Remove NEXTCLOUD_OIDC_CLIENT_STORAGE environment variable
- Remove unnecessary user_oidc patch - CORSMiddleware patch is sufficient
- Unify OAuth configuration to be provider-agnostic
## v0.22.7 (2025-10-29)
### Fix
-131
View File
@@ -395,137 +395,6 @@ uv run pytest -m oauth -v
- Playwright tests run in CI/CD environments
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
#### Keycloak OAuth/OIDC Testing (ADR-002 Integration)
The MCP server supports using **Keycloak as an external OAuth/OIDC identity provider** instead of Nextcloud's built-in OIDC app. This validates the ADR-002 architecture for background jobs and external identity providers.
**Architecture:**
```
MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates token) → APIs
```
**Key Benefits:**
-**No admin credentials needed** - All API access uses user's Keycloak token
-**External identity provider** - Demonstrates integration with enterprise IdPs
-**ADR-002 validation** - Tests offline_access and refresh token patterns
-**User provisioning** - Nextcloud automatically provisions users from Keycloak
**Setup and Testing:**
```bash
# 1. Start Keycloak and MCP server with Keycloak OAuth
docker-compose up -d keycloak app mcp-keycloak
# 2. Verify Keycloak realm is available
curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
# 3. Verify user_oidc provider is configured
docker compose exec app php occ user_oidc:provider keycloak
# 4. Generate encryption key for refresh token storage (optional, for offline access)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Set in environment: export TOKEN_ENCRYPTION_KEY='<key>'
# 5. Test OAuth flow manually
# Get token from Keycloak:
TOKEN=$(curl -s -X POST "http://localhost:8888/realms/nextcloud-mcp/protocol/openid-connect/token" \
-d "grant_type=password" \
-d "client_id=mcp-client" \
-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)
# Use token with Nextcloud API (validated by user_oidc):
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/ocs/v2.php/cloud/capabilities
# 6. Connect MCP client
# Point client to: http://localhost:8002
# Complete OAuth flow using Keycloak credentials: admin/admin
```
**Three MCP Server Containers:**
- **`mcp`** (port 8000): Basic auth with admin credentials
- **`mcp-oauth`** (port 8001): Nextcloud OIDC provider (JWT tokens)
- **`mcp-keycloak`** (port 8002): Keycloak OIDC provider (external IdP)
**Keycloak Configuration:**
- **Realm**: `nextcloud-mcp` (auto-imported from `keycloak/realm-export.json`)
- **Client**: `mcp-client` (pre-configured with PKCE, offline_access)
- **Admin user**: `admin/admin` (created in realm export)
- **Redirect URIs**: `http://localhost:*/callback`, `http://127.0.0.1:*/callback`
**Environment Variables** (Generic OIDC - works with any provider):
```bash
# Generic OIDC configuration (provider-agnostic)
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
OIDC_CLIENT_ID=nextcloud-mcp-server # OAuth client ID
OIDC_CLIENT_SECRET=mcp-secret-... # OAuth client secret
# Nextcloud API configuration
NEXTCLOUD_HOST=http://app:80 # Nextcloud API (token validation in external IdP mode)
# Refresh tokens and token exchange (ADR-002)
ENABLE_OFFLINE_ACCESS=true # Enable refresh tokens
TOKEN_ENCRYPTION_KEY=<fernet-key> # Encrypt refresh tokens
TOKEN_STORAGE_DB=/app/data/tokens.db # Token storage path
# OAuth scopes (optional - uses defaults if not specified)
NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write ...
```
**Provider Mode Detection:**
- **External IdP mode**: If `OIDC_DISCOVERY_URL` issuer ≠ `NEXTCLOUD_HOST` → Uses external provider (Keycloak, Auth0, Okta, etc.)
- **Integrated mode**: If `OIDC_DISCOVERY_URL` not set or issuer = `NEXTCLOUD_HOST` → Uses Nextcloud OIDC app
**Nextcloud user_oidc Configuration:**
The `user_oidc` app is automatically configured by `app-hooks/post-installation/15-setup-keycloak-provider.sh`:
```bash
# Configured with:
--check-bearer=1 # Validate bearer tokens
--bearer-provisioning=1 # Auto-provision users
--unique-uid=1 # Hash user IDs
--scope="openid profile email offline_access"
```
**Troubleshooting:**
```bash
# Check Keycloak is running
docker-compose ps keycloak
docker-compose logs keycloak
# Check user_oidc provider configuration
docker compose exec app php occ user_oidc:provider keycloak
# Check MCP server logs
docker-compose logs -f mcp-keycloak
# Check Nextcloud logs for token validation
docker compose exec app tail -f /var/www/html/data/nextcloud.log
# Verify Keycloak is accessible from Nextcloud container
docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
```
**ADR-002 Offline Access Testing:**
The Keycloak integration enables testing ADR-002's primary authentication pattern (offline access with refresh tokens):
1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`)
2. **Token refresh**: Access tokens refreshed automatically when expired
3. **Background workers**: Can access APIs using stored refresh tokens
4. **No admin credentials**: All operations use user's OAuth tokens
**Note**: Service account tokens (client_credentials grant) were considered but rejected as they create Nextcloud user accounts and violate OAuth "act on-behalf-of" principles. See ADR-002 "Will Not Implement" section.
See `docs/ADR-002-vector-sync-authentication.md` for architectural details.
**Audience Validation:**
Tokens include `aud: ["mcp-server", "nextcloud"]` claims for proper security:
- MCP server validates tokens are intended for it
- Nextcloud validates tokens include it as audience
- Prevents token misuse across services
See `docs/audience-validation-setup.md` for configuration details and `docs/keycloak-multi-client-validation.md` for realm-level validation behavior.
### Configuration Files
- **`pyproject.toml`** - Python project configuration using uv for dependency management
+1 -1
View File
@@ -1,4 +1,4 @@
FROM ghcr.io/astral-sh/uv:0.9.7-python3.11-alpine@sha256:0006b77df7ebf46e68959fdc8d3af9d19f1adfae8c2e7e77907ad257e5d05be4
FROM ghcr.io/astral-sh/uv:0.9.6-python3.11-alpine@sha256:b2a366adae7002a23dbba79791baac4e607ee5af5d45039d072d30115c505666
# Install git (required for caldav dependency from git)
RUN apk add --no-cache git
-18
View File
@@ -1,18 +0,0 @@
diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
index 4453f5a7d4b..f1ca9b48d21 100644
--- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
+++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php
@@ -73,6 +73,13 @@ class CORSMiddleware extends Middleware {
$user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null;
$pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null;
+ // Allow Bearer token authentication for CORS requests
+ // Bearer tokens are stateless and don't require CSRF protection
+ $authorizationHeader = $this->request->getHeader('Authorization');
+ if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
+ return;
+ }
+
// Allow to use the current session if a CSRF token is provided
if ($this->request->passesCSRFCheck()) {
return;
@@ -9,13 +9,5 @@ php /var/www/html/occ app:enable user_oidc
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
php /var/www/html/occ config:system:set user_oidc httpclient.allowselfsigned --value=true --type=boolean
# Allow Nextcloud to connect to local/internal servers (required for external IdP mode)
# This enables user_oidc to fetch JWKS from internal Keycloak container
php /var/www/html/occ config:system:set allow_local_remote_servers --value=true --type=boolean
# Note: The user_oidc app_api session flag patch is NOT required when using the
# CORSMiddleware Bearer token patch (20-apply-cors-bearer-token-patch.sh).
# The CORSMiddleware patch fixes the root cause by allowing Bearer tokens to bypass
# CORS/CSRF checks at the framework level.
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
@@ -1,100 +0,0 @@
#!/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 ""
@@ -1,64 +0,0 @@
#!/bin/bash
#
# Apply upstream CORSMiddleware Bearer token authentication patch
#
# This patch allows Bearer tokens to bypass CORS/CSRF checks, fixing
# authentication issues with app-specific APIs (Notes, Calendar, etc.)
# when using OAuth/OIDC Bearer tokens.
#
# Upstream PR: https://github.com/nextcloud/server/pull/55878
# Commit: 8fb5e77db82 (fix(cors): Allow Bearer token authentication)
#
set -e
PATCH_FILE="/docker-entrypoint-hooks.d/patches/cors-bearer-token.patch"
TARGET_FILE="/var/www/html/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php"
echo "===================================================================="
echo "Applying CORSMiddleware Bearer token authentication patch..."
echo "===================================================================="
# Check if patch file exists
if [ ! -f "$PATCH_FILE" ]; then
echo "⚠ Warning: Patch file not found: $PATCH_FILE"
echo " Skipping CORS Bearer token patch"
exit 0
fi
# Check if target file exists
if [ ! -f "$TARGET_FILE" ]; then
echo "⚠ Warning: Target file not found: $TARGET_FILE"
echo " Skipping CORS Bearer token patch"
exit 0
fi
# Check if already patched
if grep -q "Allow Bearer token authentication for CORS requests" "$TARGET_FILE"; then
echo "✓ CORSMiddleware already patched for Bearer token support"
exit 0
fi
echo "Applying patch to CORSMiddleware.php..."
# Apply the patch
cd /var/www/html
if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then
patch -p1 < "$PATCH_FILE"
echo "✓ Patch applied successfully"
else
echo "⚠ Warning: Patch failed to apply (may already be applied or file changed)"
echo " This is expected if using a Nextcloud version that already includes the fix"
exit 0
fi
echo ""
echo "===================================================================="
echo "✓ CORSMiddleware Bearer token patch applied"
echo "===================================================================="
echo ""
echo "Benefits:"
echo " • Bearer tokens now work with app-specific APIs (Notes, Calendar, etc.)"
echo " • OAuth/OIDC authentication works without CORS errors"
echo " • Stateless API authentication is properly supported"
echo ""
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: nextcloud-mcp-server
description: A Helm chart for Nextcloud MCP Server - enables AI assistants to interact with Nextcloud
type: application
version: 0.23.0
appVersion: "0.23.0"
version: 0.22.7
appVersion: "0.22.7"
keywords:
- nextcloud
- mcp
@@ -78,6 +78,8 @@ spec:
value: {{ include "nextcloud-mcp-server.mcpServerUrl" . | quote }}
- name: NEXTCLOUD_PUBLIC_ISSUER_URL
value: {{ include "nextcloud-mcp-server.publicIssuerUrl" . | quote }}
- name: NEXTCLOUD_OIDC_CLIENT_STORAGE
value: "/app/.oauth/nextcloud_oauth_client.json"
- name: NEXTCLOUD_OIDC_SCOPES
value: {{ .Values.auth.oauth.scopes | quote }}
{{- if .Values.auth.oauth.clientId }}
+5 -82
View File
@@ -30,10 +30,10 @@ services:
- db
volumes:
- nextcloud:/var/www/html
- ./app-hooks:/docker-entrypoint-hooks.d:ro
- ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro
# Mount OIDC development directory outside /var/www/html to avoid rsync conflicts
# The post-installation hook will register /opt/apps as an additional app directory
- ./third_party:/opt/apps:ro
- ./third_party/oidc:/opt/apps/oidc:ro
environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin
@@ -43,11 +43,6 @@ services:
- MYSQL_USER=nextcloud
- MYSQL_HOST=db
- REDIS_HOST=redis
#healthcheck:
#test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
#interval: 10s
#timeout: 30s
#retries: 30
recipes:
image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14
@@ -88,90 +83,18 @@ services:
ports:
- 127.0.0.1:8001:8001
environment:
# Generic OIDC configuration (integrated mode - Nextcloud OIDC app)
# OIDC_DISCOVERY_URL not set - defaults to NEXTCLOUD_HOST/.well-known/openid-configuration
# OIDC_CLIENT_ID not set - uses Dynamic Client Registration (DCR)
- NEXTCLOUD_HOST=http://app:80
- NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001
- NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080
- NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json
- NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
# Refresh token storage (ADR-002 Tier 1)
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# NO admin credentials - using OAuth with Dynamic Client Registration (DCR)
# Client credentials registered via RFC 7591 and stored in volume
# No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration
# Client credentials will be registered and stored in volume on first startup
# JWT token type is used for testing (faster validation, scopes embedded in token)
volumes:
- oauth-client-storage:/app/.oauth
- oauth-tokens:/app/data
keycloak:
image: quay.io/keycloak/keycloak:26.4.2
command:
- "start-dev"
- "--import-realm"
- "--hostname=http://localhost:8888"
- "--hostname-strict=false"
- "--hostname-backchannel-dynamic=true"
- "--features=preview" # Enable Legacy V1 token exchange (supports both Standard V2 and Legacy V1)
ports:
- 127.0.0.1:8888:8080
environment:
- KC_BOOTSTRAP_ADMIN_USERNAME=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:
# Generic OIDC configuration (external IdP mode - Keycloak)
# Provider auto-detected from OIDC_DISCOVERY_URL issuer
# Using internal Docker hostname for discovery to get consistent issuer
- OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
- OIDC_CLIENT_ID=nextcloud-mcp-server
- OIDC_CLIENT_SECRET=mcp-secret-change-in-production
- OIDC_JWKS_URI=http://keycloak:8080/realms/nextcloud-mcp/protocol/openid-connect/certs
# 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/realms/nextcloud-mcp
# Refresh token storage (ADR-002 Tier 1 & 2)
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=ESF1BvEQdGYsCluwMx9Cxvw3uh5pFowPH7Rg_nIliyo=
- TOKEN_STORAGE_DB=/app/data/tokens.db
# OAuth scopes (optional - uses defaults if not specified)
- NEXTCLOUD_OIDC_SCOPES=openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write
# NO admin credentials - using external IdP OAuth only!
volumes:
- keycloak-tokens:/app/data
- keycloak-oauth-storage:/app/.oauth
volumes:
nextcloud:
db:
oauth-client-storage:
oauth-tokens:
keycloak-tokens:
keycloak-oauth-storage:
+244 -408
View File
@@ -1,9 +1,7 @@
# ADR-002: Vector Database Background Sync Authentication
## Status
Accepted - Tier 2 (Token Exchange with Delegation) Implemented
**Important**: Service account tokens (old Tier 1) have been rejected as they violate OAuth "act on-behalf-of" principles by creating Nextcloud user accounts for the MCP server.
Proposed
## Context
@@ -41,92 +39,27 @@ We need an OAuth-native solution that maintains security while enabling backgrou
## Decision
We will implement a **tiered OAuth authentication strategy** for background operations in OAuth mode. When OAuth authentication is not configured or available, the background sync feature is not available.
We will implement a **tiered authentication strategy** that leverages OAuth standards with graceful fallback:
**Note**: This ADR applies only to **OAuth mode**. In BasicAuth mode (single-user deployments), credentials are already available via environment variables, and background operations work without additional configuration.
### Primary Strategy: OAuth-Based Authentication
### OAuth "Act On-Behalf-Of" Principle
**Tier 1: Offline Access with Refresh Tokens** (Preferred)
- Request `offline_access` scope during OAuth client registration
- Receive and securely store user refresh tokens
- Background worker exchanges refresh tokens for access tokens as needed
- Respects per-user permissions and provides full audit trail
**Core Requirement**: The MCP server must NEVER create its own user identity in Nextcloud when operating in OAuth mode.
**Tier 2: Token Exchange (RFC 8693)** (If supported)
- Service account exchanges its token for user-scoped tokens on-demand
- No token storage required
- Only available if OIDC provider implements RFC 8693
**Valid Patterns**:
-**Foreground operations**: Use user's access token from MCP request (currently implemented)
-**Background operations**: Token exchange to impersonate/delegate as user (requires provider support)
-**Service account**: Creates independent identity in Nextcloud (violates OAuth principles)
### Fallback Strategy: Admin Credentials
**Why This Matters**:
1. **Audit Trail**: All operations must be attributable to the actual user, not a service account
2. **Stateless Server**: MCP server should not have persistent identity/state in Nextcloud
3. **Security Model**: Avoid creating "admin by another name" with broad cross-user permissions
4. **OAuth Design**: OAuth tokens represent user authorization, not server authorization
**If Token Exchange Not Available**:
- Background operations simply cannot happen in OAuth mode
- This is correct behavior - not a limitation to work around
- Don't create service accounts as "workaround" - this defeats OAuth's purpose
- Use BasicAuth mode if background operations are critical to your deployment
### Tier 1: Token Exchange with Impersonation (RFC 8693) ⚠️ **NOT IMPLEMENTED**
**Better Security** - Requires provider support for user impersonation
- Service account exchanges token to impersonate specific users
- Each background operation runs as the target user
- Uses `requested_subject` parameter in token exchange
- Per-user permission enforcement at API level
**Requirements**:
- OIDC provider supports RFC 8693 token exchange
- Provider supports user impersonation (rare - requires Legacy Keycloak V1 with preview features)
- Service account has impersonation permissions
**Status**: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation
**Reference**: See `docs/oauth-impersonation-findings.md` for investigation details
### Tier 2: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED**
**Best Security** - Requires provider support for delegation with `act` claim
- Service account exchanges token on behalf of users (delegation, not impersonation)
- Token includes `act` claim showing service account as actor
- API sees both the user (`sub`) and actor (`act`) in token
- Full audit trail of delegated operations
- **Implementation**: `KeycloakOAuthClient.exchange_token_for_user()` (keycloak_oauth.py:397-495)
- **Testing**: Manual test in `tests/manual/test_token_exchange.py`
- **Limitation**: Keycloak doesn't support `act` claim yet - [Issue #38279](https://github.com/keycloak/keycloak/issues/38279)
**Requirements**:
- OIDC provider supports RFC 8693 token exchange
- Provider supports delegation with `act` claim (very rare)
- Proper token exchange permissions configured
**Current Implementation**: Internal-to-internal token exchange with audience modification (without `act` claim)
### ❌ Will Not Implement
**1. Service Account with Independent Identity (client_credentials)**
- **Status**: Previously proposed as Tier 1, now rejected
- **Why Invalid**: Creates Nextcloud user account for MCP server (e.g., `service-account-nextcloud-mcp-server`)
- **Problems**:
- **Violates OAuth "act on-behalf-of" principle**: Actions attributed to service account instead of real user
- **Breaks audit trail**: Can't determine which user initiated the action
- **Creates stateful server identity**: MCP server has persistent identity/data in Nextcloud
- **Security risk**: Service account becomes "admin by another name" with broad cross-user permissions
- **User provisioning side effect**: Nextcloud's `user_oidc` app auto-provisions service account as real user
- **Code Status**: Implementation exists (`KeycloakOAuthClient.get_service_account_token()`) but marked with warnings
- **Alternative**: If service account pattern truly needed, use BasicAuth mode instead of OAuth mode
- **Reference**: See commit c12df98 for detailed analysis of why this approach was rejected
**2. Offline Access with Refresh Tokens**
- **MCP Protocol Architecture**: FastMCP SDK manages OAuth where MCP Client handles refresh tokens
- **Security Model**: Refresh tokens must never be shared between client and server (OAuth best practice)
- **Technical Impossibility**: MCP Server has no access to refresh tokens from the OAuth callback
- **Alternative**: Token exchange provides similar benefits without violating OAuth security model
**3. Admin Credentials Fallback**
- **Out of Scope**: This ADR focuses on OAuth mode only
- **Not Appropriate**: Admin credentials bypass OAuth security model
- **BasicAuth Mode**: For single-user deployments needing background operations, use BasicAuth mode instead
**Tier 3: Admin BasicAuth** (Development/Simple Deployments)
- Dedicated sync account with read-only permissions
- Clear documentation of security implications
- Recommended only for trusted environments
### Key Architectural Principles
@@ -139,160 +72,123 @@ We will implement a **tiered OAuth authentication strategy** for background oper
## Implementation Details
### 1. Token Exchange with Impersonation (Tier 1) ✅ IMPLEMENTED (Legacy V1 only)
**Status**: Implemented and working with Keycloak Legacy V1 (`--features=preview`). Requires additional permission configuration. Recommended for advanced use cases only.
**When to Use**: When you need the exchanged token to have the exact same identity as the target user (sub claim changes). This provides the cleanest separation but requires preview features.
#### 1.1 Impersonation Flow
### 1. Offline Access Flow (Tier 1)
#### 1.1 Client Registration
```python
async def exchange_token_for_user(
subject_token: str,
target_user_id: str,
audience: str | None = None,
scopes: list[str] | None = None,
) -> dict:
"""Exchange service token to impersonate specific user.
Requires Keycloak Legacy V1 (--features=preview) and impersonation permissions.
The returned token will have the target_user_id as the 'sub' claim.
"""
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user_id, # ← KEY: Impersonate this user
}
if audience:
data["audience"] = audience
if scopes:
data["scope"] = " ".join(scopes)
response = await self._http_client.post(
self.token_endpoint,
data=data,
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
return response.json()
```
**Implementation Requirements**:
- ✅ Keycloak Legacy V1 with `--features=preview` flag
- ✅ Impersonation role granted to service account (see configuration below)
- ❌ NOT supported in Keycloak Standard V2 (rejects `requested_subject` parameter)
- ⚠️ Very few OIDC providers support user impersonation via token exchange
**Empirical Testing (2025-11-02)**:
Tested impersonation with `requested_subject` parameter against Keycloak 26.4.2:
**Test Command**: `uv run python tests/manual/test_impersonation.py`
**Keycloak Standard V2 Result**:
```
HTTP/1.1 400 Bad Request
{
"error": "invalid_request",
"error_description": "Parameter 'requested_subject' is not supported for standard token exchange"
# During OAuth client registration
client_metadata = {
"client_name": "Nextcloud MCP Server",
"redirect_uris": ["http://localhost:8000/oauth/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"scope": "openid profile email offline_access notes:read files:read ...",
"token_type": "Bearer" # or "jwt"
}
```
**Confirmation**: Keycloak explicitly rejects `requested_subject` in Standard V2, confirming this feature is unsupported. The error message is unambiguous - this parameter is not available in the current production token exchange implementation.
#### 1.2 Token Storage
```python
# Encrypted token storage
class RefreshTokenStorage:
"""Securely store and manage user refresh tokens"""
**Keycloak Legacy V1 Result - Initial Test** (with `--features=preview`):
```
HTTP/1.1 403 Forbidden
{
"error": "access_denied",
"error_description": "Client not allowed to exchange"
}
def __init__(self, db_path: str, encryption_key: bytes):
self.db = Database(db_path)
self.cipher = Fernet(encryption_key)
Keycloak logs:
reason="subject not allowed to impersonate"
impersonator="service-account-nextcloud-mcp-server"
requested_subject="admin"
async def store_refresh_token(
self,
user_id: str,
refresh_token: str,
expires_at: int | None = None
):
"""Store encrypted refresh token for user"""
encrypted_token = self.cipher.encrypt(refresh_token.encode())
await self.db.execute(
"INSERT OR REPLACE INTO refresh_tokens VALUES (?, ?, ?, ?)",
(user_id, encrypted_token, expires_at, int(time.time()))
)
async def get_refresh_token(self, user_id: str) -> str | None:
"""Retrieve and decrypt refresh token"""
row = await self.db.fetch_one(
"SELECT encrypted_token FROM refresh_tokens WHERE user_id = ?",
(user_id,)
)
if row:
return self.cipher.decrypt(row[0]).decode()
return None
```
**Analysis**: Legacy V1 **accepts** the `requested_subject` parameter (error changed from "not supported" to "not allowed"), indicating the feature is present but requires permission configuration.
#### 1.3 Token Refresh Flow
```python
async def get_user_access_token(user_id: str) -> str:
"""Exchange refresh token for fresh access token"""
**Configuration Steps to Enable Impersonation**:
# Retrieve stored refresh token
refresh_token = await token_storage.get_refresh_token(user_id)
if not refresh_token:
raise ValueError(f"No refresh token for user {user_id}")
1. **Enable Keycloak preview features** (in docker-compose.yml):
```yaml
command:
- "start-dev"
- "--features=preview" # Required for Legacy V1 token exchange
```
# Exchange for access token
async with httpx.AsyncClient() as client:
response = await client.post(
token_endpoint,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token
},
auth=(client_id, client_secret)
)
response.raise_for_status()
token_data = response.json()
2. **Grant impersonation role to service account** (using Keycloak CLI):
```bash
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
--server http://localhost:8080 \
--realm master \
--user admin \
--password admin
# Store new refresh token if rotated
if "refresh_token" in token_data:
await token_storage.store_refresh_token(
user_id,
token_data["refresh_token"],
token_data.get("refresh_expires_in")
)
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \
-r nextcloud-mcp \
--uusername service-account-nextcloud-mcp-server \
--cclientid realm-management \
--rolename impersonation
```
**Keycloak Legacy V1 Result - After Permission Grant**:
```
✅ Token exchange with impersonation SUCCEEDED!
📊 Response details:
Issued token type: urn:ietf:params:oauth:token-type:access_token
Token type: Bearer
Expires in: 300s
📋 Token claims analysis:
Subject (sub): 47c3ba5a-9104-45e0-b84e-0e39ab942c9c (admin user)
Preferred username: admin
Client ID (azp): nextcloud-mcp-server
✅ IMPERSONATION VERIFIED:
Original sub: service-account-nextcloud-mcp-server
New sub: 47c3ba5a-9104-45e0-b84e-0e39ab942c9c
➡️ The subject claim CHANGED - impersonation worked!
return token_data["access_token"]
```
**Nextcloud API Validation**:
The impersonated token successfully authenticated with Nextcloud APIs, confirming the token is valid and properly represents the target user.
#### 1.4 Capturing Refresh Tokens
**Implementation Status**: Impersonation **IS IMPLEMENTED** and working with Keycloak Legacy V1. The implementation has been tested and verified to work correctly when properly configured.
**Challenge**: MCP protocol doesn't expose refresh tokens to server
**Production Considerations**:
- ⚠️ Requires preview features (`--features=preview`) - not production-ready
- ⚠️ Requires Legacy V1 token exchange (may be deprecated in future Keycloak versions)
- ⚠️ Requires manual CLI configuration for each service account
- ⚠️ More complex permission model compared to delegation
**Solution**: Intercept OAuth callback
```python
# Add route to MCP server
@app.route("/oauth/callback")
async def oauth_callback(request):
"""Capture OAuth callback and store refresh token"""
**When to Use Tier 1 (Impersonation)**:
- ✅ You need the exchanged token to have the exact same identity as the target user
- ✅ You want the cleanest separation (sub claim changes completely)
- ✅ Your environment can support preview features
- ✅ You have operational processes to manage impersonation permissions
code = request.query_params.get("code")
state = request.query_params.get("state")
**Recommendation**: For most use cases, use Tier 2 (Delegation) instead. It provides equivalent "act on-behalf-of" capability using production-ready Standard V2 token exchange. Use Tier 1 only when you specifically need identity impersonation.
# Exchange authorization code for tokens
token_response = await exchange_authorization_code(code)
**Test Scripts**:
- `tests/manual/test_impersonation.py` - Complete impersonation test with validation
- `tests/manual/configure_impersonation.py` - Automated permission configuration helper
- **See**: `docs/oauth-impersonation-findings.md` for detailed investigation
# Extract user info
userinfo = await get_userinfo(token_response["access_token"])
user_id = userinfo["sub"]
### 2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED (Standard V2)
# Store refresh token (if present)
if "refresh_token" in token_response:
await token_storage.store_refresh_token(
user_id,
token_response["refresh_token"],
expires_at=token_response.get("refresh_expires_in")
)
logger.info(f"Stored refresh token for user: {user_id}")
**Status**: Implemented and working with Keycloak Standard V2 (production-ready). This is the **recommended** approach for most use cases.
# Continue MCP OAuth flow
return redirect_to_mcp_client(state, token_response)
```
**When to Use**: When you need "act on-behalf-of" functionality with production-ready features. The service account maintains its identity (sub claim unchanged) but acts on behalf of the user. Fully supported in Keycloak Standard V2 without preview features.
### 2. Token Exchange Flow (Tier 2)
#### 2.1 Capability Detection
```python
@@ -308,15 +204,14 @@ async def check_token_exchange_support(discovery_url: str) -> bool:
return "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
```
#### 2.2 Delegation Token Exchange
#### 2.2 Token Exchange Implementation
```python
async def exchange_for_user_token(
service_token: str,
target_user_id: str,
audience: str,
user_id: str,
scopes: list[str]
) -> str:
"""Exchange service token for user-scoped token via RFC 8693"""
"""Exchange service token for user-scoped token"""
async with httpx.AsyncClient() as client:
response = await client.post(
@@ -326,7 +221,7 @@ async def exchange_for_user_token(
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": audience, # Target resource server (e.g., "nextcloud")
"resource": f"user:{user_id}",
"scope": " ".join(scopes)
},
auth=(client_id, client_secret)
@@ -339,40 +234,25 @@ async def exchange_for_user_token(
return response.json()["access_token"]
```
**Implementation**: `KeycloakOAuthClient.exchange_token_for_user()` (keycloak_oauth.py:397-495)
#### 2.3 Service Account Token
```python
async def get_service_token() -> str:
"""Get token for MCP server's service account"""
**Note**: Full delegation with `act` claim requires provider support that is currently very rare. Keycloak tracking: [Issue #38279](https://github.com/keycloak/keycloak/issues/38279)
async with httpx.AsyncClient() as client:
response = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"scope": "notes:read files:read calendar:read"
},
auth=(client_id, client_secret)
)
response.raise_for_status()
return response.json()["access_token"]
```
### 3. Comparison: When to Use Each Tier
| Feature | Tier 1: Impersonation | Tier 2: Delegation (Recommended) |
|---------|----------------------|-----------------------------------|
| **Status** | ✅ Implemented (Legacy V1) | ✅ Implemented (Standard V2) |
| **Token Identity** | Target user (`sub` changes) | Service account (`sub` unchanged) |
| **Keycloak Version** | Legacy V1 (`--features=preview`) | Standard V2 (production-ready) |
| **Setup Complexity** | High (manual permissions) | Low (automatic) |
| **Production Ready** | ⚠️ Preview features required | ✅ Fully production-ready |
| **Permission Grant** | Manual CLI per service account | Automatic via token exchange |
| **Audit Trail** | Shows as target user | Shows as service account acting for user |
| **Token Claims** | `sub: user-id` | `sub: service-account-id` |
| **Provider Support** | Rare (Keycloak Legacy V1 only) | Common (Keycloak, Auth0, Okta) |
| **Use Case** | Need exact user identity | Standard OAuth workflows |
| **Recommendation** | Advanced use only | **Default choice** |
**Decision Guide**:
- ✅ **Use Tier 2 (Delegation)** for:
- Production deployments
- Standard OAuth workflows
- Clear audit trails (service account visible)
- Maximum provider compatibility
- ⚠️ **Use Tier 1 (Impersonation)** only if:
- You specifically need exact user identity (sub claim must match)
- You can accept preview/experimental features
- You have operational processes for permission management
- Your IdP supports `requested_subject` parameter
### 4. Sync Worker with Tiered Authentication
### 3. Sync Worker with Tiered Authentication
```python
# nextcloud_mcp_server/sync_worker.py
@@ -381,72 +261,76 @@ class VectorSyncWorker:
def __init__(self):
self.auth_method = None
self.oauth_client = None # KeycloakOAuthClient or similar
self.token_storage = None
self.vector_service = None
async def initialize(self):
"""Detect and configure authentication method"""
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
# Try Tier 1: Offline Access
if os.getenv("ENABLE_OFFLINE_ACCESS") == "true":
try:
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
self.token_storage = RefreshTokenStorage(
db_path="tokens.db",
encryption_key=base64.b64decode(encryption_key)
)
self.auth_method = "offline_access"
logger.info("✓ Using offline_access authentication")
return
except Exception as e:
logger.warning(f"Offline access unavailable: {e}")
# Try Tier 2: Token Exchange
try:
self.oauth_client = KeycloakOAuthClient.from_env()
await self.oauth_client.discover()
# Verify service account access (Tier 1)
service_token = await self.oauth_client.get_service_account_token()
logger.info("✓ Service account token acquired")
# Check if token exchange is supported (Tier 2/3)
if await check_token_exchange_support(self.oauth_client.discovery_url):
self.auth_method = "token_exchange_delegation"
logger.info(
"✓ Token exchange supported (RFC 8693) - will use delegation for user-scoped operations"
)
else:
self.auth_method = "service_account"
logger.info(
" Token exchange not supported - using service account token for all operations"
)
if await check_token_exchange_support(discovery_url):
self.auth_method = "token_exchange"
logger.info("✓ Using token exchange authentication (RFC 8693)")
return
except Exception as e:
logger.error(f"Failed to initialize OAuth authentication: {e}")
raise RuntimeError(
"OAuth authentication is required for background sync. "
"Either configure OIDC_CLIENT_ID/OIDC_CLIENT_SECRET with service account enabled, "
"or use BasicAuth mode for single-user deployments."
) from e
logger.warning(f"Token exchange unavailable: {e}")
# Fallback: Admin Credentials
if os.getenv("NEXTCLOUD_USERNAME") and os.getenv("NEXTCLOUD_PASSWORD"):
self.auth_method = "admin_basic"
logger.warning(
"⚠ Using admin BasicAuth authentication. "
"Consider enabling offline_access for production."
)
return
raise RuntimeError("No authentication method available for sync worker")
async def get_user_client(self, user_id: str) -> NextcloudClient:
"""Get authenticated client for user based on auth method"""
if self.auth_method == "token_exchange_delegation":
# Tier 2/3: Get service token and exchange for user-scoped token
service_token_data = await self.oauth_client.get_service_account_token()
user_token_data = await self.oauth_client.exchange_token_for_user(
subject_token=service_token_data["access_token"],
target_user_id=user_id,
audience="nextcloud",
scopes=["notes:read", "files:read", "calendar:read"]
)
if self.auth_method == "offline_access":
# Exchange refresh token for access token
access_token = await get_user_access_token(user_id)
return NextcloudClient.from_token(
base_url=nextcloud_host,
token=user_token_data["access_token"],
token=access_token,
username=user_id
)
elif self.auth_method == "service_account":
# Tier 1: Use service account token directly (no user scoping)
service_token_data = await self.oauth_client.get_service_account_token()
elif self.auth_method == "token_exchange":
# Get service token and exchange for user token
service_token = await get_service_token()
user_token = await exchange_for_user_token(
service_token,
user_id,
scopes=["notes:read", "files:read"]
)
return NextcloudClient.from_token(
base_url=nextcloud_host,
token=service_token_data["access_token"],
username="service-account"
token=user_token,
username=user_id
)
elif self.auth_method == "admin_basic":
# Use admin credentials (fallback)
return NextcloudClient.from_env()
raise RuntimeError(f"Unknown auth method: {self.auth_method}")
async def sync_user_content(self, user_id: str):
@@ -486,13 +370,14 @@ class VectorSyncWorker:
while True:
try:
# Get list of users to sync
# Implementation depends on how you track authenticated users
# Options:
# - Audit logs of MCP authentication events
# - MCP session history
# - Configured user list
# - If using service account with broad permissions: list all users
user_ids = await self.get_active_users()
if self.auth_method == "admin_basic":
# Admin can list all users
admin_client = NextcloudClient.from_env()
users = await admin_client.users.list_users()
user_ids = [u.id for u in users]
else:
# OAuth methods: only sync users with stored tokens
user_ids = await self.token_storage.get_all_user_ids()
logger.info(f"Syncing content for {len(user_ids)} users")
@@ -567,59 +452,40 @@ async def nc_notes_semantic_search(
### 5. Security Implementation
#### 5.1 Service Account Credentials Protection
#### 5.1 Token Encryption
```python
# Store OAuth client credentials securely
# Generate encryption key (store securely)
from cryptography.fernet import Fernet
# On first setup
encryption_key = Fernet.generate_key()
# Store in environment or secrets manager
# NEVER commit to source control
# Option 1: Environment variables (for development)
export OIDC_CLIENT_ID="nextcloud-mcp-server"
export OIDC_CLIENT_SECRET="<secure-secret>"
# Option 2: Secrets manager (for production)
import boto3
secrets = boto3.client('secretsmanager')
secret = secrets.get_secret_value(SecretId='nextcloud-mcp-oauth')
client_secret = json.loads(secret['SecretString'])['client_secret']
# Option 3: Encrypted storage (for self-hosted)
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Client credentials are encrypted at rest using Fernet
client_data = await storage.get_oauth_client()
# In production
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY") # Base64-encoded Fernet key
```
#### 5.2 Token Lifecycle Management
#### 5.2 Token Rotation
```python
async def manage_service_token_lifecycle():
"""Cache and refresh service account tokens"""
async def rotate_refresh_token(user_id: str):
"""Handle refresh token rotation"""
# Cache service token (avoid repeated requests)
cached_token = None
token_expires_at = 0
old_refresh_token = await token_storage.get_refresh_token(user_id)
async def get_fresh_service_token() -> str:
nonlocal cached_token, token_expires_at
# Exchange for new tokens
response = await exchange_refresh_token(old_refresh_token)
now = time.time()
if "refresh_token" in response:
# Store new refresh token
await token_storage.store_refresh_token(
user_id,
response["refresh_token"],
expires_at=response.get("refresh_expires_in")
)
# Return cached token if still valid (with 5-minute buffer)
if cached_token and now < (token_expires_at - 300):
return cached_token
# Request new token
token_data = await oauth_client.get_service_account_token()
cached_token = token_data["access_token"]
token_expires_at = now + token_data.get("expires_in", 3600)
logger.info("Service account token refreshed")
return cached_token
return get_fresh_service_token
# Securely delete old token
await token_storage.delete_refresh_token(user_id, old_refresh_token)
```
#### 5.3 Audit Logging
@@ -651,16 +517,17 @@ async def audit_log(
#### 6.1 Environment Variables
```bash
# OAuth Configuration (Required for Background Sync in OAuth Mode)
# Requires external OIDC provider with client_credentials support
OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
OIDC_CLIENT_ID=nextcloud-mcp-server
OIDC_CLIENT_SECRET=<secure-secret>
NEXTCLOUD_HOST=http://app:80
# Tier 1: Offline Access
ENABLE_OFFLINE_ACCESS=true
TOKEN_ENCRYPTION_KEY=<base64-encoded-fernet-key>
TOKEN_STORAGE_DB=/app/data/tokens.db
# Tier selection is automatic:
# - Tier 1 (service_account): Always available if client has service account enabled
# - Tier 2/3 (token_exchange): Used if provider supports RFC 8693 token exchange
# Tier 2: Token Exchange (auto-detected)
# No configuration needed - detected via OIDC discovery
# Tier 3: Admin Fallback
NEXTCLOUD_USERNAME=sync-bot
NEXTCLOUD_PASSWORD=<secure-password>
# Vector Database
QDRANT_URL=http://qdrant:6333
@@ -669,31 +536,9 @@ QDRANT_API_KEY=<api-key>
# Sync Configuration
SYNC_INTERVAL_SECONDS=300
SYNC_BATCH_SIZE=100
# Note: For BasicAuth mode (single-user), background sync uses NEXTCLOUD_USERNAME/NEXTCLOUD_PASSWORD
# This ADR focuses on OAuth mode only
```
#### 6.2 Keycloak Configuration (for Token Exchange)
**Client Settings** (`nextcloud-mcp-server`):
```json
{
"clientId": "nextcloud-mcp-server",
"serviceAccountsEnabled": true,
"authorizationServicesEnabled": false,
"attributes": {
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
}
}
```
**Service Account Roles**:
- Assign appropriate Nextcloud roles/scopes to the service account
- Configure token exchange permissions
#### 6.3 Docker Compose
#### 6.2 Docker Compose
```yaml
services:
mcp-sync:
@@ -701,24 +546,20 @@ services:
command: ["python", "-m", "nextcloud_mcp_server.sync_worker"]
environment:
- NEXTCLOUD_HOST=http://app:80
# External OIDC provider (Keycloak)
- OIDC_DISCOVERY_URL=http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
- OIDC_CLIENT_ID=nextcloud-mcp-server
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
# Vector database
- ENABLE_OFFLINE_ACCESS=true
- TOKEN_ENCRYPTION_KEY=${TOKEN_ENCRYPTION_KEY}
- QDRANT_URL=http://qdrant:6333
- QDRANT_API_KEY=${QDRANT_API_KEY}
# OAuth client credentials (for token refresh)
- NEXTCLOUD_OIDC_CLIENT_ID=${NEXTCLOUD_OIDC_CLIENT_ID}
- NEXTCLOUD_OIDC_CLIENT_SECRET=${NEXTCLOUD_OIDC_CLIENT_SECRET}
volumes:
- sync-data:/app/data # For OAuth client credential storage
- sync-tokens:/app/data
depends_on:
- app
- keycloak
- qdrant
volumes:
sync-data: # Persistent storage for encrypted OAuth client credentials
sync-tokens: # Persistent storage for encrypted tokens
```
## Consequences
@@ -800,37 +641,32 @@ volumes:
#### Security Best Practices
1. **OAuth Client Secret Management**
1. **Token Encryption Key Management**
```bash
# Generate secure key
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Store in secrets manager (Vault, AWS Secrets Manager, etc.)
# Or use environment variable with restricted permissions
# For self-hosted: Use encrypted storage
# OAuth client credentials stored in SQLite with Fernet encryption
# Encryption key: TOKEN_ENCRYPTION_KEY environment variable
# Generate encryption key:
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
2. **Service Account Token Lifecycle**
- Cache service tokens to minimize requests (with expiry buffer)
- Automatically refresh expired tokens
- Use short-lived tokens (provider default, typically 1 hour)
- Monitor token request rates and failures
3. **Database Permissions (for Client Credential Storage)**
2. **Token Storage Permissions**
```bash
# Restrict database file permissions
chmod 600 /app/data/tokens.db
chown mcp-server:mcp-server /app/data/tokens.db
```
3. **Token Rotation Schedule**
- Refresh access tokens every 5 minutes (or token expiry)
- Rotate refresh tokens on each use (if provider supports)
- Revoke tokens on user logout/deauthorization
4. **Monitoring and Alerting**
- Alert on token exchange failures
- Alert on token refresh failures
- Monitor for unusual access patterns
- Track service account token usage
- Audit sync operations per user (if delegation supported)
- Track token age and rotation
- Audit sync operations per user
### Future Enhancements
-521
View File
@@ -1,521 +0,0 @@
# 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
> **Note on Service Account Tokens**: Service account tokens (`client_credentials` grant) 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:**
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
### Token Exchange with Delegation (ADR-002 Tier 2 - Implemented)
**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)
+11 -2
View File
@@ -45,7 +45,8 @@ NEXTCLOUD_HOST=https://your.nextcloud.instance.com
NEXTCLOUD_OIDC_CLIENT_ID=your-client-id
NEXTCLOUD_OIDC_CLIENT_SECRET=your-client-secret
# OAuth Callback Settings (optional)
# OAuth Storage and Callback Settings (optional)
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Leave these EMPTY for OAuth mode
@@ -60,6 +61,7 @@ NEXTCLOUD_PASSWORD=
| `NEXTCLOUD_HOST` | ✅ Yes | - | Full URL of your Nextcloud instance (e.g., `https://cloud.example.com`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Optional | - | OAuth client ID (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Optional | - | OAuth client secret (auto-registers if empty) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Path to store auto-registered client credentials |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty to enable OAuth mode |
@@ -158,6 +160,10 @@ Options:
NEXTCLOUD_OIDC_CLIENT_ID env var)
--oauth-client-secret TEXT OAuth client secret (can also use
NEXTCLOUD_OIDC_CLIENT_SECRET env var)
--oauth-storage-path TEXT Path to store OAuth client credentials
(can also use
NEXTCLOUD_OIDC_CLIENT_STORAGE env var)
[default: .nextcloud_oauth_client.json]
--mcp-server-url TEXT MCP server URL for OAuth callbacks (can
also use NEXTCLOUD_MCP_SERVER_URL env
var) [default: http://localhost:8000]
@@ -219,7 +225,10 @@ uv run nextcloud-mcp-server --no-oauth \
- Store OAuth client credentials securely
- Use environment variables from your deployment platform (Docker secrets, Kubernetes ConfigMaps, etc.)
- Never commit credentials to version control
- SQLite database permissions are handled automatically by the server
- Set appropriate file permissions on credential storage:
```bash
chmod 600 .nextcloud_oauth_client.json
```
### For Docker
+11 -10
View File
@@ -272,7 +272,7 @@ mcp-oauth:
**Key Points:**
- **No credentials needed** - DCR automatically registers the client on first start
- **Credentials persist** - Saved to SQLite database and reused
- **Credentials persist** - Saved to `.nextcloud_oauth_client.json` and reused
- **JWT tokens** - Use `--oauth-token-type jwt` for better performance
- **Token verifier supports both** - Can handle JWT and opaque tokens
- **Pre-configured credentials** - Providing `CLIENT_ID`/`CLIENT_SECRET` skips DCR
@@ -286,6 +286,7 @@ mcp-oauth:
| `NEXTCLOUD_PUBLIC_ISSUER_URL` | Public issuer URL for JWT validation | (uses `NEXTCLOUD_HOST`) |
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured OAuth client ID | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured OAuth client secret | (optional - uses DCR if unset) |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path to persist DCR-registered credentials | `.nextcloud_oauth_client.json` |
| `NEXTCLOUD_OIDC_SCOPES` | Space-separated scopes to request | `"openid profile email mcp:notes:read mcp:notes:write"` |
| `NEXTCLOUD_OIDC_TOKEN_TYPE` | Token format: `"jwt"` or `"Bearer"` | `"Bearer"` |
@@ -302,8 +303,8 @@ When the MCP server starts in OAuth mode, it follows this **three-tier credentia
├─ NEXTCLOUD_OIDC_CLIENT_ID
└─ NEXTCLOUD_OIDC_CLIENT_SECRET
2. SQLite Database (Second Priority)
└─ OAuth client credentials table
2. Storage File (Second Priority)
└─ NEXTCLOUD_OIDC_CLIENT_STORAGE (.nextcloud_oauth_client.json)
3. Dynamic Client Registration (Automatic Fallback)
├─ Discovers registration endpoint from /.well-known/openid-configuration
@@ -326,10 +327,10 @@ export NEXTCLOUD_OIDC_TOKEN_TYPE=jwt # or "Bearer" for opaque tokens
**Credential Storage:**
- Registered credentials are saved to SQLite database
- Database is encrypted and protected by file system permissions
- Registered credentials are saved to `NEXTCLOUD_OIDC_CLIENT_STORAGE` (default: `.nextcloud_oauth_client.json`)
- File has restrictive permissions (0600 - owner read/write only)
- Credentials are reused on subsequent starts (no re-registration needed)
- Stored credentials are checked for expiration (auto-regenerates if expired)
- Storage file is checked for expiration (auto-regenerates if expired)
**Format:**
```json
@@ -385,9 +386,9 @@ export NEXTCLOUD_OIDC_CLIENT_ID="<client_id>"
export NEXTCLOUD_OIDC_CLIENT_SECRET="<client_secret>"
export NEXTCLOUD_OIDC_TOKEN_TYPE="jwt"
# Option 2: SQLite database (second priority)
# Credentials are automatically saved to the database after DCR
# Server will automatically load them on startup
# Option 2: Storage file (second priority)
# Save the JSON response to .nextcloud_oauth_client.json
# Server will automatically load it on startup
```
When credentials are provided via environment variables or storage file, **DCR is skipped**.
@@ -723,7 +724,7 @@ docker compose exec db mariadb -u nextcloud -ppassword nextcloud \
1. Ensure `NEXTCLOUD_OIDC_SCOPES` environment variable is set correctly
2. Check MCP server startup logs for the scopes being requested
3. Verify DCR is enabled in Nextcloud OIDC app settings
4. Clear the SQLite database OAuth client entry and restart to force re-registration
4. Delete `.nextcloud_oauth_client.json` and restart to force re-registration
### Issue: Token Type Case Sensitivity
-298
View File
@@ -1,298 +0,0 @@
# 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`
+5 -4
View File
@@ -39,7 +39,7 @@ Phase 0: MCP Server Startup & Client Registration (DCR - RFC 7591)
│ 0d. Client credentials │
│<────────────────────────────────────┤
│ {client_id, client_secret} │
│ → Saved to SQLite database
│ → Saved to .nextcloud_oauth_*.json
│ │
│ ✓ Server ready for connections │
@@ -211,7 +211,7 @@ Insufficient Scope Example (Step-Up Authorization)
- **PKCE Validation**: Verifies server advertises S256 code challenge method
- **Dynamic Client Registration (DCR)**: Automatically registers OAuth client via `/apps/oidc/register` (RFC 7591)
- Or loads pre-configured client credentials
- Saves credentials to SQLite database
- Saves credentials to `.nextcloud_oauth_client.json`
- **Tool Registration**: Loads all MCP tools with their `@require_scopes` decorators
#### Client Connection Phase
@@ -324,7 +324,7 @@ The OAuth flow consists of four distinct phases (see diagram above for visual re
- MCP server registers itself as OAuth client (RFC 7591)
- Provides: client name, redirect URIs, requested scopes, token type
- Receives: `client_id`, `client_secret`
- Saves credentials to SQLite database
- Saves credentials to `.nextcloud_oauth_client.json`
3. **Tool Registration**
- All MCP tools loaded with their `@require_scopes` decorators
@@ -515,7 +515,7 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
**How it works**:
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
2. Calls `/apps/oidc/register` to register a client on first startup
3. Saves credentials to SQLite database
3. Saves credentials to `.nextcloud_oauth_client.json`
4. Reuses these credentials on subsequent startups
5. Re-registers only if credentials are missing or expired
@@ -718,6 +718,7 @@ See [Configuration Guide](configuration.md) for all OAuth environment variables:
| `NEXTCLOUD_OIDC_CLIENT_ID` | Pre-configured client ID (optional) |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | Pre-configured client secret (optional) |
| `NEXTCLOUD_MCP_SERVER_URL` | MCP server URL for OAuth callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | Path for auto-registered credentials |
## Testing
-387
View File
@@ -1,387 +0,0 @@
# OAuth Impersonation Investigation Findings
**Date**: 2025-11-02
**Last Updated**: 2025-11-02 (Token Exchange Resolution)
**Status**: Implementation Complete - Token Exchange Working
**Conclusion**: Keycloak Standard Token Exchange (RFC 8693) working for internal-to-internal token exchange. User impersonation requires Legacy V1.
---
## ⚠️ IMPORTANT UPDATE (2025-11-02)
**This document contains outdated information regarding service account tokens.**
After implementation and testing, we discovered that service account tokens (`client_credentials` grant) **violate OAuth "act on-behalf-of" principles** by creating Nextcloud user accounts (e.g., `service-account-nextcloud-mcp-server`). This approach has been **REJECTED** and moved to ADR-002's "Will Not Implement" section.
**Key Changes:**
-**Service account tokens (client_credentials) are INVALID** - Creates user accounts, breaks audit trail
-**Token exchange (RFC 8693) is the correct approach** - Implemented and working (ADR-002 Tier 2)
-**Offline access with refresh tokens** - Still valid for background operations (ADR-002 primary approach)
**For current architecture, see**: `docs/ADR-002-vector-sync-authentication.md`
---
## Summary
We investigated options for implementing user impersonation to enable background operations without requiring admin credentials (ADR-002 Tier 2). Here are the findings:
## 1. Keycloak Token Exchange (RFC 8693)
### What We Implemented
- ✅ Service account token acquisition (`client_credentials` grant)
-`get_service_account_token()` method in `KeycloakOAuthClient`
-`exchange_token_for_user()` method implementing RFC 8693
- ✅ Token exchange configuration in Keycloak realm
### What Works ✅
**Keycloak Standard V2 Token Exchange (RFC 8693) is WORKING**:
- ✅ Service account token acquisition via `client_credentials` grant
- ✅ Token exchange for internal-to-internal tokens
- ✅ Audience and scope modifications
- ✅ Integration with Nextcloud APIs using exchanged tokens
**Configuration Requirements**:
To enable Standard Token Exchange in Keycloak 26.2+, add to client attributes in `realm-export.json`:
```json
"attributes": {
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
}
```
### Limitations
Keycloak Standard V2 does NOT support:
- ❌ User impersonation (`requested_subject` parameter)
- ❌ Cross-client delegation (limited to same realm)
These features require Legacy V1 with `--features=preview`
### Alternative: Keycloak Legacy V1
Keycloak Legacy Token Exchange (V1) WOULD support user impersonation, but:
- ❌ Requires `--features=preview --features=token-exchange` flag
- ❌ Not suitable for production
- ❌ Deprecated and being phased out
**Decision**: Not viable for production use.
---
## 2. Nextcloud OIDC App Token Exchange
### Discovery Endpoint Analysis
```json
{
"grant_types_supported": [
"authorization_code",
"implicit"
]
}
```
### Findings
**Nextcloud OIDC app does NOT support**:
- RFC 8693 token exchange
- `client_credentials` grant
- `refresh_token` grant (refresh tokens not issued)
- User impersonation APIs
The Nextcloud OIDC app is a basic OAuth 2.0 provider focused on:
- Authorization code flow for user login
- JWT tokens for API access
- Scope-based authorization
It is NOT designed for:
- Service accounts
- Token delegation
- Background operations
**Decision**: Not viable - missing required grant types.
---
## 3. Nextcloud Impersonate App
### What It Provides
✅ Admin users can impersonate other users via:
- UI: Settings → Users → Impersonate button
- API: `POST /apps/impersonate/user` with `userId` parameter
### How It Works
```php
// From SettingsController.php
public function impersonate(string $userId): JSONResponse {
// 1. Verify admin/delegated admin permissions
// 2. Check target user has logged in before
// 3. Set session: $this->userSession->setUser($impersonatee)
// 4. Return success
}
```
### Requirements
- ✅ Admin credentials
- ✅ Session-based authentication (cookies)
- ✅ CSRF token
- ✅ Target user must have logged in at least once
- ❌ Not compatible with encryption-enabled instances
### Limitations for Background Workers
**Session-based, not stateless**:
- Requires maintaining HTTP session/cookies
- Not suitable for distributed workers
- Can't use with bearer tokens
- Requires re-authentication periodically
**Security concerns**:
- Requires admin credentials stored on server
- All impersonated actions logged as target user
- Violates principle of least privilege
**Decision**: Not suitable for background operations - session-based architecture incompatible with stateless OAuth/bearer token model.
---
## 4. What Actually Works
### Option A: Admin Credentials (Current Implementation)
**BasicAuth mode with admin account**:
```python
client = NextcloudClient.from_env() # Uses NEXTCLOUD_USERNAME/PASSWORD
# Can access all APIs with admin permissions
```
**Pros**:
- Simple, works immediately
- Full access to all APIs
**Cons**:
- Requires admin credentials stored on server
- No per-user permission scoping
- Security risk if credentials leaked
- Violates ADR-002 goals
**Status**: Available but not recommended for production.
### Option B: Service Account with Scoped Permissions
**Create dedicated service account**:
1. Create `mcp-sync` user in Nextcloud
2. Grant specific permissions (group memberships, shares)
3. Use those credentials for background operations
**Pros**:
- Dedicated account, easier to audit
- Can limit permissions via Nextcloud groups
- Works with current BasicAuth implementation
**Cons**:
- Still requires credentials storage
- Can't truly act "as" individual users
- Limited by Nextcloud's permission model
**Status**: Best available option without OAuth delegation.
---
## 5. Recommendations
### Short Term (Immediate)
**Use Service Account Pattern**:
```python
# Background worker configuration
SYNC_ACCOUNT_USERNAME=mcp-sync
SYNC_ACCOUNT_PASSWORD=<secure-password>
# Create service account with limited permissions
docker compose exec app php occ user:add mcp-sync
docker compose exec app php occ group:adduser <appropriate-group> mcp-sync
```
**Benefits**:
- Works with existing implementation
- Better than admin credentials
- Auditable
### Medium Term (If OAuth Delegation Required)
**Wait for proper standards support**:
- Monitor Keycloak for Standard V2 improvements
- Contribute to/request Nextcloud OIDC app enhancements
- Consider alternative identity providers (e.g., Authelia, Authentik)
### Long Term (Ideal Solution)
**Implement proper OAuth delegation**:
1. Use identity provider that supports RFC 8693 properly (e.g., Auth0, Okta)
2. Or implement custom delegation endpoint in Nextcloud
3. Or propose MCP protocol extension for refresh token sharing
---
## 6. Updated ADR-002 Status
| Tier | Solution | Status | Viability |
|------|----------|--------|-----------|
| **Tier 0** | Admin BasicAuth | ✅ Implemented | ⚠️ Works but not recommended |
| **Tier 1** | Offline Access (Refresh Tokens) | ⚠️ Infrastructure ready | ❌ MCP protocol limitation |
| **Tier 2** | Token Exchange (RFC 8693) | ✅ **WORKING** | ✅ **Internal token exchange functional** |
| **Tier 3** | Service Account (NEW) | ✅ Available | ✅ **RECOMMENDED for background ops** |
---
## 7. Implementation Status
### What Was Built
1.`RefreshTokenStorage` - SQLite + encryption (ready for future use)
2.`KeycloakOAuthClient.get_service_account_token()` - Works
3.`KeycloakOAuthClient.exchange_token_for_user()` - Implemented but non-functional
4. ✅ Token exchange configuration - Keycloak realm updated
5. ✅ Test scripts - Comprehensive testing completed
### What to Use
**For Background Operations**:
```python
# Use service account with BasicAuth
from nextcloud_mcp_server.client import NextcloudClient
# In background worker
sync_client = NextcloudClient(
base_url=os.getenv("NEXTCLOUD_HOST"),
username=os.getenv("SYNC_ACCOUNT_USERNAME"),
password=os.getenv("SYNC_ACCOUNT_PASSWORD"),
)
# Perform operations
notes = await sync_client.notes.search_notes("important")
# Index to vector database, etc.
```
**For User Requests**:
```python
# Continue using OAuth bearer tokens
# Per-request client creation as currently implemented
client = get_client_from_context(ctx, nextcloud_host)
```
---
## 8. Files Modified/Created
### Implementation
- `nextcloud_mcp_server/auth/keycloak_oauth.py` - Token exchange methods
- `nextcloud_mcp_server/auth/refresh_token_storage.py` - Token storage (ready for future)
- `nextcloud_mcp_server/app.py` - OAuth configuration updates
- `keycloak/realm-export.json` - Token exchange enabled
- `pyproject.toml` - Added aiosqlite dependency
### Documentation
- `docs/oauth-impersonation-findings.md` - This document
- `docs/ADR-002-vector-sync-authentication.md` - Original architecture decision
### Tests
- `tests/manual/test_token_exchange.py` - Keycloak RFC 8693 testing
- `tests/manual/test_nextcloud_impersonate.py` - Nextcloud impersonate API testing
---
## 9. Conclusion
**Neither Keycloak nor Nextcloud currently provide viable OAuth-based user impersonation for background operations.**
The infrastructure is ready (token storage, exchange methods), but provider limitations prevent use.
**Recommended approach**: Use dedicated service account with appropriate Nextcloud permissions for background operations until proper OAuth delegation becomes available.
The implemented code remains valuable:
- Ready for future when providers add support
- Demonstrates proper OAuth patterns
- Test infrastructure for validation
---
## Appendix: Technical Details
### Keycloak Configuration Applied
```json
{
"clientId": "nextcloud-mcp-server",
"serviceAccountsEnabled": true,
"attributes": {
"token.exchange.grant.enabled": "true"
}
}
```
### Test Results - UPDATED (2025-11-02)
```
✅ Service account token acquisition: WORKS
✅ Token exchange discovery: SUPPORTED
✅ Token exchange configuration: ENABLED
✅ Actual token exchange: WORKS (after adding client.token.exchange.standard.enabled)
✅ Nextcloud API access: WORKS with exchanged tokens
```
**Resolution**: The realm-export.json was missing the `client.token.exchange.standard.enabled` attribute. After adding this attribute to keycloak/realm-export.json:128, token exchange works correctly on fresh Keycloak imports.
### Nextcloud Impersonate Results
```
✓ App installation: SUCCESS
✓ Admin can impersonate: YES (session-based)
✗ Bearer token impersonate: NO (requires session cookies)
✗ Stateless impersonate: NOT AVAILABLE
```
---
## 10. Token Exchange Resolution (2025-11-02)
### Problem
Initial token exchange implementation was failing with:
```
"Standard token exchange is not enabled for the requested client"
```
### Root Cause
The `realm-export.json` was missing a critical attribute for Keycloak 26.2+ Standard Token Exchange:
- Had: `"token.exchange.grant.enabled": "true"`
- Missing: `"client.token.exchange.standard.enabled": "true"`
### Fix Applied
Updated `keycloak/realm-export.json` at line 128 to include both attributes:
```json
"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",
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true" // ADDED
}
```
### Verification
After recreating Keycloak with fresh realm import:
```bash
$ docker compose down -v keycloak && docker compose up -d keycloak
$ uv run python tests/manual/test_token_exchange.py
✅ Token Exchange Test PASSED
```
### Current Status
- ✅ RFC 8693 Token Exchange fully functional
- ✅ Service account token acquisition works
- ✅ Token exchange for internal tokens works
- ✅ Exchanged tokens validate with Nextcloud APIs
- ✅ Realm import automatically applies correct configuration
- ⚠️ User impersonation still requires Keycloak Legacy V1
### Files Modified
- `keycloak/realm-export.json` - Added `client.token.exchange.standard.enabled` attribute
- `docs/oauth-impersonation-findings.md` - Updated with resolution
### Testing
Run the complete token exchange flow:
```bash
uv run python tests/manual/test_token_exchange.py
```
+9 -5
View File
@@ -170,7 +170,7 @@ You have two options for managing OAuth clients:
**How it works**:
- MCP server automatically registers an OAuth client on first startup
- Uses Nextcloud's dynamic client registration endpoint
- Saves credentials to SQLite database
- Saves credentials to `.nextcloud_oauth_client.json`
- Reuses stored credentials on subsequent restarts
- Re-registers automatically if credentials expire
@@ -253,6 +253,9 @@ NEXTCLOUD_PASSWORD=
# Optional: MCP server URL (for OAuth callbacks)
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# Optional: Client storage path
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
EOF
```
@@ -288,6 +291,7 @@ EOF
| `NEXTCLOUD_OIDC_CLIENT_ID` | ⚠️ Mode B only | - | OAuth client ID |
| `NEXTCLOUD_OIDC_CLIENT_SECRET` | ⚠️ Mode B only | - | OAuth client secret |
| `NEXTCLOUD_MCP_SERVER_URL` | ⚠️ Optional | `http://localhost:8000` | MCP server URL for callbacks |
| `NEXTCLOUD_OIDC_CLIENT_STORAGE` | ⚠️ Optional | `.nextcloud_oauth_client.json` | Client credentials storage path |
| `NEXTCLOUD_USERNAME` | ❌ Must be empty | - | Leave empty for OAuth |
| `NEXTCLOUD_PASSWORD` | ❌ Must be empty | - | Leave empty for OAuth |
@@ -330,7 +334,7 @@ INFO OIDC discovery successful
INFO Attempting dynamic client registration...
INFO Dynamic client registration successful
INFO OAuth client ready: <client-id>...
INFO Saved OAuth client credentials to SQLite database
INFO Saved OAuth client credentials to .nextcloud_oauth_client.json
INFO OAuth initialization complete
INFO MCP server ready at http://127.0.0.1:8000
```
@@ -423,9 +427,9 @@ uv run nextcloud-mcp-server --oauth --log-level debug
2. **Secure Credential Storage**
```bash
# Set restrictive permissions on environment file
# Set restrictive permissions
chmod 600 .nextcloud_oauth_client.json
chmod 600 .env
# Database permissions are handled automatically
```
3. **Use HTTPS for MCP Server**
@@ -470,7 +474,7 @@ services:
NEXTCLOUD_OIDC_CLIENT_SECRET: ${NEXTCLOUD_OIDC_CLIENT_SECRET}
NEXTCLOUD_MCP_SERVER_URL: http://your-server:8000
volumes:
- ./data:/app/data # For SQLite database persistence
- ./oauth_client.json:/app/.nextcloud_oauth_client.json
command: ["--oauth", "--transport", "streamable-http"]
restart: unless-stopped
```
+20 -19
View File
@@ -17,7 +17,7 @@ Start here to identify your issue:
| Only seeing Notes tools (7 instead of 90+) | Limited OAuth scopes granted | [Limited Scopes](#limited-scopes---only-seeing-notes-tools) |
| HTTP 401 for Notes API | Bearer token patch missing | [Bearer Token Auth Fails](#bearer-token-authentication-fails) |
| "OIDC discovery failed" | Network or configuration issue | [Discovery Failed](#oidc-discovery-failed) |
| "Database error" on OAuth client storage | Database permissions issue | [Database Permission Error](#database-permission-error) |
| "Permission denied" on .nextcloud_oauth_client.json | File permissions issue | [File Permission Error](#file-permission-error) |
## Configuration Issues
@@ -161,38 +161,39 @@ php occ config:app:set oidc expire_time --value "86400" # 24 hours
---
### Database Permission Error
### File Permission Error
**Error Message**:
```
Permission denied when accessing SQLite database
Database is locked
Permission denied when reading/writing .nextcloud_oauth_client.json
```
**Cause**: The server cannot access the SQLite database file.
**Cause**: The server cannot access the OAuth client storage file.
**Solution**:
```bash
# Check database directory permissions
ls -la /app/data/
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Fix file permissions (owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Ensure directory is writable
chmod 755 /app/data
chmod 755 $(dirname .nextcloud_oauth_client.json)
# Check if database file exists and has correct permissions
ls -la /app/data/tokens.db
chmod 644 /app/data/tokens.db
# If running in Docker, ensure volume is mounted correctly
docker compose logs mcp-oauth | grep -i "database\|sqlite"
# If file doesn't exist, ensure directory is writable
mkdir -p $(dirname .nextcloud_oauth_client.json)
```
**For Docker deployments**:
Ensure the data directory is properly mounted as a volume:
```yaml
volumes:
- ./data:/app/data # Persistent storage for SQLite database
For custom storage paths:
```bash
# Set custom path in .env
NEXTCLOUD_OIDC_CLIENT_STORAGE=/path/to/custom/oauth_client.json
# Ensure directory exists and is writable
mkdir -p $(dirname /path/to/custom/oauth_client.json)
chmod 755 $(dirname /path/to/custom/oauth_client.json)
```
---
+26 -84
View File
@@ -16,79 +16,35 @@ While the core OAuth flow works, there are **pending upstream improvements** tha
**Status**: 🟡 **Patch Required** (Pending Upstream)
**Affected Component**: **Nextcloud core server** (`CORSMiddleware`)
**Affected Component**: `user_oidc` app
**Issue**: Bearer token authentication fails for app-specific APIs (Notes, Calendar, etc.) with `401 Unauthorized` errors, even though OCS APIs work correctly.
**Root Cause**: The `CORSMiddleware` in Nextcloud core server logs out sessions when CSRF tokens are missing. Bearer token authentication creates a session (via `user_oidc` app), but doesn't include CSRF tokens (stateless authentication). The middleware detects the logged-in session without CSRF token and calls `session->logout()`, invalidating the request.
**Root Cause**: The `CORSMiddleware` in Nextcloud logs out sessions created by Bearer token authentication when CSRF tokens are missing, which breaks API requests.
**Solution**: Allow Bearer token requests to bypass CORS/CSRF checks in `CORSMiddleware`, since Bearer tokens are stateless and don't require CSRF protection.
**Solution**: Set the `app_api` session flag during Bearer token authentication to bypass CSRF checks.
**Upstream PR**: [nextcloud/server#55878](https://github.com/nextcloud/server/pull/55878)
**Upstream PR**: [nextcloud/user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221)
**Workaround**: Manually apply the patch to `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in Nextcloud core server
**Workaround**: Manually apply the patch to `lib/User/Backend.php` in the `user_oidc` app
**Impact**:
-**Works**: OCS APIs (`/ocs/v2.php/cloud/capabilities`)
-**Requires Patch**: App APIs (`/apps/notes/api/`, `/apps/calendar/`, etc.)
**Files Modified**: `lib/private/AppFramework/Middleware/Security/CORSMiddleware.php` in **Nextcloud core server**
**Files Modified**: `lib/User/Backend.php` in `user_oidc` app
**Patch Summary**:
```php
// Allow Bearer token authentication for CORS requests
// Bearer tokens are stateless and don't require CSRF protection
$authorizationHeader = $this->request->getHeader('Authorization');
if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) {
return;
}
// Add before successful Bearer token authentication returns
$this->session->set('app_api', true);
```
This is added before the CSRF check at line ~73 in `CORSMiddleware.php`.
This is added at lines ~243, ~310, ~315, and ~337 in `Backend.php`.
---
### 2. JWT Token Support, Introspection, and Scope Validation
**Status**: ✅ **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC app needed support for JWT tokens, token introspection, and enhanced scope validation for fine-grained authorization.
**Resolution**: Complete JWT and scope validation support has been implemented and merged:
**Upstream PR**: [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - ✅ **Merged**
- **Changes**:
- JWT token generation and validation
- Token introspection endpoint (RFC 7662)
- Enhanced scope validation and parsing
- Custom scope support for Nextcloud apps
- **Status**: Merged and available in v1.10.0+ of the `oidc` app
---
### 3. User Consent Management
**Status**: ✅ **Complete** (Merged Upstream)
**Affected Component**: `oidc` app
**Issue**: The OIDC app needed proper user consent management for OAuth authorization flows.
**Resolution**: Complete user consent management has been implemented and merged:
**Upstream PR**: [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - ✅ **Merged**
- **Changes**:
- User consent UI for OAuth authorization
- Consent expiration and cleanup
- Admin control for user consent settings
- Consent tracking and management
- **Status**: Merged and available in v1.11.0+ of the `oidc` app
---
### 4. PKCE Support (RFC 7636)
### 2. PKCE Support (RFC 7636)
**Status**: ✅ **Complete** (Merged Upstream)
@@ -141,34 +97,24 @@ This is added before the CSRF check at line ~73 in `CORSMiddleware.php`.
| PR/Issue | Component | Status | Priority | Notes |
|----------|-----------|--------|----------|-------|
| [server#55878](https://github.com/nextcloud/server/pull/55878) | Nextcloud core server | 🟡 Open | High | CORSMiddleware patch for Bearer tokens |
| [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) | `oidc` | ✅ Merged | Medium | ✅ User consent complete (v1.11.0+) |
| [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) | `oidc` | ✅ Merged | Medium | ✅ JWT tokens, introspection, scope validation (v1.10.0+) |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~High~~ | ✅ PKCE support (RFC 7636) (v1.10.0+) |
| [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) | `user_oidc` | 🟡 Open | High | Required for app-specific APIs |
| [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) | `oidc` | ✅ Merged | ~~Medium~~ | ✅ PKCE advertisement complete (v1.10.0+) |
## What Works Without Patches
The following functionality works **out of the box** without any patches:
**OAuth Flow** (requires `oidc` app v1.10.0+):
- OIDC discovery with full PKCE support (RFC 7636)
**OAuth Flow**:
- OIDC discovery with full PKCE support (requires `oidc` app v1.10.0+)
- Dynamic client registration
- Authorization code flow with PKCE (S256 and plain methods)
- Token exchange with code_verifier verification
- User consent management
- Userinfo endpoint
**Token Features** (requires `oidc` app v1.10.0+):
- JWT token generation and validation
- Token introspection endpoint (RFC 7662)
- Enhanced scope validation and parsing
- Custom scope support for Nextcloud apps
**MCP Server as Resource Server**:
- Token validation via userinfo
- Per-user client instances
- Token caching
- Scope-based authorization
**Nextcloud OCS APIs**:
- Capabilities endpoint
@@ -178,7 +124,7 @@ The following functionality works **out of the box** without any patches:
The following functionality requires upstream patches:
🟡 **App-Specific APIs** (Requires Nextcloud core server CORSMiddleware patch):
🟡 **App-Specific APIs** (Requires user_oidc#1221):
- Notes API (`/apps/notes/api/`)
- Calendar API (CalDAV)
- Contacts API (CardDAV)
@@ -252,23 +198,19 @@ uv run pytest tests/client/test_oauth_playwright.py --browser firefox -v
## Monitoring Upstream Progress
To track progress on remaining issues:
To track progress on these issues:
1. **Watch the upstream repository**:
- [nextcloud/server](https://github.com/nextcloud/server)
1. **Watch the upstream repositories**:
- [nextcloud/user_oidc](https://github.com/nextcloud/user_oidc)
- [nextcloud/oidc](https://github.com/nextcloud/oidc)
2. **Subscribe to the CORSMiddleware PR**:
- [server#55878](https://github.com/nextcloud/server/pull/55878) - CORSMiddleware Bearer token support
2. **Subscribe to specific issues**:
- [user_oidc#1221](https://github.com/nextcloud/user_oidc/issues/1221) - Bearer token support
3. **Check Nextcloud server release notes** for mentions of:
3. **Check Nextcloud release notes** for mentions of:
- Bearer token authentication improvements
- CORS middleware enhancements
- OAuth/OIDC API compatibility
4. **Completed upstream work** (no monitoring needed):
- ✅ [H2CK/oidc#584](https://github.com/H2CK/oidc/pull/584) - PKCE support (v1.10.0+)
- ✅ [H2CK/oidc#585](https://github.com/H2CK/oidc/pull/585) - JWT, introspection, scopes (v1.10.0+)
- ✅ [H2CK/oidc#586](https://github.com/H2CK/oidc/pull/586) - User consent (v1.11.0+)
- OIDC/OAuth enhancements
- AppAPI compatibility
## Contributing
@@ -295,6 +237,6 @@ Want to help get these patches merged?
---
**Last Updated**: 2025-11-02
**Last Updated**: 2025-10-20
**Next Review**: When Nextcloud server CORSMiddleware PR has activity
**Next Review**: When issue #1221 (Bearer token support) has activity
+441
View File
@@ -0,0 +1,441 @@
# Ollama Capacity Analysis: ollama.internal.coutinho.io
**Date**: 2025-10-30
**Model**: nomic-embed-text:latest
**Test Location**: From nextcloud-mcp-server host
## Summary
**Ollama instance is operational and performing well**
- Embedding generation working correctly
- Reasonable latency for small-medium workloads
- Good parallelism support
- Suitable for development and small production deployments
## Test Results
### Model Configuration
```json
{
"model": "nomic-embed-text",
"dimensions": 768,
"status": "operational"
}
```
### Performance Metrics
#### 1. Single Embedding Latency
**Result**: ~553ms per embedding
- **Total time**: 0.553 seconds
- **Includes**: Network + processing + model inference
- **Quality**: Full 768-dimensional vector
**Analysis**:
- Higher than bare-metal benchmarks (~100ms) due to network latency
- Acceptable for interactive search queries
- Within expected range for remote Ollama instance
#### 2. Batch Processing (5 items)
**Result**: ~1.02 seconds for 5 embeddings
- **Per-item average**: 204ms
- **Throughput**: ~4.9 embeddings/sec
- **Batch efficiency**: 2.7x faster than sequential
**Analysis**:
- Good batching efficiency (2.7x speedup vs 5x theoretical)
- Optimal for background indexing
- Network overhead amortized across batch
#### 3. Batch Processing (20 items)
**Result**: ~6.71 seconds for 20 embeddings
- **Per-item average**: 336ms
- **Throughput**: ~3.0 embeddings/sec
- **Batch efficiency**: 1.65x faster than sequential
**Analysis**:
- Performance degrades slightly with larger batches
- Still faster than sequential processing
- Matches reported Ollama behavior (quality issues at batch >16)
- **Recommendation**: Keep batch size ≤16 for best quality
#### 4. Concurrent Requests (5 parallel)
**Result**: ~1.27 seconds for 5 parallel requests
- **Effective parallelism**: ~4x speedup (vs 2.77s sequential)
- **Per-request average**: 254ms
- **Throughput**: ~3.9 requests/sec
**Analysis**:
- Excellent parallelism support
- Server handles concurrent requests efficiently
- Network and compute overlap effectively
- Good for multi-user scenarios
## Capacity Planning
### Current Performance Profile
| Metric | Value | Rating |
|--------|-------|--------|
| Single embedding latency | 553ms | ⚠️ Moderate |
| Batch (5) throughput | 4.9/sec | ✅ Good |
| Batch (20) throughput | 3.0/sec | ⚠️ Moderate |
| Concurrent throughput | 3.9/sec | ✅ Good |
| Network latency | ~300-400ms | ⚠️ Significant |
### Bottleneck Analysis
**Primary Bottleneck**: Network latency (~300-400ms per request)
- Model inference: ~100-200ms (estimated)
- Network round-trip: ~300-400ms (measured overhead)
- **Impact**: 60-70% of total latency is network
**Secondary Bottleneck**: CPU/GPU capacity (unknown hardware)
- Batch performance degrades at >16 items
- Suggests resource constraints
- Likely CPU-only (no GPU metrics available)
### Recommended Usage Patterns
#### ✅ **Excellent For:**
**1. Background Indexing**
- Use batch size of 10-15 items
- Expected throughput: 3-5 embeddings/sec
- **10,000 notes**: ~30-55 minutes to index
- **1,000 notes**: ~3-5 minutes to index
**2. Interactive Search**
- Single query embedding: ~550ms
- Acceptable for user-facing search
- Add 100-200ms for vector search + verification
- **Total search time**: ~650-750ms (reasonable UX)
**3. Multi-User Development**
- 5-10 concurrent users: Comfortable
- Good parallelism support
- Network latency dominates (shared)
#### ⚠️ **Consider Alternatives For:**
**1. Real-Time Applications**
- Sub-100ms latency requirements
- High-frequency queries (>10/sec sustained)
- Consider: Local embeddings or Infinity
**2. Large-Scale Batch Processing**
- >100,000 documents to index
- >10 embeddings/sec sustained
- Consider: GPU-accelerated TEI
**3. Production with >50 Users**
- High concurrent load
- Latency sensitivity
- Consider: Dedicated embedding service
### Deployment Scenarios
#### Scenario 1: Development Environment
**Profile**:
- 1-3 developers
- 1,000-5,000 notes total
- Occasional searches/indexing
**Verdict**: ✅ **Perfect fit**
- Initial index: ~5-15 minutes (one-time)
- Incremental updates: <1 minute
- Search latency: Acceptable
- No infrastructure changes needed
**Configuration**:
```bash
OLLAMA_URL=https://ollama.internal.coutinho.io
OLLAMA_MODEL=nomic-embed-text
VECTOR_SYNC_INTERVAL=600 # 10 minutes
VECTOR_SYNC_BATCH_SIZE=10
```
#### Scenario 2: Small Production (10-20 users)
**Profile**:
- 10-20 active users
- 10,000-50,000 notes total
- 50-200 searches/day
- Nightly incremental indexing
**Verdict**: ✅ **Suitable with optimizations**
- Initial index: 1-3 hours (run overnight)
- Incremental: 5-15 minutes/night
- Search: Acceptable for most users
- Monitor network latency
**Configuration**:
```bash
OLLAMA_URL=https://ollama.internal.coutinho.io
OLLAMA_MODEL=nomic-embed-text
VECTOR_SYNC_INTERVAL=86400 # Daily at night
VECTOR_SYNC_BATCH_SIZE=12 # Conservative for quality
SEARCH_TIMEOUT_MS=1000 # Account for 550ms latency
```
**Optimizations**:
- Run sync during off-hours
- Cache query embeddings (common searches)
- Use hybrid search (keyword + semantic)
#### Scenario 3: Medium Production (50-100 users)
**Profile**:
- 50-100 active users
- 100,000+ notes
- 500-1000 searches/day
- Real-time indexing desired
**Verdict**: ⚠️ **Marginal - monitor closely**
- Initial index: 5-10 hours
- Search latency: May feel slow for some users
- Concurrent load: Approaching limits
- **Recommendation**: Plan migration to Infinity
**Configuration**:
```bash
OLLAMA_URL=https://ollama.internal.coutinho.io
OLLAMA_MODEL=nomic-embed-text
VECTOR_SYNC_INTERVAL=3600 # Hourly
VECTOR_SYNC_BATCH_SIZE=10
SEMANTIC_WEIGHT=0.5 # Rely more on keyword search
SEARCH_TIMEOUT_MS=2000 # Generous timeout
```
**Migration Path**:
- Start with Ollama
- Monitor latency metrics
- When p95 latency >1s, migrate to Infinity
- Keep Ollama as fallback
#### Scenario 4: Large Production (>100 users)
**Profile**:
- >100 active users
- >500,000 notes
- >1000 searches/day
- Real-time expectations
**Verdict**: ❌ **Not recommended**
- Latency too high for scale
- Throughput insufficient
- Network becomes bottleneck
- **Recommendation**: Use Infinity or TEI from start
## Network Latency Optimization
### Current Overhead: ~300-400ms
**If MCP server runs closer to Ollama**:
```
Same VPC/network: ~1-5ms (300-400ms savings!)
Same host: <1ms (300-400ms savings!)
```
### Recommendation
**Option A: Co-locate MCP server with Ollama**
- Reduces latency from 550ms → 150-200ms
- 2.5-3x improvement
- Makes Ollama competitive with cloud APIs
**Option B: Keep separate (current)**
- Simpler deployment
- Better security isolation
- Accept 550ms latency
**Option C: Add Infinity container to MCP server**
- Best of both worlds
- Use Infinity for speed (local)
- Fallback to Ollama if needed
## Capacity Estimates
### Indexing Capacity
**Sustained Throughput**: 3-4 embeddings/sec (conservative)
| Document Count | Index Time | Notes |
|----------------|------------|-------|
| 1,000 | 4-5 min | Quick |
| 5,000 | 20-25 min | Reasonable |
| 10,000 | 40-50 min | Acceptable |
| 50,000 | 3.5-4.5 hours | Overnight job |
| 100,000 | 7-9 hours | Long batch |
| 500,000 | 35-45 hours | Not recommended |
**Incremental Updates** (10% change daily):
- 1,000 docs: ~30 sec
- 10,000 docs: ~5 min
- 50,000 docs: ~25 min
### Search Capacity
**Query Latency Budget**:
- Embedding: 550ms
- Vector search: 50-100ms
- Permission verification: 50-100ms
- **Total**: 650-750ms
**Concurrent Users** (assuming 1 search every 5 minutes):
- 10 users: 2 queries/min → Comfortable
- 50 users: 10 queries/min → Near limit
- 100 users: 20 queries/min → Over capacity
**Peak Load** (all users search at once):
- Parallelism: ~4 concurrent
- Queue time: Proportional to position
- 10 simultaneous: ~1.5-2 sec for last user
- 50 simultaneous: ~7-10 sec for last user
## Recommendations
### Immediate Actions (Development)
1. **✅ Use Ollama as-is**
- Current setup is perfect for dev/testing
- No changes needed
- Start building semantic search
2. **Configuration**:
```bash
OLLAMA_URL=https://ollama.internal.coutinho.io
OLLAMA_MODEL=nomic-embed-text
VECTOR_SYNC_BATCH_SIZE=10
```
3. **Add Monitoring**:
```python
# Track these metrics
- embedding_latency_seconds (histogram)
- embedding_batch_size (gauge)
- embedding_errors_total (counter)
```
### Short-Term (Small Production)
1. **Optimize Batching**:
- Use batch size 10-12 (quality sweet spot)
- Process during off-hours
- Implement incremental sync
2. **Add Caching**:
```python
# Cache common query embeddings
@lru_cache(maxsize=1000)
async def embed_with_cache(query: str):
return await ollama.embed(query)
```
3. **Monitor Metrics**:
- P50, P95, P99 latency
- Throughput (embeddings/sec)
- Error rates
### Medium-Term (If Scaling Up)
1. **Add Infinity Container** (when >50 users or latency issues):
```yaml
services:
infinity:
image: michaelf34/infinity:latest
# Local to MCP server - ~10-20ms latency
```
2. **Implement Tiered Fallback**:
```
Infinity (local, fast) → Ollama (remote, slower) → Local model
```
3. **Load Testing**:
- Simulate 50-100 concurrent users
- Measure actual throughput limits
- Identify breaking points
### Long-Term (Enterprise Scale)
1. **Migrate to TEI Cluster** (when >100 users):
- GPU-accelerated
- Horizontal scaling
- <20ms latency
2. **Consider Managed Services**:
- Pinecone, Qdrant Cloud
- Removes operational burden
- Better SLAs
## Testing Recommendations
### Load Testing Script
```bash
# Test sustained load
for i in {1..100}; do
curl -s https://ollama.internal.coutinho.io/api/embed \
-d "{\"model\": \"nomic-embed-text\", \"input\": \"Test $i\"}" &
# Rate limit: 5 concurrent
if [ $(($i % 5)) -eq 0 ]; then
wait
sleep 1
fi
done
```
### Metrics to Collect
1. **Latency Distribution**:
- P50 (median)
- P95 (acceptable)
- P99 (outliers)
2. **Throughput**:
- Embeddings/second
- Peak vs sustained
3. **Error Rates**:
- Timeouts
- Server errors
- Quality issues
## Conclusion
**Your Ollama instance is ready for development and small production use!**
**Current Capacity**:
- ✅ Development: Unlimited
- ✅ Small prod (10-20 users, 10k docs): Comfortable
- ⚠️ Medium prod (50 users, 50k docs): Monitoring needed
- ❌ Large prod (>100 users): Migrate to Infinity/TEI
**Key Strengths**:
- Fully operational
- Good parallelism
- Acceptable latency for most use cases
- Easy to integrate
**Key Limitations**:
- Network latency adds 300-400ms overhead
- Batch quality issues at >16 items
- Limited scalability beyond 50 users
**Recommendation**:
Start using Ollama immediately for development. Add monitoring and plan for Infinity when you approach 50 users or experience latency issues. The abstraction layer in ADR-003 makes migration seamless.
**Next Steps**:
1. Configure MCP server with Ollama URL
2. Implement semantic search tools
3. Add basic monitoring
4. Test with real workload
5. Scale up as needed
+796
View File
@@ -0,0 +1,796 @@
# Ollama Embeddings Investigation
**Date**: 2025-10-30
**Status**: Recommendation for Integration
## Executive Summary
Ollama provides a **local, self-hosted embedding solution** that is excellent for **development and small-scale deployments** but has **performance limitations** compared to specialized embedding inference engines (TEI, Infinity).
**Recommendation**: Include Ollama as **Tier 2 fallback** in our embedding strategy (after cloud APIs, before local sentence-transformers), prioritizing ease of setup over maximum performance.
## Overview
Ollama is primarily known as a local LLM runner but added embedding model support in version 0.1.26, making it a convenient option for generating vector embeddings without external API dependencies.
### Key Characteristics
- **Local & Self-Hosted**: No external API calls, full privacy
- **Easy Setup**: Single binary, simple model downloads (`ollama pull nomic-embed-text`)
- **Unified Platform**: Same tool for both LLMs and embeddings
- **OpenAI Compatible**: `/v1/embeddings` endpoint for drop-in replacement
- **Multi-Platform**: Linux, macOS, Windows support
- **GPU Support**: CUDA, ROCm, Metal acceleration
## API Details
### Endpoint Structure
**New API** (recommended):
```bash
POST http://localhost:11434/api/embed
```
**OpenAI Compatible**:
```bash
POST http://localhost:11434/v1/embeddings
```
**Legacy API** (deprecated):
```bash
POST http://localhost:11434/api/embeddings
```
### Request Format
**Single Text Embedding**:
```json
{
"model": "nomic-embed-text",
"input": "Text to embed"
}
```
**Batch Embedding** (since v0.2.0):
```json
{
"model": "nomic-embed-text",
"input": [
"First text to embed",
"Second text to embed",
"Third text to embed"
]
}
```
### Response Format
```json
{
"model": "nomic-embed-text",
"embeddings": [
[0.123, -0.456, 0.789, ...], // 768 dimensions for nomic-embed-text
[0.234, -0.567, 0.890, ...]
]
}
```
### Python Integration
```python
import ollama
# Single embedding
response = ollama.embed(
model='nomic-embed-text',
input='Text to embed'
)
embedding = response['embeddings'][0]
# Batch embeddings (more efficient)
response = ollama.embed(
model='nomic-embed-text',
input=[
'First text',
'Second text',
'Third text'
]
)
embeddings = response['embeddings']
```
## Available Models
### 1. nomic-embed-text (Recommended)
**Specifications**:
- **Parameters**: 137M
- **Dimensions**: 768
- **Context Length**: 8,192 tokens (2K effective)
- **Size**: 274MB
- **Architecture**: BERT-based
**Performance**:
- Outperforms OpenAI `text-embedding-ada-002` and `text-embedding-3-small`
- Excellent for long-context tasks
- Strong general-purpose performance
**Use Cases**:
- General RAG applications
- Long document processing
- Semantic search
- Document clustering
**Pull Command**:
```bash
ollama pull nomic-embed-text
```
### 2. mxbai-embed-large
**Specifications**:
- **Parameters**: 334M
- **Dimensions**: 1,024
- **Context Length**: 512 tokens
- **Architecture**: BERT-large optimized
**Performance**:
- Claims to outperform commercial models
- Higher precision for complex queries
- Best quality but slower
**Use Cases**:
- High-precision semantic search
- Enterprise knowledge bases
- Multilingual content
**Pull Command**:
```bash
ollama pull mxbai-embed-large
```
### 3. all-minilm
**Specifications**:
- **Parameters**: 23M
- **Dimensions**: 384
- **Context Length**: 256 tokens
- **Size**: Smallest footprint
**Performance**:
- Fastest processing speed
- Good for sentence-level tasks
- Limited context window
**Use Cases**:
- Real-time applications
- Resource-constrained environments
- High-throughput scenarios
- Development/testing
**Pull Command**:
```bash
ollama pull all-minilm
```
## Performance Benchmarks
### Throughput Comparison
| Hardware | Model | Batch Size | Throughput | Notes |
|----------|-------|------------|------------|-------|
| RTX 4090 (24GB) | nomic-embed-text | 256 | 12,450 tok/sec | GPU-accelerated |
| RTX 4090 (24GB) | mxbai-embed-large | 128 | 8,920 tok/sec | GPU-accelerated |
| Intel i9-13900K (CPU) | nomic-embed-text | 32 | 3,250 tok/sec | CPU-only |
| Intel i9-13900K (CPU) | mxbai-embed-large | 16 | 2,180 tok/sec | CPU-only |
### Latency Comparison
**Single Request Latency** (RTX 4060):
- Ollama: ~99ms
- TEI: ~20ms (5x faster)
- Infinity: ~30-40ms (2.5-3x faster)
**Batch Processing**:
- Optimal batch size: 32-64 (model dependent)
- Performance degrades with batches >16 (quality issues reported)
- 2x slower than direct sentence-transformers usage
### Engine Comparison
Based on benchmarks from Baseten (2024):
| Engine | Relative Throughput | Notes |
|--------|---------------------|-------|
| BEI | 9.0x (baseline) | Fastest (proprietary) |
| TEI | 4.5x | Open source, Rust-based |
| Infinity | 3.5x | PyTorch/ONNX optimized |
| vLLM | 3.0x | General LLM inference |
| **Ollama** | **1.0x** | Slowest for embeddings |
**Key Insight**: Ollama is **5-9x slower** than specialized embedding engines but trades performance for ease of use and unified platform.
## Integration Implementation
### Python Client Wrapper
```python
# nextcloud_mcp_server/embeddings/ollama.py
import httpx
from typing import List
class OllamaEmbedding:
"""Ollama embedding provider"""
def __init__(
self,
base_url: str = "http://localhost:11434",
model: str = "nomic-embed-text"
):
self.base_url = base_url.rstrip("/")
self.model = model
self.client = httpx.AsyncClient(timeout=60.0)
# Model dimension mapping
self.dimensions = {
"nomic-embed-text": 768,
"mxbai-embed-large": 1024,
"all-minilm": 384
}
self.dimension = self.dimensions.get(model, 768)
async def embed(self, text: str) -> List[float]:
"""Generate embedding for single text"""
response = await self.client.post(
f"{self.base_url}/api/embed",
json={
"model": self.model,
"input": text
}
)
response.raise_for_status()
data = response.json()
return data["embeddings"][0]
async def embed_batch(
self,
texts: List[str],
batch_size: int = 32
) -> List[List[float]]:
"""
Generate embeddings for multiple texts in batches.
Note: Ollama has reported quality issues with batch sizes >16.
We use batch_size=32 as default but allow configuration.
"""
all_embeddings = []
# Process in chunks to avoid batch size issues
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
response = await self.client.post(
f"{self.base_url}/api/embed",
json={
"model": self.model,
"input": batch
}
)
response.raise_for_status()
data = response.json()
all_embeddings.extend(data["embeddings"])
return all_embeddings
async def check_health(self) -> bool:
"""Check if Ollama server is running and model is available"""
try:
# Check if server is up
response = await self.client.get(f"{self.base_url}/api/tags")
response.raise_for_status()
# Check if model is pulled
models = response.json().get("models", [])
model_names = [m["name"] for m in models]
if self.model not in model_names:
raise ValueError(
f"Model '{self.model}' not found. "
f"Run: ollama pull {self.model}"
)
return True
except Exception as e:
raise ConnectionError(f"Ollama health check failed: {e}")
async def close(self):
"""Close HTTP client"""
await self.client.aclose()
```
### Auto-Detection in Embedding Service
```python
# nextcloud_mcp_server/embeddings/service.py
from typing import Optional
import os
import logging
logger = logging.getLogger(__name__)
class EmbeddingService:
"""Unified embedding service with automatic provider detection"""
def __init__(self):
self.provider = None
self._detect_provider()
def _detect_provider(self):
"""Auto-detect available embedding provider"""
# Tier 1: OpenAI API (best quality)
if os.getenv("OPENAI_API_KEY"):
from .openai import OpenAIEmbedding
self.provider = OpenAIEmbedding(
model=os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
api_key=os.getenv("OPENAI_API_KEY")
)
logger.info("✓ Using OpenAI embeddings")
return
# Tier 2a: Infinity (optimized self-hosted)
if os.getenv("INFINITY_URL"):
from .infinity import InfinityEmbedding
try:
self.provider = InfinityEmbedding(
url=os.getenv("INFINITY_URL"),
model=os.getenv("EMBEDDING_MODEL", "BAAI/bge-small-en-v1.5")
)
logger.info("✓ Using Infinity embeddings (optimized)")
return
except Exception as e:
logger.warning(f"Infinity unavailable: {e}")
# Tier 2b: Ollama (easy self-hosted)
if os.getenv("OLLAMA_URL"):
from .ollama import OllamaEmbedding
try:
self.provider = OllamaEmbedding(
base_url=os.getenv("OLLAMA_URL", "http://localhost:11434"),
model=os.getenv("OLLAMA_MODEL", "nomic-embed-text")
)
# Verify Ollama is running and model is available
import asyncio
asyncio.run(self.provider.check_health())
logger.info("✓ Using Ollama embeddings (easy setup)")
return
except Exception as e:
logger.warning(f"Ollama unavailable: {e}")
# Tier 3: Local model (fallback)
logger.warning("No cloud/hosted embeddings available, using local model")
from .local import LocalEmbedding
self.provider = LocalEmbedding(
model=os.getenv("LOCAL_EMBEDDING_MODEL", "all-MiniLM-L6-v2")
)
logger.info("✓ Using local embeddings (CPU fallback)")
async def embed(self, text: str):
"""Generate embedding for text"""
return await self.provider.embed(text)
async def embed_batch(self, texts: list[str]):
"""Generate embeddings for multiple texts"""
return await self.provider.embed_batch(texts)
@property
def dimension(self) -> int:
"""Get embedding dimension"""
return self.provider.dimension
```
### Docker Compose Configuration
```yaml
services:
# Ollama embedding service
ollama:
image: ollama/ollama:latest
restart: always
ports:
- 127.0.0.1:11434:11434
volumes:
- ollama_models:/root/.ollama
# Optional: GPU support
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
# Pull models on startup
entrypoint: ["/bin/sh", "-c"]
command:
- |
ollama serve &
sleep 5
ollama pull nomic-embed-text
wait
# MCP Server with Ollama embeddings
mcp:
build: .
depends_on:
- ollama
environment:
# ... other vars ...
- OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=nomic-embed-text
# Vector sync worker
mcp-vector-sync:
build: .
command: ["python", "-m", "nextcloud_mcp_server.sync.vector_indexer"]
depends_on:
- ollama
- qdrant
environment:
# ... other vars ...
- OLLAMA_URL=http://ollama:11434
- OLLAMA_MODEL=nomic-embed-text
volumes:
ollama_models:
```
## Advantages of Ollama
### 1. **Ease of Setup**
```bash
# Install Ollama
curl -fsSL https://ollama.com/install.sh | sh
# Pull embedding model
ollama pull nomic-embed-text
# Done! API available at localhost:11434
```
No complex configuration, no Docker registries, no model conversion.
### 2. **Privacy & Data Sovereignty**
- All processing happens locally
- No data leaves your infrastructure
- No API keys or external dependencies
- Ideal for sensitive content (medical, legal, financial)
### 3. **Unified Platform**
- Same tool for LLMs and embeddings
- Consistent API across model types
- Single point of management
- Simplified operations
### 4. **Developer Experience**
- Simple API (similar to OpenAI)
- Good documentation
- Active community
- Framework integrations (LangChain, LlamaIndex)
### 5. **Cost**
- Free and open source
- No per-token API costs
- Only infrastructure costs (compute)
### 6. **Model Variety**
Growing library of embedding models:
- nomic-embed-text (general purpose)
- mxbai-embed-large (high quality)
- all-minilm (fast)
- More models added regularly
## Limitations of Ollama
### 1. **Performance**
- **5-9x slower** than specialized engines (TEI, Infinity)
- Not optimized specifically for embedding inference
- Batch processing issues at larger batch sizes (>16)
- Higher latency compared to alternatives
### 2. **Scalability**
- Single-instance deployment (no native clustering)
- Limited concurrent request handling
- Not designed for high-throughput production
- Resource usage per request is higher
### 3. **Batch Processing Issues**
- Quality degradation reported with large batches
- Optimal batch size: 32-64 (conservative)
- Less efficient than specialized engines
- GitHub issues tracking batch problems (#6262)
### 4. **Resource Usage**
- Models stay loaded in memory (VRAM/RAM)
- Higher memory footprint per model
- GPU context switching overhead
- Not as memory-efficient as specialized engines
### 5. **Production Features**
- No built-in load balancing
- Limited monitoring/metrics
- No automatic scaling
- Basic error handling
## Use Case Recommendations
### ✅ **Excellent For:**
1. **Development & Testing**
- Quick setup for prototyping
- Local development environments
- Testing embedding pipelines
2. **Small Deployments**
- <10 users
- <10,000 documents
- Infrequent searches (<100/day)
- Hobbyist/personal projects
3. **Privacy-Critical Applications**
- Medical/healthcare records
- Legal documents
- Financial data
- Air-gapped environments
4. **Unified LLM Stack**
- Projects already using Ollama for LLMs
- Simplified operations
- Consistent tooling
5. **Educational/Learning**
- Teaching RAG concepts
- Learning embeddings
- Hackathons/workshops
### ⚠️ **Consider Alternatives For:**
1. **Production at Scale**
- >100 users
- >100,000 documents
- High query volume (>1000/day)
- Use: TEI or Infinity
2. **Performance-Critical**
- Real-time search (<50ms latency)
- High-throughput batch processing
- Use: TEI with GPU
3. **Enterprise Deployments**
- Need for high availability
- Load balancing requirements
- Advanced monitoring
- Use: Managed services or TEI cluster
4. **Large-Scale Indexing**
- Millions of documents
- Continuous high-volume ingestion
- Use: Infinity or commercial solutions
## Integration Strategy
### Recommended Tier Placement
**Update ADR-003 embedding strategy:**
```
Tier 1: OpenAI API (best quality, requires API key)
↓ fallback
Tier 2a: Infinity (optimized self-hosted, complex setup)
↓ fallback
Tier 2b: Ollama (easy self-hosted, moderate performance) ← NEW
↓ fallback
Tier 3: Local sentence-transformers (CPU fallback, simplest)
```
### Configuration
```bash
# Option 1: Use Infinity (if available)
INFINITY_URL=http://infinity:7997
EMBEDDING_MODEL=BAAI/bge-small-en-v1.5
# Option 2: Use Ollama (if Infinity unavailable)
OLLAMA_URL=http://ollama:11434
OLLAMA_MODEL=nomic-embed-text
# Option 3: Use local model (automatic fallback)
# No configuration needed
```
### When to Choose Ollama
**Choose Ollama if**:
- You're already using Ollama for LLMs
- You need privacy/data sovereignty
- You have <10k documents and <100 users
- Ease of setup is more important than max performance
- You're in development/testing phase
**Choose Infinity/TEI if**:
- You need maximum throughput (>1000 embeddings/sec)
- You have >100k documents
- Latency is critical (<50ms)
- You're in production with >100 users
**Choose OpenAI API if**:
- You're okay with cloud dependencies
- You need best-in-class quality
- Cost is not a concern (~$0.02 per 1M tokens)
## Production Deployment Guidance
### Small Production (Ollama Acceptable)
**Profile**:
- 5-20 users
- 1,000-10,000 documents
- 50-200 searches/day
- <2 sec acceptable latency
**Configuration**:
```yaml
ollama:
image: ollama/ollama:latest
deploy:
resources:
limits:
memory: 4GB
cpus: "2.0"
reservations:
devices:
- driver: nvidia # GPU if available
count: 1
capabilities: [gpu]
environment:
- OLLAMA_NUM_PARALLEL=2 # Concurrent requests
```
**Expected Performance**:
- Embedding latency: 100-200ms
- Throughput: 5-10 embeddings/sec
- Memory: 2-3GB (model loaded)
### Medium Production (Use Infinity/TEI)
**Profile**:
- 20-200 users
- 10,000-1M documents
- 500-5,000 searches/day
- <500ms acceptable latency
**Recommendation**: Migrate to Infinity or TEI
```yaml
infinity:
image: michaelf34/infinity:latest
# Better throughput and latency
```
### Large Production (Use Specialized Solution)
**Profile**:
- >200 users
- >1M documents
- >5,000 searches/day
- <100ms required latency
**Recommendation**: Use TEI cluster or commercial service
## Monitoring Considerations
### Key Metrics to Track
```python
# Add Ollama-specific metrics
from prometheus_client import Histogram, Counter, Gauge
ollama_embedding_latency = Histogram(
'ollama_embedding_duration_seconds',
'Ollama embedding generation time',
['model', 'batch_size']
)
ollama_batch_size = Gauge(
'ollama_batch_size',
'Current batch size being processed'
)
ollama_errors = Counter(
'ollama_errors_total',
'Ollama embedding errors',
['error_type']
)
```
### Health Checks
```python
async def ollama_health_check():
"""Check Ollama availability"""
try:
async with httpx.AsyncClient() as client:
# Check server
response = await client.get("http://ollama:11434/api/tags")
response.raise_for_status()
# Verify model loaded
models = response.json().get("models", [])
if "nomic-embed-text" not in [m["name"] for m in models]:
return False, "Model not pulled"
return True, "OK"
except Exception as e:
return False, str(e)
```
## Migration Path
### Starting with Ollama
**Phase 1: Development** (Ollama)
- Use Ollama for initial development
- Validate embedding pipeline
- Test search quality
**Phase 2: Growth** (Ollama → Infinity)
- Monitor performance metrics
- When >50 users or >10k docs, migrate to Infinity
- Simple config change, no code changes
**Phase 3: Scale** (Infinity → TEI/Commercial)
- When >200 users or performance issues
- Consider TEI cluster or managed services
### Code Compatibility
All embedding providers use the same interface:
```python
# Works with Ollama, Infinity, OpenAI, Local
embedding = await embedding_service.embed(text)
embeddings = await embedding_service.embed_batch(texts)
```
**Migration is a configuration change only** - no code rewrite needed.
## Conclusion
**Ollama is a solid choice for:**
- Early-stage projects
- Development/testing
- Privacy-critical applications
- Small deployments (<10 users, <10k docs)
- Unified LLM + embedding stack
**But recognize its limitations:**
- 5-9x slower than specialized engines
- Not designed for high-throughput production
- Batch processing can be problematic
- Limited scalability
**Recommendation**:
**Include Ollama as Tier 2b** (after Infinity, before local models) in the embedding strategy. It provides a good balance of ease-of-use and privacy for small-to-medium deployments while allowing seamless migration to more performant engines as needs grow.
The key is designing the abstraction layer (as done in ADR-003) so migration between engines requires only configuration changes, not code rewrites.
+10 -13
View File
@@ -136,27 +136,24 @@ A patch for the `user_oidc` app is required to fix Bearer token support. See [oa
---
### Issue: "Permission denied" or "Database is locked" when accessing OAuth client storage
### Issue: "Permission denied" when reading/writing OAuth client credentials file
**Cause:** The server cannot access the SQLite database for OAuth client credentials storage.
**Cause:** The server cannot access the OAuth client storage file (default: `.nextcloud_oauth_client.json`).
**Solution:**
```bash
# Check database directory permissions
ls -la data/
# Check file permissions
ls -la .nextcloud_oauth_client.json
# Ensure directory is writable
chmod 755 data/
# Fix file permissions (should be 0600 - owner read/write only)
chmod 600 .nextcloud_oauth_client.json
# Check if database file exists and has correct permissions
ls -la data/tokens.db
chmod 644 data/tokens.db
# Ensure the directory is writable
chmod 755 $(dirname .nextcloud_oauth_client.json)
# For Docker deployments, ensure volume is mounted correctly:
# docker-compose.yml should have:
# volumes:
# - ./data:/app/data
# If the file doesn't exist, ensure the directory is writable so it can be created
mkdir -p $(dirname .nextcloud_oauth_client.json)
```
---
+1 -8
View File
@@ -8,19 +8,12 @@ NEXTCLOUD_HOST=
# - Requires Nextcloud OIDC app installed and configured
# - Admin must enable "Dynamic Client Registration" in OIDC app settings
# - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode
# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB)
# - Optional: Pre-register client and provide credentials (otherwise auto-registers)
NEXTCLOUD_OIDC_CLIENT_ID=
NEXTCLOUD_OIDC_CLIENT_SECRET=
NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json
NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000
# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens)
# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens
# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
#TOKEN_ENCRYPTION_KEY=
# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db)
#TOKEN_STORAGE_DB=/app/data/tokens.db
# Option 2: Basic Authentication (LEGACY - Less Secure)
# - Requires username and password
# - Credentials stored in environment variables
-783
View File
@@ -1,783 +0,0 @@
{
"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,
"attributes": {
"frontendUrl": "http://localhost:8888"
},
"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"
]
}
},
{
"username": "test_read_only",
"enabled": true,
"email": "readonly@example.com",
"emailVerified": true,
"firstName": "Read",
"lastName": "Only",
"credentials": [
{
"type": "password",
"value": "test123",
"temporary": false
}
],
"realmRoles": [
"default-roles-nextcloud-mcp",
"offline_access"
],
"attributes": {
"quota": [
"1073741824"
]
}
},
{
"username": "test_write_only",
"enabled": true,
"email": "writeonly@example.com",
"emailVerified": true,
"firstName": "Write",
"lastName": "Only",
"credentials": [
{
"type": "password",
"value": "test123",
"temporary": false
}
],
"realmRoles": [
"default-roles-nextcloud-mcp",
"offline_access"
],
"attributes": {
"quota": [
"1073741824"
]
}
},
{
"username": "test_no_scopes",
"enabled": true,
"email": "noscopes@example.com",
"emailVerified": true,
"firstName": "No",
"lastName": "Scopes",
"credentials": [
{
"type": "password",
"value": "test123",
"temporary": false
}
],
"realmRoles": [
"default-roles-nextcloud-mcp",
"offline_access"
],
"attributes": {
"quota": [
"1073741824"
]
}
},
{
"username": "service-account-nextcloud-mcp-server",
"enabled": true,
"serviceAccountClientId": "nextcloud-mcp-server",
"clientRoles": {
"realm-management": [
"impersonation"
]
}
}
],
"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": true,
"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",
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
},
"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": "sub",
"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": "sub",
"jsonType.label": "String"
}
},
{
"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",
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"contacts:read",
"contacts:write",
"cookbook:read",
"cookbook:write",
"deck:read",
"deck:write",
"tables:read",
"tables:write",
"files:read",
"files:write",
"sharing:read",
"sharing:write",
"todo:read",
"todo:write"
]
}
],
"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": "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": "given name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "firstName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "given_name",
"jsonType.label": "String"
}
},
{
"name": "family name",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-property-mapper",
"consentRequired": false,
"config": {
"userinfo.token.claim": "true",
"user.attribute": "lastName",
"id.token.claim": "true",
"access.token.claim": "true",
"claim.name": "family_name",
"jsonType.label": "String"
}
}
]
},
{
"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": {}
}
]
},
{
"name": "notes:read",
"description": "Nextcloud Notes read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your notes"
}
},
{
"name": "notes:write",
"description": "Nextcloud Notes write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete your notes"
}
},
{
"name": "calendar:read",
"description": "Nextcloud Calendar read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your calendars and events"
}
},
{
"name": "calendar:write",
"description": "Nextcloud Calendar write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete calendars and events"
}
},
{
"name": "contacts:read",
"description": "Nextcloud Contacts read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your contacts"
}
},
{
"name": "contacts:write",
"description": "Nextcloud Contacts write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete contacts"
}
},
{
"name": "cookbook:read",
"description": "Nextcloud Cookbook read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your recipes"
}
},
{
"name": "cookbook:write",
"description": "Nextcloud Cookbook write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete recipes"
}
},
{
"name": "deck:read",
"description": "Nextcloud Deck read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your boards and cards"
}
},
{
"name": "deck:write",
"description": "Nextcloud Deck write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete boards and cards"
}
},
{
"name": "tables:read",
"description": "Nextcloud Tables read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your tables and rows"
}
},
{
"name": "tables:write",
"description": "Nextcloud Tables write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete tables and rows"
}
},
{
"name": "files:read",
"description": "Nextcloud Files read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your files"
}
},
{
"name": "files:write",
"description": "Nextcloud Files write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Upload, update, and delete files"
}
},
{
"name": "sharing:read",
"description": "Nextcloud Sharing read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "View shared resources"
}
},
{
"name": "sharing:write",
"description": "Nextcloud Sharing write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create and manage shares"
}
},
{
"name": "todo:read",
"description": "Nextcloud Tasks/Todo read access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Read your tasks"
}
},
{
"name": "todo:write",
"description": "Nextcloud Tasks/Todo write access",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "true",
"consent.screen.text": "Create, update, and delete tasks"
}
},
{
"name": "audience",
"description": "Audience scope for token validation",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "true",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "mcp-server-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "nextcloud-mcp-server",
"id.token.claim": "false",
"access.token.claim": "true"
}
},
{
"name": "nextcloud-audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "nextcloud",
"id.token.claim": "false",
"access.token.claim": "true"
}
}
]
}
],
"components": {
"org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [
{
"name": "Trusted Hosts",
"providerId": "trusted-hosts",
"subType": "anonymous",
"subComponents": {},
"config": {
"trusted-hosts": [
"localhost",
"127.0.0.1",
"172.19.0.1"
],
"host-sending-registration-request-must-match": [
"false"
],
"client-uris-must-match": [
"true"
]
}
},
{
"name": "Max Clients",
"providerId": "max-clients",
"subType": "anonymous",
"subComponents": {},
"config": {
"max-clients": [
"200"
]
}
}
]
},
"defaultDefaultClientScopes": [
"profile",
"email",
"roles",
"web-origins",
"audience"
],
"defaultOptionalClientScopes": [
"offline_access",
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"contacts:read",
"contacts:write",
"cookbook:read",
"cookbook:write",
"deck:read",
"deck:write",
"tables:read",
"tables:write",
"files:read",
"files:write",
"sharing:read",
"sharing:write",
"todo:read",
"todo:write"
]
}
+139 -293
View File
@@ -3,10 +3,6 @@ import os
from collections.abc import AsyncIterator
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
import click
import httpx
@@ -212,9 +208,6 @@ class OAuthAppContext:
nextcloud_host: str
token_verifier: NextcloudTokenVerifier
refresh_token_storage: Optional["RefreshTokenStorage"] = None
oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
def is_oauth_mode() -> bool:
@@ -268,22 +261,21 @@ async def load_oauth_client_credentials(
logger.info("Using pre-configured OAuth client credentials from environment")
return (client_id, client_secret)
# Try loading from SQLite storage
try:
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
# Try loading from storage file
storage_path = os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
)
from pathlib import Path
storage = RefreshTokenStorage.from_env()
await storage.initialize()
from nextcloud_mcp_server.auth.client_registration import load_client_from_file
client_data = await storage.get_oauth_client()
if client_data:
logger.info(
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
)
return (client_data["client_id"], client_data["client_secret"])
except ValueError:
# TOKEN_ENCRYPTION_KEY not set, skip SQLite storage check
logger.debug("SQLite storage not available (TOKEN_ENCRYPTION_KEY not set)")
client_info = load_client_from_file(Path(storage_path))
if client_info:
logger.info(
f"Loaded OAuth client from storage: {client_info.client_id[:16]}..."
)
return (client_info.client_id, client_info.client_secret)
# Try dynamic registration if available
if registration_endpoint:
@@ -314,17 +306,6 @@ async def load_oauth_client_credentials(
"sharing:read sharing:write"
)
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
# Add offline_access scope if refresh tokens are enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
"1",
"yes",
)
if enable_offline_access and "offline_access" not in scopes:
scopes = f"{scopes} offline_access"
logger.info("✓ offline_access scope enabled for refresh tokens")
logger.info(f"Requesting OAuth scopes: {scopes}")
# Get token type from environment (Bearer or jwt)
@@ -335,17 +316,15 @@ async def load_oauth_client_credentials(
token_type = "Bearer"
logger.info(f"Requesting token type: {token_type}")
# Ensure OAuth client in SQLite storage
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
# Load or register client
from nextcloud_mcp_server.auth.client_registration import (
load_or_register_client,
)
storage = RefreshTokenStorage.from_env()
await storage.initialize()
client_info = await ensure_oauth_client(
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage=storage,
storage_path=storage_path,
client_name=f"Nextcloud MCP Server ({token_type})",
redirect_uris=redirect_uris,
scopes=scopes,
@@ -359,9 +338,8 @@ async def load_oauth_client_credentials(
raise ValueError(
"OAuth mode requires either:\n"
"1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n"
"2. Pre-existing client credentials in SQLite storage (TOKEN_STORAGE_DB), OR\n"
"3. Dynamic client registration enabled on Nextcloud OIDC app\n\n"
"Note: TOKEN_ENCRYPTION_KEY is required for SQLite storage"
"2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n"
"3. Dynamic client registration enabled on Nextcloud OIDC app"
)
@@ -389,26 +367,84 @@ async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]:
await client.close()
@asynccontextmanager
async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
"""
Manage application lifecycle for OAuth mode.
Initializes OAuth client registration and token verifier.
Does NOT create a Nextcloud client - clients are created per-request.
"""
logger.info("Starting MCP server in OAuth mode")
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
raise ValueError("NEXTCLOUD_HOST environment variable is required")
nextcloud_host = nextcloud_host.rstrip("/")
# Get OAuth discovery endpoint
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
try:
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info(f"OIDC discovery successful: {discovery_url}")
# Extract endpoints
userinfo_uri = discovery["userinfo_endpoint"]
registration_endpoint = discovery.get("registration_endpoint")
introspection_uri = discovery.get("introspection_endpoint")
logger.info(f"Userinfo endpoint: {userinfo_uri}")
if introspection_uri:
logger.info(f"Introspection endpoint: {introspection_uri}")
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier with introspection support
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
introspection_uri=introspection_uri,
client_id=client_id,
client_secret=client_secret,
)
logger.info("OAuth initialization complete")
# Initialize document processors
initialize_document_processors()
try:
yield OAuthAppContext(
nextcloud_host=nextcloud_host, token_verifier=token_verifier
)
finally:
logger.info("Shutting down OAuth mode")
await token_verifier.close()
except Exception as e:
logger.error(f"Failed to initialize OAuth mode: {e}")
raise
async def setup_oauth_config():
"""
Setup OAuth configuration by performing OIDC discovery and client registration.
Auto-detects OAuth provider mode:
- Integrated mode: OIDC_DISCOVERY_URL points to NEXTCLOUD_HOST (or not set)
→ Nextcloud OIDC app provides both OAuth and API access
- External IdP mode: OIDC_DISCOVERY_URL points to external provider
→ External IdP for OAuth, Nextcloud user_oidc validates tokens and provides API access
Uses generic OIDC environment variables:
- OIDC_DISCOVERY_URL: OIDC discovery endpoint (optional, defaults to NEXTCLOUD_HOST)
- OIDC_CLIENT_ID / OIDC_CLIENT_SECRET: Static credentials (optional, uses DCR if not provided)
- NEXTCLOUD_OIDC_SCOPES: Requested OAuth scopes
This is done synchronously before FastMCP initialization because FastMCP
requires token_verifier at construction time.
Returns:
Tuple of (nextcloud_host, token_verifier, auth_settings, refresh_token_storage, oauth_client, oauth_provider)
Tuple of (nextcloud_host, token_verifier, auth_settings)
"""
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
@@ -417,222 +453,68 @@ async def setup_oauth_config():
)
nextcloud_host = nextcloud_host.rstrip("/")
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
# Get OIDC discovery URL (defaults to Nextcloud integrated mode)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL", f"{nextcloud_host}/.well-known/openid-configuration"
)
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Perform OIDC discovery
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("OIDC discovery successful")
logger.info("OIDC discovery successful")
# Validate PKCE support
validate_pkce_support(discovery, discovery_url)
# Extract OIDC endpoints
# Extract endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
registration_endpoint = discovery.get("registration_endpoint")
# Allow overriding JWKS URI (useful when running in Docker with frontendUrl)
# Example: frontendUrl=http://localhost:8888 but MCP server needs http://keycloak:8080
jwks_uri_override = os.getenv("OIDC_JWKS_URI")
if jwks_uri_override:
logger.info(f"OIDC_JWKS_URI override: {jwks_uri}{jwks_uri_override}")
jwks_uri = jwks_uri_override
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
if jwks_uri:
logger.info(f" JWKS: {jwks_uri}")
logger.info(f" JWKS: {jwks_uri}")
if introspection_uri:
logger.info(f" Introspection: {introspection_uri}")
# Auto-detect provider mode based on issuer
# External IdP mode: issuer doesn't match Nextcloud host
# Normalize URLs for comparison (handle port differences like :80 for HTTP)
from urllib.parse import urlparse
def normalize_url(url: str) -> str:
"""Normalize URL by removing default ports (80 for HTTP, 443 for HTTPS)."""
parsed = urlparse(url)
# Remove default ports
if (parsed.scheme == "http" and parsed.port == 80) or (
parsed.scheme == "https" and parsed.port == 443
):
# Remove explicit default port
hostname = parsed.hostname or parsed.netloc.split(":")[0]
return f"{parsed.scheme}://{hostname}"
return f"{parsed.scheme}://{parsed.netloc}"
issuer_normalized = normalize_url(issuer)
nextcloud_normalized = normalize_url(nextcloud_host)
is_external_idp = not issuer_normalized.startswith(nextcloud_normalized)
if is_external_idp:
oauth_provider = "external" # Could be Keycloak, Auth0, Okta, etc.
logger.info(
f"✓ Detected external IdP mode (issuer: {issuer} != Nextcloud: {nextcloud_host})"
)
logger.info(" Tokens will be validated via Nextcloud user_oidc app")
else:
oauth_provider = "nextcloud"
logger.info("✓ Detected integrated mode (Nextcloud OIDC app)")
# Check if offline access (refresh tokens) is enabled
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
"true",
"1",
"yes",
)
# Initialize refresh token storage if enabled
refresh_token_storage = None
if enable_offline_access:
try:
from nextcloud_mcp_server.auth.refresh_token_storage import (
RefreshTokenStorage,
)
# Validate encryption key before initializing
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key:
logger.warning(
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
"Refresh tokens will NOT be stored. Generate a key with:\n"
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
else:
refresh_token_storage = RefreshTokenStorage.from_env()
await refresh_token_storage.initialize()
logger.info(
"✓ Refresh token storage initialized (offline_access enabled)"
)
except Exception as e:
logger.error(f"Failed to initialize refresh token storage: {e}")
logger.warning(
"Continuing without refresh token storage - users will need to re-authenticate after token expiration"
)
# Load client credentials (static or dynamic registration)
client_id = os.getenv("OIDC_CLIENT_ID")
client_secret = os.getenv("OIDC_CLIENT_SECRET")
if client_id and client_secret:
logger.info(f"Using static OIDC client credentials: {client_id}")
elif registration_endpoint:
logger.info("OIDC_CLIENT_ID not set, attempting Dynamic Client Registration")
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
else:
raise ValueError(
"OIDC_CLIENT_ID and OIDC_CLIENT_SECRET environment variables are required "
"when the OIDC provider does not support Dynamic Client Registration. "
f"Discovery URL: {discovery_url}"
)
# Handle public issuer override (for clients accessing via different URL)
# Allow override of public issuer URL for both client configuration and JWT validation
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# but the MCP server accesses via internal URL (e.g., http://app:80),
# we need to use the public URL for JWT validation and client configuration
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL override for JWT validation: {public_issuer}"
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
)
# Use public issuer for both client configuration AND JWT validation
issuer = public_issuer
jwt_validation_issuer = public_issuer
client_issuer = public_issuer
else:
# Use discovered issuer for both
jwt_validation_issuer = issuer
client_issuer = issuer
# Create token verifier
if is_external_idp:
# External IdP mode: Validate via Nextcloud user_oidc app
# The user_oidc app accepts tokens from the external IdP and provisions users
nextcloud_userinfo_uri = f"{nextcloud_host}/apps/user_oidc/userinfo"
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=nextcloud_userinfo_uri, # Nextcloud validates external tokens
jwks_uri=jwks_uri, # External IdP's JWKS for JWT validation
issuer=jwt_validation_issuer, # External IdP issuer
introspection_uri=None, # External IdP introspection not used
client_id=client_id,
client_secret=client_secret,
)
logger.info(
"✓ External IdP mode configured - tokens validated via Nextcloud user_oidc app"
)
else:
# Integrated mode: Nextcloud provides both OAuth and validation
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri, # Nextcloud userinfo endpoint
jwks_uri=jwks_uri, # Nextcloud JWKS for JWT validation
issuer=jwt_validation_issuer, # Nextcloud issuer (or public override)
introspection_uri=introspection_uri, # Nextcloud introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
logger.info(
"✓ Integrated mode configured - Nextcloud provides OAuth and validation"
)
# Create OAuth client for server-initiated flows (e.g., token exchange, background workers)
oauth_client = None
if enable_offline_access and refresh_token_storage and is_external_idp:
# For external IdP mode, create generic OIDC client for token operations
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
redirect_uri = f"{mcp_server_url}/oauth/callback"
# Extract base URL and realm from discovery URL
# Format: http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration
# → base_url: http://keycloak:8080, realm: nextcloud-mcp
if "/realms/" in discovery_url:
base_url = discovery_url.split("/realms/")[0]
realm = discovery_url.split("/realms/")[1].split("/")[0]
else:
# Fallback: use issuer to extract base URL
base_url = (
issuer.rsplit("/realms/", 1)[0] if "/realms/" in issuer else issuer
)
realm = issuer.split("/realms/")[1] if "/realms/" in issuer else ""
oauth_client = KeycloakOAuthClient(
keycloak_url=base_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
await oauth_client.discover()
logger.info(
"✓ OIDC client initialized for token operations (token exchange, refresh)"
)
elif enable_offline_access and refresh_token_storage:
# For integrated mode, OAuth client could be added later
# For now, token refresh can use httpx directly with discovered endpoints
logger.info(
"OAuth client for token refresh not yet implemented for integrated mode"
)
# Create token verifier with JWT support and introspection
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enable JWT verification if available
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
# Create auth settings
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
@@ -641,22 +523,13 @@ async def setup_oauth_config():
# Scopes are now advertised via PRM endpoint and enforced per-tool.
# This allows dynamic tool filtering based on user's actual token scopes.
auth_settings = AuthSettings(
issuer_url=AnyHttpUrl(
client_issuer
), # Use client issuer (may be public override)
issuer_url=AnyHttpUrl(issuer),
resource_server_url=AnyHttpUrl(mcp_server_url),
)
logger.info("OAuth configuration complete")
return (
nextcloud_host,
token_verifier,
auth_settings,
refresh_token_storage,
oauth_client,
oauth_provider,
)
return nextcloud_host, token_verifier, auth_settings
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
@@ -670,53 +543,10 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
# Asynchronously get the OAuth configuration
import anyio
(
nextcloud_host,
token_verifier,
auth_settings,
refresh_token_storage,
oauth_client,
oauth_provider,
) = anyio.run(setup_oauth_config)
# Create lifespan function with captured OAuth context (closure)
@asynccontextmanager
async def oauth_lifespan(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
"""
Lifespan context for OAuth mode - captures OAuth configuration from outer scope.
"""
logger.info("Starting MCP server in OAuth mode")
logger.info(f"Using OAuth provider: {oauth_provider}")
if refresh_token_storage:
logger.info("Refresh token storage is available")
if oauth_client:
logger.info("OAuth client is available for token refresh")
# Initialize document processors
initialize_document_processors()
try:
yield OAuthAppContext(
nextcloud_host=nextcloud_host,
token_verifier=token_verifier,
refresh_token_storage=refresh_token_storage,
oauth_client=oauth_client,
oauth_provider=oauth_provider,
)
finally:
logger.info("Shutting down MCP server")
# RefreshTokenStorage uses context managers, no close() needed
# OAuth client cleanup (if it has a close method)
if oauth_client and hasattr(oauth_client, "close"):
try:
await oauth_client.close()
except Exception as e:
logger.warning(f"Error closing OAuth client: {e}")
logger.info("MCP server shutdown complete")
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
mcp = FastMCP(
"Nextcloud MCP",
lifespan=oauth_lifespan,
lifespan=app_lifespan_oauth,
token_verifier=token_verifier,
auth=auth_settings,
)
@@ -1030,6 +860,13 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
envvar="NEXTCLOUD_OIDC_CLIENT_SECRET",
help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)",
)
@click.option(
"--oauth-storage-path",
envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE",
default=".nextcloud_oauth_client.json",
show_default=True,
help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)",
)
@click.option(
"--mcp-server-url",
envvar="NEXTCLOUD_MCP_SERVER_URL",
@@ -1081,6 +918,7 @@ def run(
oauth: bool | None,
oauth_client_id: str | None,
oauth_client_secret: str | None,
oauth_storage_path: str,
mcp_server_url: str,
nextcloud_host: str | None,
nextcloud_username: str | None,
@@ -1135,6 +973,8 @@ def run(
os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id
if oauth_client_secret:
os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret
if oauth_storage_path:
os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path
if oauth_scopes:
os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes
if oauth_token_type:
@@ -1177,7 +1017,13 @@ def run(
click.echo("OAuth Configuration:", err=True)
click.echo(" Mode: Dynamic Client Registration", err=True)
click.echo(" Host: " + nextcloud_host, err=True)
click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True)
click.echo(
" Storage: "
+ os.getenv(
"NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json"
),
err=True,
)
click.echo("", err=True)
click.echo(
"Note: Make sure 'Dynamic Client Registration' is enabled", err=True
+2 -2
View File
@@ -1,7 +1,7 @@
"""OAuth authentication components for Nextcloud MCP server."""
from .bearer_auth import BearerAuth
from .client_registration import ensure_oauth_client, register_client
from .client_registration import load_or_register_client, register_client
from .context_helper import get_client_from_context
from .scope_authorization import (
InsufficientScopeError,
@@ -20,7 +20,7 @@ __all__ = [
"BearerAuth",
"NextcloudTokenVerifier",
"register_client",
"ensure_oauth_client",
"load_or_register_client",
"get_client_from_context",
"require_scopes",
"ScopeAuthorizationError",
@@ -1,15 +1,16 @@
"""Dynamic client registration for Nextcloud OIDC."""
import datetime as dt
import json
import logging
import os
import time
from pathlib import Path
from typing import Any
import anyio
import httpx
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
@@ -169,6 +170,72 @@ async def register_client(
raise ValueError(f"Invalid registration response: missing {e}")
def load_client_from_file(storage_path: Path) -> ClientInfo | None:
"""
Load client credentials from storage file.
Args:
storage_path: Path to the JSON file containing client credentials
Returns:
ClientInfo if file exists and is valid, None otherwise
"""
if not storage_path.exists():
logger.debug(f"Client storage file not found: {storage_path}")
return None
try:
with open(storage_path, "r") as f:
data = json.load(f)
client_info = ClientInfo.from_dict(data)
if client_info.is_expired:
logger.warning(
f"Stored client has expired (expired at {client_info.client_secret_expires_at})"
)
return None
logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...")
if client_info.expires_soon:
logger.warning("Client expires soon (within 5 minutes)")
return client_info
except (json.JSONDecodeError, KeyError, ValueError) as e:
logger.error(f"Failed to load client from file: {e}")
return None
def save_client_to_file(client_info: ClientInfo, storage_path: Path):
"""
Save client credentials to storage file.
Args:
client_info: Client information to save
storage_path: Path to save the JSON file
Raises:
OSError: If file cannot be written
"""
try:
# Create directory if it doesn't exist
storage_path.parent.mkdir(parents=True, exist_ok=True)
# Write client info
with open(storage_path, "w") as f:
json.dump(client_info.to_dict(), f, indent=2)
# Set restrictive permissions (owner read/write only)
os.chmod(storage_path, 0o600)
logger.info(f"Saved client credentials to {storage_path}")
except OSError as e:
logger.error(f"Failed to save client credentials: {e}")
raise
async def delete_client(
nextcloud_url: str,
client_id: str,
@@ -295,28 +362,28 @@ async def delete_client(
return False
async def ensure_oauth_client(
async def load_or_register_client(
nextcloud_url: str,
registration_endpoint: str,
storage: RefreshTokenStorage,
storage_path: str | Path,
client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None,
scopes: str = "openid profile email",
token_type: str = "Bearer",
) -> ClientInfo:
"""
Ensure OAuth client exists in SQLite storage.
Load client from storage or register a new one if not found/expired.
This function:
1. Checks for existing client credentials in SQLite storage
1. Checks for existing client credentials in storage
2. Validates the credentials are not expired
3. Registers a new client if needed (no stored credentials or expired)
4. Saves the new client credentials to SQLite
4. Saves the new client credentials
Args:
nextcloud_url: Base URL of the Nextcloud instance
registration_endpoint: Full URL to the registration endpoint
storage: RefreshTokenStorage instance for SQLite storage
storage_path: Path to store client credentials
client_name: Name of the client application
redirect_uris: List of redirect URIs
scopes: Space-separated list of scopes to request (default: "openid profile email")
@@ -329,13 +396,12 @@ async def ensure_oauth_client(
httpx.HTTPStatusError: If registration fails
ValueError: If response is invalid
"""
# Try to load existing client from SQLite
client_data = await storage.get_oauth_client()
if client_data:
logger.info(
f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..."
)
return ClientInfo.from_dict(client_data)
storage_path = Path(storage_path)
# Try to load existing client
client_info = load_client_from_file(storage_path)
if client_info:
return client_info
# Register new client
logger.info("Registering new OAuth client...")
@@ -348,15 +414,7 @@ async def ensure_oauth_client(
token_type=token_type,
)
# Save to SQLite storage
await storage.store_oauth_client(
client_id=client_info.client_id,
client_secret=client_info.client_secret,
client_id_issued_at=client_info.client_id_issued_at,
client_secret_expires_at=client_info.client_secret_expires_at,
redirect_uris=client_info.redirect_uris,
registration_access_token=client_info.registration_access_token,
registration_client_uri=client_info.registration_client_uri,
)
# Save to storage
save_client_to_file(client_info, storage_path)
return client_info
-581
View File
@@ -1,581 +0,0 @@
"""
Keycloak OAuth 2.0 / OIDC Client
Handles OAuth flows with Keycloak as the identity provider, including:
- OIDC Discovery
- Authorization Code Flow with PKCE
- Token refresh using refresh tokens (ADR-002 Tier 1)
- Integration with RefreshTokenStorage
"""
import hashlib
import logging
import os
import secrets
from typing import Optional
from urllib.parse import urlencode, urlparse
import httpx
logger = logging.getLogger(__name__)
class KeycloakOAuthClient:
"""OAuth 2.0 client for Keycloak integration"""
def __init__(
self,
keycloak_url: str,
realm: str,
client_id: str,
client_secret: str,
redirect_uri: str,
scopes: Optional[list[str]] = None,
):
"""
Initialize Keycloak OAuth client.
Args:
keycloak_url: Base URL of Keycloak (e.g., http://keycloak:8080)
realm: Keycloak realm name
client_id: OAuth client ID
client_secret: OAuth client secret
redirect_uri: OAuth redirect URI
scopes: List of scopes to request (default: openid, profile, email, offline_access)
"""
self.keycloak_url = keycloak_url.rstrip("/")
self.realm = realm
self.client_id = client_id
self.client_secret = client_secret
self.redirect_uri = redirect_uri
self.scopes = scopes or ["openid", "profile", "email", "offline_access"]
# Discovered endpoints (populated by discover())
self.authorization_endpoint: Optional[str] = None
self.token_endpoint: Optional[str] = None
self.userinfo_endpoint: Optional[str] = None
self.jwks_uri: Optional[str] = None
self.end_session_endpoint: Optional[str] = None
self._http_client: Optional[httpx.AsyncClient] = None
@classmethod
def from_env(cls) -> "KeycloakOAuthClient":
"""
Create client from environment variables.
Environment variables:
KEYCLOAK_URL: Keycloak base URL
KEYCLOAK_REALM: Realm name
KEYCLOAK_CLIENT_ID: Client ID
KEYCLOAK_CLIENT_SECRET: Client secret
NEXTCLOUD_MCP_SERVER_URL: MCP server URL (for redirect URI)
Returns:
KeycloakOAuthClient instance
Raises:
ValueError: If required environment variables are missing
"""
keycloak_url = os.getenv("KEYCLOAK_URL")
realm = os.getenv("KEYCLOAK_REALM")
client_id = os.getenv("KEYCLOAK_CLIENT_ID")
client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
if not all([keycloak_url, realm, client_id, client_secret]):
raise ValueError(
"Missing required environment variables: "
"KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET"
)
# Parse server URL to construct redirect URI
parsed_url = urlparse(server_url)
redirect_uri = f"{parsed_url.scheme}://{parsed_url.netloc}/oauth/callback"
return cls(
keycloak_url=keycloak_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
async def _get_http_client(self) -> httpx.AsyncClient:
"""Get or create HTTP client"""
if self._http_client is None:
self._http_client = httpx.AsyncClient(timeout=30.0)
return self._http_client
async def close(self) -> None:
"""Close HTTP client"""
if self._http_client:
await self._http_client.aclose()
self._http_client = None
async def discover(self) -> None:
"""
Perform OIDC discovery to get endpoint URLs.
Raises:
httpx.HTTPError: If discovery fails
"""
discovery_url = (
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
)
logger.info(f"Discovering Keycloak endpoints at {discovery_url}")
client = await self._get_http_client()
response = await client.get(discovery_url)
response.raise_for_status()
discovery_data = response.json()
self.authorization_endpoint = discovery_data["authorization_endpoint"]
self.token_endpoint = discovery_data["token_endpoint"]
self.userinfo_endpoint = discovery_data["userinfo_endpoint"]
self.jwks_uri = discovery_data.get("jwks_uri")
self.end_session_endpoint = discovery_data.get("end_session_endpoint")
logger.info(
f"✓ Discovered Keycloak endpoints:\n"
f" Authorization: {self.authorization_endpoint}\n"
f" Token: {self.token_endpoint}\n"
f" Userinfo: {self.userinfo_endpoint}\n"
f" JWKS: {self.jwks_uri}"
)
def generate_pkce_challenge(self) -> tuple[str, str]:
"""
Generate PKCE code verifier and challenge.
Returns:
Tuple of (code_verifier, code_challenge)
"""
import base64
# Generate code verifier (43-128 characters)
code_verifier = secrets.token_urlsafe(32)
# Generate code challenge using S256 method (base64url-encoded SHA256)
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return code_verifier, code_challenge
async def get_authorization_url(
self,
state: str,
code_challenge: str,
extra_params: Optional[dict[str, str]] = None,
) -> str:
"""
Build authorization URL for OAuth flow.
Args:
state: CSRF protection state parameter
code_challenge: PKCE code challenge
extra_params: Additional query parameters
Returns:
Authorization URL
Raises:
RuntimeError: If discover() hasn't been called
"""
if not self.authorization_endpoint:
await self.discover()
if not self.authorization_endpoint:
raise RuntimeError("Authorization endpoint not discovered")
params = {
"client_id": self.client_id,
"response_type": "code",
"redirect_uri": self.redirect_uri,
"scope": " ".join(self.scopes),
"state": state,
"code_challenge": code_challenge,
"code_challenge_method": "S256",
}
if extra_params:
params.update(extra_params)
return f"{self.authorization_endpoint}?{urlencode(params)}"
async def exchange_authorization_code(
self,
code: str,
code_verifier: str,
) -> dict:
"""
Exchange authorization code for tokens.
Args:
code: Authorization code from OAuth callback
code_verifier: PKCE code verifier
Returns:
Token response dictionary with keys:
- access_token: Access token
- refresh_token: Refresh token (if offline_access scope requested)
- id_token: ID token (JWT)
- expires_in: Access token lifetime in seconds
- refresh_expires_in: Refresh token lifetime in seconds (optional)
- token_type: Token type (Bearer)
Raises:
httpx.HTTPError: If token exchange fails
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
logger.debug(
f"Exchanging authorization code for tokens at {self.token_endpoint}"
)
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": self.redirect_uri,
"code_verifier": code_verifier,
},
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
token_data = response.json()
logger.info("✓ Successfully exchanged authorization code for tokens")
if "refresh_token" in token_data:
logger.info(" Received refresh token (offline_access granted)")
return token_data
async def refresh_access_token(self, refresh_token: str) -> dict:
"""
Refresh access token using refresh token.
Args:
refresh_token: Refresh token
Returns:
Token response dictionary (same format as exchange_authorization_code)
Raises:
httpx.HTTPError: If token refresh fails
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
logger.debug("Refreshing access token")
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
token_data = response.json()
logger.debug("✓ Successfully refreshed access token")
return token_data
async def get_userinfo(self, access_token: str) -> dict:
"""
Get user information using access token.
Args:
access_token: Access token
Returns:
Userinfo response dictionary with claims like:
- sub: Subject (user ID)
- name: Full name
- preferred_username: Username
- email: Email address
- email_verified: Email verification status
Raises:
httpx.HTTPError: If userinfo request fails
"""
if not self.userinfo_endpoint:
await self.discover()
if not self.userinfo_endpoint:
raise RuntimeError("Userinfo endpoint not discovered")
logger.debug("Fetching user info")
client = await self._get_http_client()
response = await client.get(
self.userinfo_endpoint,
headers={"Authorization": f"Bearer {access_token}"},
)
response.raise_for_status()
userinfo = response.json()
logger.debug(f"✓ Retrieved user info for subject: {userinfo.get('sub')}")
return userinfo
async def get_service_account_token(self, scopes: list[str] | None = None) -> dict:
"""
Get a service account token using client_credentials grant.
⚠️ **WARNING: DO NOT USE FOR DIRECT API ACCESS IN OAUTH MODE** ⚠️
This method creates a service account user in Nextcloud which VIOLATES
OAuth "act on-behalf-of" principles. Using this token directly for API
access will:
- Create a Nextcloud user: `service-account-{client_id}`
- Attribute all actions to service account instead of real user
- Break audit trail and user attribution
- Create stateful server identity in Nextcloud
- Violate OAuth security model
**Valid Use Case**: ONLY as subject_token for RFC 8693 token exchange
(ADR-002 Tier 2) where it's immediately exchanged for a user token.
**Invalid Use Case**: Direct API access with this token (ADR-002 rejected
this as "Tier 1" - see docs/ADR-002-vector-sync-authentication.md).
**Alternative**: Use token exchange (impersonation/delegation) for
background operations, or use BasicAuth mode if truly need service account.
This requires the client to have serviceAccountsEnabled=true in provider.
Args:
scopes: Optional list of scopes to request (default: openid profile email)
Returns:
Token response dictionary with:
- access_token: Service account access token
- token_type: Bearer
- expires_in: Token lifetime in seconds
- scope: Granted scopes
Raises:
httpx.HTTPError: If token request fails
See Also:
- ADR-002 "Will Not Implement" section for detailed critique
- exchange_token_for_user() for proper token exchange usage
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
# Default scopes
if scopes is None:
scopes = ["openid", "profile", "email"]
scope_str = " ".join(scopes)
logger.info(f"Requesting service account token with scopes: {scope_str}")
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data={
"grant_type": "client_credentials",
"scope": scope_str,
},
auth=(self.client_id, self.client_secret),
)
response.raise_for_status()
token_data = response.json()
logger.info("✓ Service account token acquired")
return token_data
async def exchange_token_for_user(
self,
subject_token: str,
target_user_id: str | None = None,
audience: str | None = None,
scopes: list[str] | None = None,
) -> dict:
"""
Exchange a token for a user-scoped token using RFC 8693 Token Exchange.
This allows the MCP server (with a service account token) to obtain
user-scoped access tokens for background operations without needing
refresh tokens.
Args:
subject_token: The token being exchanged (service account or user token)
target_user_id: Optional user ID to impersonate/exchange for
audience: Optional target audience (client ID)
scopes: Optional list of scopes for the new token
Returns:
Token response dictionary with:
- access_token: User-scoped access token
- issued_token_type: urn:ietf:params:oauth:token-type:access_token
- token_type: Bearer
- expires_in: Token lifetime in seconds
Raises:
httpx.HTTPError: If token exchange fails (403 if not authorized)
Example:
# Get service account token
service_token = await client.get_service_account_token()
# Exchange for user-scoped token
user_token = await client.exchange_token_for_user(
subject_token=service_token["access_token"],
target_user_id="admin", # Username or sub claim
audience="nextcloud",
scopes=["notes:read", "files:read"]
)
Note:
This implements BOTH ADR-002 tiers:
**Tier 2 (Delegation - Recommended)**: When target_user_id is None
- Uses Keycloak Standard V2 (production-ready)
- Service account maintains its identity (sub claim unchanged)
- No special permissions required
**Tier 1 (Impersonation - Advanced)**: When target_user_id is provided
- Requires Keycloak Legacy V1 (--features=preview)
- Subject claim changes to target user
- Requires impersonation role granted via Keycloak CLI:
```
kcadm.sh add-roles -r <realm> \
--uusername service-account-<client-id> \
--cclientid realm-management \
--rolename impersonation
```
Both tiers require:
- Client has token.exchange.grant.enabled=true
- Client has serviceAccountsEnabled=true
"""
if not self.token_endpoint:
await self.discover()
if not self.token_endpoint:
raise RuntimeError("Token endpoint not discovered")
# Build token exchange request
data = {
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
}
# Add optional parameters
if audience:
data["audience"] = audience
if scopes:
data["scope"] = " ".join(scopes)
if target_user_id:
# Tier 1: Impersonation (Legacy V1)
# Use requested_subject for user impersonation
data["requested_subject"] = target_user_id
logger.info(
f"Exchanging token with impersonation (Tier 1): target_user={target_user_id}"
)
else:
# Tier 2: Delegation (Standard V2)
logger.info(
"Exchanging token with delegation (Tier 2): service account identity preserved"
)
client = await self._get_http_client()
response = await client.post(
self.token_endpoint,
data=data,
auth=(self.client_id, self.client_secret),
)
if response.status_code != 200:
error_data = (
response.json()
if response.headers.get("content-type", "").startswith(
"application/json"
)
else {"error": "unknown"}
)
logger.error(f"Token exchange failed: {response.status_code}")
logger.error(f"Error response: {error_data}")
response.raise_for_status()
token_data = response.json()
logger.info(
f"✓ Token exchange successful, issued_token_type: {token_data.get('issued_token_type')}"
)
return token_data
async def check_token_exchange_support(self) -> bool:
"""
Check if Keycloak supports RFC 8693 token exchange.
Returns:
True if token exchange is supported
Note:
This is ADR-002 Tier 2. Most Keycloak installations don't
have token exchange enabled by default.
"""
if not self.token_endpoint:
await self.discover()
# Try to get discovery document and check for token exchange grant
discovery_url = (
f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration"
)
try:
client = await self._get_http_client()
response = await client.get(discovery_url)
response.raise_for_status()
discovery_data = response.json()
grant_types = discovery_data.get("grant_types_supported", [])
supported = "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
if supported:
logger.info("✓ Token exchange (RFC 8693) is supported")
else:
logger.info("Token exchange (RFC 8693) is not supported")
return supported
except Exception as e:
logger.warning(f"Failed to check token exchange support: {e}")
return False
__all__ = ["KeycloakOAuthClient"]
@@ -1,628 +0,0 @@
"""
Refresh Token Storage for ADR-002 Tier 1: Offline Access
Securely stores and manages user refresh tokens for background operations.
Tokens are encrypted at rest using Fernet symmetric encryption.
"""
import json
import logging
import os
import time
from pathlib import Path
from typing import Optional
import aiosqlite
from cryptography.fernet import Fernet
logger = logging.getLogger(__name__)
class RefreshTokenStorage:
"""Securely store and manage user refresh tokens"""
def __init__(self, db_path: str, encryption_key: bytes):
"""
Initialize refresh token storage.
Args:
db_path: Path to SQLite database file
encryption_key: Fernet encryption key (32 bytes, base64-encoded)
"""
self.db_path = db_path
self.cipher = Fernet(encryption_key)
self._initialized = False
@classmethod
def from_env(cls) -> "RefreshTokenStorage":
"""
Create storage instance from environment variables.
Environment variables:
TOKEN_STORAGE_DB: Path to database file (default: /app/data/tokens.db)
TOKEN_ENCRYPTION_KEY: Base64-encoded Fernet key
Returns:
RefreshTokenStorage instance
Raises:
ValueError: If TOKEN_ENCRYPTION_KEY is not set
"""
db_path = os.getenv("TOKEN_STORAGE_DB", "/app/data/tokens.db")
encryption_key_b64 = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key_b64:
raise ValueError(
"TOKEN_ENCRYPTION_KEY environment variable is required. "
"Generate one with: python -c 'from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())'"
)
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
# The key from Fernet.generate_key() is already base64url-encoded
try:
# Convert string to bytes if needed
if isinstance(encryption_key_b64, str):
encryption_key = encryption_key_b64.encode()
else:
encryption_key = encryption_key_b64
# Validate the key by trying to create a Fernet instance
Fernet(encryption_key)
except Exception as e:
raise ValueError(
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
) from e
return cls(db_path=db_path, encryption_key=encryption_key)
async def initialize(self) -> None:
"""Initialize database schema"""
if self._initialized:
return
# Ensure directory exists
db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True)
# Set restrictive permissions on database file
if Path(self.db_path).exists():
os.chmod(self.db_path, 0o600)
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
CREATE TABLE IF NOT EXISTS refresh_tokens (
user_id TEXT PRIMARY KEY,
encrypted_token BLOB NOT NULL,
expires_at INTEGER,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
await db.execute(
"""
CREATE TABLE IF NOT EXISTS audit_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
event TEXT NOT NULL,
user_id TEXT NOT NULL,
resource_type TEXT,
resource_id TEXT,
auth_method TEXT,
hostname TEXT
)
"""
)
# Create index on audit logs for efficient queries
await db.execute(
"CREATE INDEX IF NOT EXISTS idx_audit_user_timestamp "
"ON audit_logs(user_id, timestamp)"
)
# OAuth client credentials storage
await db.execute(
"""
CREATE TABLE IF NOT EXISTS oauth_clients (
id INTEGER PRIMARY KEY,
client_id TEXT UNIQUE NOT NULL,
encrypted_client_secret BLOB NOT NULL,
client_id_issued_at INTEGER NOT NULL,
client_secret_expires_at INTEGER NOT NULL,
redirect_uris TEXT NOT NULL,
encrypted_registration_access_token BLOB,
registration_client_uri TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
await db.commit()
# Set restrictive permissions after creation
os.chmod(self.db_path, 0o600)
self._initialized = True
logger.info(f"Initialized refresh token storage at {self.db_path}")
async def store_refresh_token(
self,
user_id: str,
refresh_token: str,
expires_at: Optional[int] = None,
) -> None:
"""
Store encrypted refresh token for user.
Args:
user_id: User identifier (from OIDC 'sub' claim)
refresh_token: Refresh token to store
expires_at: Token expiration timestamp (Unix epoch), if known
"""
if not self._initialized:
await self.initialize()
encrypted_token = self.cipher.encrypt(refresh_token.encode())
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO refresh_tokens
(user_id, encrypted_token, expires_at, created_at, updated_at)
VALUES (?, ?, ?, COALESCE((SELECT created_at FROM refresh_tokens WHERE user_id = ?), ?), ?)
""",
(user_id, encrypted_token, expires_at, user_id, now, now),
)
await db.commit()
logger.info(
f"Stored refresh token for user {user_id}"
+ (f" (expires at {expires_at})" if expires_at else "")
)
# Audit log
await self._audit_log(
event="store_refresh_token",
user_id=user_id,
auth_method="offline_access",
)
async def get_refresh_token(self, user_id: str) -> Optional[str]:
"""
Retrieve and decrypt refresh token for user.
Args:
user_id: User identifier
Returns:
Decrypted refresh token, or None if not found or expired
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT encrypted_token, expires_at FROM refresh_tokens WHERE user_id = ?",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No refresh token found for user {user_id}")
return None
encrypted_token, expires_at = row
# Check expiration
if expires_at is not None and expires_at < time.time():
logger.warning(
f"Refresh token for user {user_id} has expired (expired at {expires_at})"
)
await self.delete_refresh_token(user_id)
return None
try:
decrypted_token = self.cipher.decrypt(encrypted_token).decode()
logger.debug(f"Retrieved refresh token for user {user_id}")
return decrypted_token
except Exception as e:
logger.error(f"Failed to decrypt refresh token for user {user_id}: {e}")
return None
async def delete_refresh_token(self, user_id: str) -> bool:
"""
Delete refresh token for user.
Args:
user_id: User identifier
Returns:
True if token was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM refresh_tokens WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.info(f"Deleted refresh token for user {user_id}")
await self._audit_log(
event="delete_refresh_token",
user_id=user_id,
auth_method="offline_access",
)
else:
logger.debug(f"No refresh token to delete for user {user_id}")
return deleted
async def get_all_user_ids(self) -> list[str]:
"""
Get list of all user IDs with stored refresh tokens.
Returns:
List of user IDs
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM refresh_tokens ORDER BY updated_at DESC"
) as cursor:
rows = await cursor.fetchall()
user_ids = [row[0] for row in rows]
logger.debug(f"Found {len(user_ids)} users with refresh tokens")
return user_ids
async def cleanup_expired_tokens(self) -> int:
"""
Remove expired refresh tokens from storage.
Returns:
Number of tokens deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM refresh_tokens WHERE expires_at IS NOT NULL AND expires_at < ?",
(now,),
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info(f"Cleaned up {deleted} expired refresh token(s)")
return deleted
async def store_oauth_client(
self,
client_id: str,
client_secret: str,
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
registration_access_token: Optional[str] = None,
registration_client_uri: Optional[str] = None,
) -> None:
"""
Store encrypted OAuth client credentials.
Args:
client_id: OAuth client identifier
client_secret: OAuth client secret (will be encrypted)
client_id_issued_at: Unix timestamp when client was issued
client_secret_expires_at: Unix timestamp when secret expires
redirect_uris: List of redirect URIs
registration_access_token: RFC 7592 registration token (will be encrypted)
registration_client_uri: RFC 7592 client management URI
"""
if not self._initialized:
await self.initialize()
# Encrypt sensitive data
encrypted_secret = self.cipher.encrypt(client_secret.encode())
encrypted_reg_token = (
self.cipher.encrypt(registration_access_token.encode())
if registration_access_token
else None
)
# Serialize redirect_uris as JSON
redirect_uris_json = json.dumps(redirect_uris)
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO oauth_clients
(id, client_id, encrypted_client_secret, client_id_issued_at,
client_secret_expires_at, redirect_uris, encrypted_registration_access_token,
registration_client_uri, created_at, updated_at)
VALUES (
1, ?, ?, ?, ?, ?, ?, ?,
COALESCE((SELECT created_at FROM oauth_clients WHERE id = 1), ?),
?
)
""",
(
client_id,
encrypted_secret,
client_id_issued_at,
client_secret_expires_at,
redirect_uris_json,
encrypted_reg_token,
registration_client_uri,
now,
now,
),
)
await db.commit()
logger.info(
f"Stored OAuth client credentials (client_id: {client_id[:16]}..., "
f"expires at {client_secret_expires_at})"
)
# Audit log
await self._audit_log(
event="store_oauth_client",
user_id="system",
auth_method="oauth",
)
async def get_oauth_client(self) -> Optional[dict]:
"""
Retrieve and decrypt OAuth client credentials.
Returns:
Dictionary with client credentials, or None if not found or expired:
{
"client_id": str,
"client_secret": str,
"client_id_issued_at": int,
"client_secret_expires_at": int,
"redirect_uris": list[str],
"registration_access_token": str | None,
"registration_client_uri": str | None,
}
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT client_id, encrypted_client_secret, client_id_issued_at,
client_secret_expires_at, redirect_uris,
encrypted_registration_access_token, registration_client_uri
FROM oauth_clients WHERE id = 1
"""
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug("No OAuth client credentials found in storage")
return None
(
client_id,
encrypted_secret,
issued_at,
expires_at,
redirect_uris_json,
encrypted_reg_token,
reg_client_uri,
) = row
# Check expiration
if expires_at < time.time():
logger.warning(
f"OAuth client has expired (expired at {expires_at}), deleting"
)
await self.delete_oauth_client()
return None
try:
# Decrypt sensitive data
client_secret = self.cipher.decrypt(encrypted_secret).decode()
reg_token = (
self.cipher.decrypt(encrypted_reg_token).decode()
if encrypted_reg_token
else None
)
# Deserialize redirect_uris
redirect_uris = json.loads(redirect_uris_json)
logger.debug(
f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)"
)
return {
"client_id": client_id,
"client_secret": client_secret,
"client_id_issued_at": issued_at,
"client_secret_expires_at": expires_at,
"redirect_uris": redirect_uris,
"registration_access_token": reg_token,
"registration_client_uri": reg_client_uri,
}
except Exception as e:
logger.error(f"Failed to decrypt OAuth client credentials: {e}")
return None
async def delete_oauth_client(self) -> bool:
"""
Delete OAuth client credentials.
Returns:
True if client was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute("DELETE FROM oauth_clients WHERE id = 1")
await db.commit()
deleted = cursor.rowcount > 0
if deleted:
logger.info("Deleted OAuth client credentials from storage")
await self._audit_log(
event="delete_oauth_client",
user_id="system",
auth_method="oauth",
)
else:
logger.debug("No OAuth client credentials to delete")
return deleted
async def has_oauth_client(self) -> bool:
"""
Check if OAuth client credentials exist (and are not expired).
Returns:
True if valid client exists, False otherwise
"""
if not self._initialized:
await self.initialize()
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT client_secret_expires_at FROM oauth_clients WHERE id = 1"
) as cursor:
row = await cursor.fetchone()
if not row:
return False
expires_at = row[0]
return expires_at >= time.time()
async def _audit_log(
self,
event: str,
user_id: str,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
auth_method: Optional[str] = None,
) -> None:
"""
Log operation to audit log.
Args:
event: Event name (e.g., "store_refresh_token", "token_refresh")
user_id: User identifier
resource_type: Resource type (e.g., "note", "file")
resource_id: Resource identifier
auth_method: Authentication method used
"""
import socket
hostname = socket.gethostname()
timestamp = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT INTO audit_logs
(timestamp, event, user_id, resource_type, resource_id, auth_method, hostname)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
timestamp,
event,
user_id,
resource_type,
resource_id,
auth_method,
hostname,
),
)
await db.commit()
async def get_audit_logs(
self,
user_id: Optional[str] = None,
since: Optional[int] = None,
limit: int = 100,
) -> list[dict]:
"""
Retrieve audit logs.
Args:
user_id: Filter by user ID (optional)
since: Filter by timestamp (Unix epoch, optional)
limit: Maximum number of logs to return
Returns:
List of audit log entries
"""
if not self._initialized:
await self.initialize()
query = "SELECT * FROM audit_logs WHERE 1=1"
params = []
if user_id:
query += " AND user_id = ?"
params.append(user_id)
if since:
query += " AND timestamp >= ?"
params.append(since)
query += " ORDER BY timestamp DESC LIMIT ?"
params.append(limit)
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute(query, params) as cursor:
rows = await cursor.fetchall()
return [dict(row) for row in rows]
async def generate_encryption_key() -> str:
"""
Generate a new Fernet encryption key.
Returns:
Base64-encoded encryption key suitable for TOKEN_ENCRYPTION_KEY env var
"""
return Fernet.generate_key().decode()
# Example usage
if __name__ == "__main__":
import asyncio
async def main():
# Generate a key for testing
key = await generate_encryption_key()
print(f"Generated encryption key: {key}")
print(f"Set this in your environment: export TOKEN_ENCRYPTION_KEY='{key}'")
asyncio.run(main())
+4 -13
View File
@@ -168,36 +168,27 @@ class NextcloudTokenVerifier(TokenVerifier):
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
# Verify and decode JWT
# Accept tokens with audience: "mcp-server" or ["mcp-server", "nextcloud"]
# This allows:
# 1. Tokens from MCP clients (aud: "mcp-server")
# 2. Tokens for Nextcloud APIs (aud: "nextcloud")
# 3. Tokens for both (aud: ["mcp-server", "nextcloud"])
payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=self.issuer,
audience=["mcp-server", "nextcloud"], # Accept either audience
options={
"verify_signature": True,
"verify_exp": True,
"verify_iat": True,
"verify_iss": True if self.issuer else False,
"verify_aud": True, # Enable audience validation
"verify_aud": False, # Skip audience validation for Bearer tokens
},
)
logger.debug(f"JWT verified successfully for user: {payload.get('sub')}")
logger.debug(f"Full JWT payload: {payload}")
# Extract username (sub claim, with fallback to preferred_username)
# Some OIDC providers (like Keycloak) may not include sub in access tokens
username = payload.get("sub") or payload.get("preferred_username")
# Extract username (sub claim)
username = payload.get("sub")
if not username:
logger.error(
"No 'sub' or 'preferred_username' claim found in JWT payload"
)
logger.error("No 'sub' claim found in JWT payload")
return None
# Extract scopes from scope claim (space-separated string)
+2 -4
View File
@@ -1,6 +1,6 @@
[project]
name = "nextcloud-mcp-server"
version = "0.23.0"
version = "0.22.7"
description = "Model Context Protocol (MCP) server for Nextcloud integration - enables AI assistants to interact with Nextcloud data"
authors = [
{name = "Chris Coutinho", email = "chris@coutinho.io"}
@@ -18,8 +18,7 @@ dependencies = [
"pydantic>=2.11.4",
"click>=8.1.8",
"caldav",
"pyjwt[crypto]>=2.8.0",
"aiosqlite>=0.20.0", # Async SQLite for refresh token storage
"pyjwt[crypto]>=2.8.0", # Async I/O library for better compatibility
]
classifiers = [
"Development Status :: 4 - Beta",
@@ -51,7 +50,6 @@ markers = [
"integration: Integration tests requiring Docker containers",
"oauth: OAuth tests requiring Playwright (slowest)",
"smoke: Critical path smoke tests for quick validation",
"keycloak: OAuth tests that utilize keycloak external identity provider",
]
testpaths = [
"tests",
-90
View File
@@ -1,90 +0,0 @@
#!/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
-430
View File
@@ -2456,433 +2456,3 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group):
logger.debug(f"Added user {user_config['userid']} to group {groupid}")
yield (user_config, groupid)
# ===========================================================================================
# Keycloak External IdP OAuth Fixtures
# ===========================================================================================
@pytest.fixture(scope="session")
async def keycloak_oauth_client_credentials(anyio_backend, oauth_callback_server):
"""
Fixture to obtain Keycloak OAuth client credentials for external IdP testing.
Uses pre-configured client from keycloak/realm-export.json (no DCR needed).
The client (nextcloud-mcp-server) is already configured with:
- serviceAccountsEnabled=true
- token.exchange.grant.enabled=true
- client.token.exchange.standard.enabled=true
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
# Get Keycloak configuration from environment
keycloak_discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
)
client_id = os.getenv("OIDC_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv("OIDC_CLIENT_SECRET", "mcp-secret-change-in-production")
if not all([keycloak_discovery_url, client_id, client_secret]):
pytest.skip(
"Keycloak OAuth requires OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET"
)
# Get callback URL from the real callback server
auth_states, callback_url = oauth_callback_server
logger.info("Setting up Keycloak external IdP OAuth client credentials...")
logger.info(f"Using Keycloak discovery URL: {keycloak_discovery_url}")
logger.info(f"Using static client credentials: {client_id}")
logger.info(f"Using real callback server at: {callback_url}")
async with httpx.AsyncClient(timeout=30.0) as http_client:
# OIDC Discovery
discovery_response = await http_client.get(keycloak_discovery_url)
discovery_response.raise_for_status()
oidc_config = discovery_response.json()
token_endpoint = oidc_config.get("token_endpoint")
authorization_endpoint = oidc_config.get("authorization_endpoint")
if not token_endpoint or not authorization_endpoint:
raise ValueError(
"Keycloak OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)"
)
logger.info(f"✓ Discovered token endpoint: {token_endpoint}")
logger.info(f"✓ Discovered authorization endpoint: {authorization_endpoint}")
yield (
client_id,
client_secret,
callback_url,
token_endpoint,
authorization_endpoint,
)
# No cleanup needed - client is pre-configured in realm export
async def _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes: str,
username: str = "admin",
password: str = "admin",
) -> str:
"""
Helper function to obtain OAuth token from Keycloak using Playwright.
Args:
browser: Playwright browser instance
keycloak_oauth_client_credentials: Tuple of Keycloak OAuth client credentials
oauth_callback_server: OAuth callback server fixture
scopes: Space-separated list of scopes
username: Keycloak username (default: admin)
password: Keycloak password (default: admin)
Returns:
OAuth access token string from Keycloak
"""
import base64
import hashlib
import secrets
import time
from urllib.parse import quote
# Get auth_states dict from callback server
auth_states, _ = oauth_callback_server
# Unpack Keycloak client credentials
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
keycloak_oauth_client_credentials
)
logger.info(f"Starting Playwright-based Keycloak OAuth flow with scopes: {scopes}")
logger.info(f"Using Keycloak client: {client_id}")
logger.info(f"Using real callback server at: {callback_url}")
logger.info(f"Authenticating as Keycloak user: {username}")
# Generate unique state parameter for this OAuth flow
state = secrets.token_urlsafe(32)
logger.debug(f"Generated state: {state[:16]}...")
# Generate PKCE parameters (required by Keycloak client configuration)
code_verifier = secrets.token_urlsafe(64) # 86 chars base64url
code_challenge = (
base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest())
.decode()
.rstrip("=")
)
logger.debug(f"Generated PKCE code_challenge: {code_challenge[:20]}...")
# URL-encode scopes
scopes_encoded = quote(scopes, safe="")
# Construct authorization URL with state, scopes, and PKCE parameters
auth_url = (
f"{authorization_endpoint}?"
f"response_type=code&"
f"client_id={client_id}&"
f"redirect_uri={quote(callback_url, safe='')}&"
f"state={state}&"
f"scope={scopes_encoded}&"
f"code_challenge={code_challenge}&"
f"code_challenge_method=S256"
)
logger.info(f"Authorization URL: {auth_url[:100]}...")
# Create browser context and page
context = await browser.new_context()
page = await context.new_page()
try:
# Navigate to Keycloak authorization endpoint
logger.info("Navigating to Keycloak authorization endpoint...")
await page.goto(auth_url, wait_until="networkidle", timeout=30000)
# Handle Keycloak login page
# Keycloak uses input#username and input#password (different from Nextcloud)
logger.info(f"Filling Keycloak login credentials for {username}...")
await page.wait_for_selector("input#username", timeout=10000)
await page.fill("input#username", username)
await page.fill("input#password", password)
logger.info("Submitting Keycloak login form...")
# Submit the form and wait for navigation
# Use JavaScript to submit the form directly (more reliable than clicking button)
async with page.expect_navigation(timeout=30000):
await page.evaluate("document.querySelector('form').submit()")
logger.info(f"Keycloak login submitted for {username}, redirected to callback")
# Check if we need to handle consent screen
# Keycloak consent screen has "Yes" button
consent_button = page.locator('input[name="accept"][value="Yes"]')
if await consent_button.count() > 0:
logger.info("Keycloak consent screen detected, clicking Yes...")
await consent_button.click()
await page.wait_for_load_state("networkidle", timeout=30000)
logger.info("Keycloak consent granted")
# Wait for callback server to receive auth code with timeout
logger.info(f"Waiting for auth code with state: {state[:16]}...")
timeout = 30 # seconds
start_time = time.time()
auth_code = None
while time.time() - start_time < timeout:
if state in auth_states:
auth_code = auth_states[state]
logger.info("Auth code received from callback server")
break
await anyio.sleep(0.1)
else:
raise TimeoutError(
f"Auth code not received within {timeout}s. State: {state[:16]}..."
)
finally:
await context.close()
# Exchange authorization code for access token (with PKCE code_verifier)
logger.info("Exchanging authorization code for access token with PKCE...")
async with httpx.AsyncClient(timeout=30.0) as token_client:
token_response = await token_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": callback_url,
"client_id": client_id,
"client_secret": client_secret,
"code_verifier": code_verifier, # PKCE verifier
},
)
token_response.raise_for_status()
token_data = token_response.json()
access_token = token_data.get("access_token")
if not access_token:
raise ValueError(f"No access_token in response: {token_data}")
logger.info(
f"Successfully obtained Keycloak OAuth access token with scopes: {scopes}"
)
return access_token
@pytest.fixture(scope="session")
async def keycloak_oauth_token(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain an OAuth access token from Keycloak using Playwright automation.
This fixture tests the external IdP flow where:
1. User authenticates with Keycloak (external IdP)
2. Keycloak issues an access token with Nextcloud custom scopes
3. Token is used to access Nextcloud APIs via user_oidc app validation
The Nextcloud custom scopes (notes:read, calendar:write, etc.) are now defined
in Keycloak's realm configuration and can be requested in the OAuth flow.
Returns:
OAuth access token from Keycloak for the admin user with full scopes
"""
# Standard OIDC scopes + Nextcloud custom scopes (now defined in Keycloak realm)
default_scopes = "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write"
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes=default_scopes,
username="admin",
password="admin",
)
@pytest.fixture(scope="session")
async def keycloak_oauth_token_read_only(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain a Keycloak OAuth token with only read scopes.
This token will only be able to perform read operations and should
have write tools filtered out from the tool list.
Returns:
OAuth access token from Keycloak for test_read_only user with read-only scopes
"""
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes=DEFAULT_READ_SCOPES,
username="test_read_only",
password="test123",
)
@pytest.fixture(scope="session")
async def keycloak_oauth_token_write_only(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain a Keycloak OAuth token with only write scopes.
This token will only be able to perform write operations and should
have read tools filtered out from the tool list.
Returns:
OAuth access token from Keycloak for test_write_only user with write-only scopes
"""
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes=DEFAULT_WRITE_SCOPES,
username="test_write_only",
password="test123",
)
@pytest.fixture(scope="session")
async def keycloak_oauth_token_no_custom_scopes(
anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server
) -> str:
"""
Fixture to obtain a Keycloak OAuth token with NO custom scopes.
Tests the security behavior when a user grants only default OIDC scopes
(openid, profile, email) but declines application-specific scopes.
Expected behavior: Should see 0 tools (all tools require custom scopes).
Returns:
OAuth access token from Keycloak for test_no_scopes user with no custom scopes
"""
return await _get_keycloak_oauth_token(
browser,
keycloak_oauth_client_credentials,
oauth_callback_server,
scopes="openid profile email", # No custom scopes
username="test_no_scopes",
password="test123",
)
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client(
anyio_backend, keycloak_oauth_token
) -> AsyncGenerator[ClientSession, Any]:
"""
Session-scoped fixture providing an MCP client session authenticated with Keycloak tokens.
This MCP client connects to the mcp-keycloak service (port 8002) which is configured
to use Keycloak as an external identity provider. The token flow is:
1. Keycloak issues OAuth token (via keycloak_oauth_token fixture)
2. MCP client uses token to authenticate with MCP server
3. MCP server validates token via Nextcloud user_oidc app
4. MCP server uses validated token to access Nextcloud APIs
This tests ADR-002 external IdP integration.
Yields:
MCP client session for testing tools/resources with Keycloak auth
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(f"Creating MCP client session for Keycloak external IdP at {mcp_url}")
logger.info("Using Keycloak OAuth token for authentication")
async for session in create_mcp_client_session(
url=mcp_url, token=keycloak_oauth_token, client_name="Keycloak External IdP MCP"
):
logger.info("✓ MCP client session established with Keycloak authentication")
yield session
logger.info("✓ MCP client session closed")
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client_read_only(
anyio_backend, keycloak_oauth_token_read_only
) -> AsyncGenerator[ClientSession, Any]:
"""
MCP client session authenticated with Keycloak read-only token.
This client should only see read tools and should get filtered
write tools based on token scopes.
Uses JWT tokens because they embed scope information in claims,
enabling proper scope-based tool filtering.
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(f"Creating read-only MCP client session for Keycloak at {mcp_url}")
async for session in create_mcp_client_session(
url=mcp_url,
token=keycloak_oauth_token_read_only,
client_name="Keycloak Read-Only MCP",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client_write_only(
anyio_backend, keycloak_oauth_token_write_only
) -> AsyncGenerator[ClientSession, Any]:
"""
MCP client session authenticated with Keycloak write-only token.
This client should only see write tools and should get filtered
read tools based on token scopes.
Uses JWT tokens because they embed scope information in claims,
enabling proper scope-based tool filtering.
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(f"Creating write-only MCP client session for Keycloak at {mcp_url}")
async for session in create_mcp_client_session(
url=mcp_url,
token=keycloak_oauth_token_write_only,
client_name="Keycloak Write-Only MCP",
):
yield session
@pytest.fixture(scope="session")
async def nc_mcp_keycloak_client_no_custom_scopes(
anyio_backend, keycloak_oauth_token_no_custom_scopes
) -> AsyncGenerator[ClientSession, Any]:
"""
MCP client session authenticated with Keycloak token without custom scopes.
This client has only OIDC default scopes (openid, profile, email) without
application-specific scopes (notes:read, notes:write, etc.).
Expected behavior: Should see 0 tools (all tools require custom scopes).
Uses JWT tokens because they embed scope information in claims,
enabling proper scope-based tool filtering.
"""
mcp_url = "http://localhost:8002/mcp"
logger.info(
f"Creating no-custom-scopes MCP client session for Keycloak at {mcp_url}"
)
async for session in create_mcp_client_session(
url=mcp_url,
token=keycloak_oauth_token_no_custom_scopes,
client_name="Keycloak No Custom Scopes MCP",
):
yield session
@@ -1,308 +0,0 @@
"""
Integration test for RFC 8693 Token Exchange - Legacy V1 (Impersonation/Tier 1).
Tests the advanced impersonation feature where the service account token is
exchanged for a token with the target user's identity (sub claim changes).
This requires:
1. Keycloak with --features=preview enabled
2. Impersonation role granted to the service account
⚠️ This test will SKIP if impersonation permissions are not configured.
Configuration (one-time setup):
# Grant impersonation role
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \\
--server http://localhost:8080 \\
--realm master \\
--user admin \\
--password admin
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \\
-r nextcloud-mcp \\
--uusername service-account-nextcloud-mcp-server \\
--cclientid realm-management \\
--rolename impersonation
Usage:
pytest tests/integration/auth/test_token_exchange_legacy_v1.py -v
"""
import base64
import json
import os
import httpx
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification."""
try:
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
@pytest.fixture
def keycloak_config():
"""Keycloak configuration for testing."""
return {
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
"client_secret": os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
),
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
}
@pytest.fixture
async def service_account_token(keycloak_config):
"""Get a service account token using client_credentials grant."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "client_credentials",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"scope": "openid profile email",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def test_token_exchange_impersonation_requires_permissions(
keycloak_config, service_account_token
):
"""Test that impersonation requires explicit permission grant.
This test documents that Legacy V1 impersonation is opt-in and requires
administrative configuration via Keycloak CLI.
"""
target_user = "admin" # User to impersonate
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user, # ← KEY: Request impersonation
},
)
# If permissions not granted, we expect 403 Forbidden
if exchange_response.status_code == 403:
pytest.skip(
"Impersonation permissions not configured. "
"Run tests/manual/configure_impersonation.py or grant manually via Keycloak CLI. "
"See test docstring for configuration commands."
)
# If permissions are granted, exchange should succeed
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
async def test_token_exchange_impersonation_changes_subject(
keycloak_config, service_account_token
):
"""Test Legacy V1 impersonation - subject claim should change."""
target_user = "admin"
# Decode service account token
service_claims = decode_jwt(service_account_token)
assert "error" not in service_claims
service_sub = service_claims["sub"]
assert "service-account" in service_sub.lower()
# Exchange token WITH requested_subject (Legacy V1 impersonation)
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user, # ← KEY: Impersonate admin
},
)
# Skip if permissions not configured
if exchange_response.status_code == 403:
pytest.skip(
"Impersonation permissions not configured. "
"See test docstring for setup instructions."
)
# Token exchange should succeed with permissions
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
exchanged_token = exchanged_data["access_token"]
# Decode exchanged token
exchanged_claims = decode_jwt(exchanged_token)
assert "error" not in exchanged_claims
exchanged_sub = exchanged_claims["sub"]
# CRITICAL: Verify impersonation - sub claim MUST change
assert service_sub != exchanged_sub, (
f"Impersonation should change subject claim. "
f"Original: {service_sub}, Exchanged: {exchanged_sub}"
)
# Verify the new token represents the target user
assert "preferred_username" in exchanged_claims
assert exchanged_claims["preferred_username"] == target_user
async def test_impersonated_token_with_nextcloud(
keycloak_config, service_account_token
):
"""Test that impersonated token works with Nextcloud APIs."""
target_user = "admin"
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Exchange token with impersonation
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user,
},
)
# Skip if permissions not configured
if exchange_response.status_code == 403:
pytest.skip("Impersonation permissions not configured.")
exchange_response.raise_for_status()
exchanged_token = exchange_response.json()["access_token"]
# Test with Nextcloud API
nc_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"Authorization": f"Bearer {exchanged_token}"},
)
# Should get valid response from Nextcloud
assert nc_response.status_code in [
200,
401,
], f"Unexpected status: {nc_response.status_code}"
if nc_response.status_code == 200:
# Token was accepted - verify we got a valid response
# Nextcloud OCS API can return XML or JSON
assert len(nc_response.content) > 0, "Response should not be empty"
content_type = nc_response.headers.get("content-type", "")
assert any(t in content_type for t in ["json", "xml"]), (
f"Unexpected content type: {content_type}"
)
async def test_standard_v2_rejects_requested_subject():
"""Verify that Standard V2 (without preview features) rejects requested_subject.
This test documents the key difference between Standard V2 and Legacy V1.
NOTE: This test will PASS if preview features are enabled, as Keycloak
accepts the parameter in Legacy V1 mode. The test exists to document the
expected behavior when preview features are DISABLED.
"""
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
async with httpx.AsyncClient(timeout=30.0) as client:
# Get service account token
token_response = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "openid profile email",
},
)
token_response.raise_for_status()
service_token = token_response.json()["access_token"]
# Try token exchange with requested_subject
exchange_response = await client.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"client_secret": client_secret,
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": "admin", # Try to impersonate
},
)
# Standard V2: expects 400 Bad Request with "not supported" message
# Legacy V1: accepts parameter, returns 200 or 403 (depending on permissions)
if exchange_response.status_code == 400:
# Standard V2 behavior
error_data = exchange_response.json()
assert (
"requested_subject" in error_data.get("error_description", "").lower()
)
# Test passes - Standard V2 correctly rejects the parameter
elif exchange_response.status_code in [200, 403]:
# Legacy V1 behavior - parameter is accepted
pytest.skip(
"Preview features enabled - Keycloak is in Legacy V1 mode. "
"This test documents Standard V2 behavior which rejects requested_subject."
)
else:
pytest.fail(
f"Unexpected status code: {exchange_response.status_code}. "
f"Expected 400 (Standard V2) or 200/403 (Legacy V1)"
)
@@ -1,222 +0,0 @@
"""
Integration test for RFC 8693 Token Exchange - Standard V2 (Delegation/Tier 2).
Tests the production-ready token exchange without impersonation.
The service account exchanges its token for a user-scoped token while
maintaining its own identity (sub claim unchanged).
This is the RECOMMENDED approach for most use cases.
Requirements:
- Keycloak container running (can be Standard V2 or Legacy V1)
- MCP Keycloak service running on port 8002
Usage:
pytest tests/integration/auth/test_token_exchange_standard_v2.py -v
"""
import base64
import json
import os
import httpx
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification."""
try:
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
@pytest.fixture
def keycloak_config():
"""Keycloak configuration for testing."""
return {
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
"client_secret": os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
),
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
}
@pytest.fixture
async def service_account_token(keycloak_config):
"""Get a service account token using client_credentials grant."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "client_credentials",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"scope": "openid profile email",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def test_token_exchange_delegation(keycloak_config, service_account_token):
"""Test Standard V2 token exchange with delegation (no impersonation)."""
# Decode service account token to get original claims
service_claims = decode_jwt(service_account_token)
assert "error" not in service_claims, "Failed to decode service account token"
assert "sub" in service_claims
service_sub = service_claims["sub"]
# Exchange token WITHOUT requested_subject (Standard V2 delegation)
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
# NOTE: NO requested_subject parameter - this is delegation, not impersonation
},
)
# Token exchange should succeed
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
assert "token_type" in exchanged_data
assert exchanged_data["token_type"].lower() == "bearer"
exchanged_token = exchanged_data["access_token"]
# Decode exchanged token
exchanged_claims = decode_jwt(exchanged_token)
assert "error" not in exchanged_claims, "Failed to decode exchanged token"
assert "sub" in exchanged_claims
exchanged_sub = exchanged_claims["sub"]
# CRITICAL: Verify delegation behavior - sub claim should NOT change
assert service_sub == exchanged_sub, (
f"Subject should remain unchanged in delegation (service account identity preserved). Original: {service_sub}, Exchanged: {exchanged_sub}"
)
# The exchanged token should still identify as the service account
assert "service-account" in exchanged_sub.lower(), (
"Exchanged token should maintain service account identity"
)
async def test_exchanged_token_with_nextcloud(keycloak_config, service_account_token):
"""Test that exchanged token works with Nextcloud APIs."""
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Exchange the service account token
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
)
exchange_response.raise_for_status()
exchanged_token = exchange_response.json()["access_token"]
# Test the exchanged token with Nextcloud API
nc_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"Authorization": f"Bearer {exchanged_token}"},
)
# Should get a valid response from Nextcloud
# Note: This might fail with 401 if user_oidc doesn't accept the token
# That's expected - this test verifies the token exchange itself works
assert nc_response.status_code in [
200,
401,
], f"Unexpected status: {nc_response.status_code}"
if nc_response.status_code == 200:
# Token was accepted - verify we got a valid response
# Nextcloud OCS API can return XML or JSON
assert len(nc_response.content) > 0, "Response should not be empty"
# Verify we got either JSON or XML capabilities response
content_type = nc_response.headers.get("content-type", "")
assert any(t in content_type for t in ["json", "xml"]), (
f"Unexpected content type: {content_type}"
)
async def test_token_exchange_without_permissions_should_work():
"""Verify Standard V2 doesn't require special permissions (unlike Legacy V1 impersonation)."""
# This test documents that Standard V2 token exchange works out-of-the-box
# without needing to grant impersonation roles via Keycloak CLI
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
async with httpx.AsyncClient(timeout=30.0) as client:
# Get service account token
token_response = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "openid profile email",
},
)
token_response.raise_for_status()
service_token = token_response.json()["access_token"]
# Exchange token - should work without any special role grants
exchange_response = await client.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"client_secret": client_secret,
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
)
# Should succeed without 403 Forbidden (no permission requirements)
assert exchange_response.status_code == 200, (
f"Standard V2 delegation should work without special permissions. "
f"Got: {exchange_response.status_code} {exchange_response.text}"
)
+5 -10
View File
@@ -27,8 +27,7 @@ import click
import httpx
from playwright.async_api import async_playwright
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.client_registration import load_or_register_client
from nextcloud_mcp_server.client import NextcloudClient
from tests.load.oauth_metrics import OAuthBenchmarkMetrics
from tests.load.oauth_pool import (
@@ -143,7 +142,7 @@ async def setup_oauth_client(
nextcloud_host: str, callback_url: str, registration_endpoint: str
) -> dict[str, str]:
"""
Setup OAuth client using ensure_oauth_client with SQLite storage.
Setup OAuth client using load_or_register_client.
Args:
nextcloud_host: Nextcloud host URL
@@ -155,15 +154,11 @@ async def setup_oauth_client(
"""
logger.info("Setting up OAuth client...")
# Initialize SQLite storage
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Use the client registration utility with SQLite storage
client_info = await ensure_oauth_client(
# Use the client registration utility
client_info = await load_or_register_client(
nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint,
storage=storage,
storage_path=".nextcloud_oauth_benchmark_client.json",
client_name="OAuth Benchmark Test Client",
redirect_uris=[callback_url],
)
-195
View File
@@ -1,195 +0,0 @@
"""
Configure Keycloak client for token exchange with impersonation.
This script uses Keycloak Admin API to configure the necessary permissions
for the nextcloud-mcp-server client to impersonate users via token exchange.
Usage:
uv run python tests/manual/configure_impersonation.py
"""
import asyncio
import logging
import os
import sys
import httpx
logging.basicConfig(level=logging.INFO, format="%(levelname)-8s | %(message)s")
logger = logging.getLogger(__name__)
async def main():
"""Configure impersonation permissions in Keycloak"""
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
admin_username = "admin"
admin_password = "admin"
client_id = "nextcloud-mcp-server"
logger.info("=" * 80)
logger.info("Configuring Keycloak Impersonation Permissions")
logger.info("=" * 80)
logger.info(f"Keycloak URL: {keycloak_url}")
logger.info(f"Realm: {realm}")
logger.info(f"Client ID: {client_id}")
logger.info("")
async with httpx.AsyncClient(timeout=30.0) as client:
# Step 1: Get admin access token
logger.info("Step 1: Getting admin access token...")
token_response = await client.post(
f"{keycloak_url}/realms/master/protocol/openid-connect/token",
data={
"grant_type": "password",
"client_id": "admin-cli",
"username": admin_username,
"password": admin_password,
},
)
token_response.raise_for_status()
admin_token = token_response.json()["access_token"]
logger.info("✓ Admin token acquired")
logger.info("")
headers = {"Authorization": f"Bearer {admin_token}"}
# Step 2: Get client internal ID
logger.info("Step 2: Looking up client internal ID...")
clients_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/clients",
headers=headers,
params={"clientId": client_id},
)
clients_response.raise_for_status()
clients = clients_response.json()
if not clients:
logger.error(f"❌ Client '{client_id}' not found")
return 1
client_uuid = clients[0]["id"]
logger.info(f"✓ Found client UUID: {client_uuid}")
logger.info("")
# Step 3: Enable token exchange permission
logger.info("Step 3: Configuring token exchange permissions...")
# Get all clients (we need to allow exchange from/to any client)
all_clients_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/clients",
headers=headers,
)
all_clients_response.raise_for_status()
all_clients = all_clients_response.json()
# Get all users (we need to allow impersonation of any user)
users_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/users",
headers=headers,
)
users_response.raise_for_status()
users = users_response.json()
logger.info(f" Found {len(all_clients)} clients and {len(users)} users")
logger.info("")
# Step 4: Enable permission for client to perform token exchange
logger.info("Step 4: Enabling token exchange permission...")
# Update client to enable fine-grained permissions
update_response = await client.put(
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
headers=headers,
json={
**clients[0],
"authorizationServicesEnabled": False, # Don't need full authz
"serviceAccountsEnabled": True, # Already enabled
},
)
if update_response.status_code in [200, 204]:
logger.info("✓ Client configuration updated")
else:
logger.warning(f"⚠ Client update returned {update_response.status_code}")
logger.info("")
# Step 5: Set up token exchange permission policy
logger.info("Step 5: Configuring impersonation policy...")
# In Keycloak Legacy V1, we need to use the token-exchange permissions endpoint
# This is part of the preview features
# First, check if token exchange permissions endpoint exists
try:
perms_response = await client.get(
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}/token-exchange/permissions",
headers=headers,
)
if perms_response.status_code == 200:
logger.info("✓ Token exchange permissions endpoint available")
permissions = perms_response.json()
logger.info(f" Current permissions: {permissions}")
logger.info("")
# Enable impersonation for all users
logger.info("Step 6: Enabling impersonation for admin user...")
# Find admin user
admin_user = next((u for u in users if u["username"] == "admin"), None)
if admin_user:
# Enable permission for this client to impersonate admin
enable_response = await client.put(
f"{keycloak_url}/admin/realms/{realm}/users/{admin_user['id']}/impersonation",
headers=headers,
json={
"client": client_uuid,
"enabled": True,
},
)
if enable_response.status_code in [200, 204]:
logger.info("✓ Impersonation enabled for admin user")
else:
logger.warning(
f"⚠ Impersonation enable returned {enable_response.status_code}"
)
logger.info(f" Response: {enable_response.text}")
else:
logger.error("❌ Admin user not found")
elif perms_response.status_code == 404:
logger.warning("⚠ Token exchange permissions endpoint not found")
logger.info(" This might mean preview features aren't fully enabled")
logger.info(" Or the Keycloak version doesn't support this API")
else:
logger.warning(f"⚠ Unexpected response: {perms_response.status_code}")
except Exception as e:
logger.error(f"❌ Error configuring permissions: {e}")
logger.info("")
logger.info("Alternative: Manual configuration required")
logger.info(" 1. Open Keycloak Admin Console")
logger.info(" 2. Go to Clients → nextcloud-mcp-server")
logger.info(" 3. Go to Permissions tab")
logger.info(" 4. Enable 'token-exchange' permission")
logger.info(" 5. Configure permission policies for impersonation")
logger.info("")
logger.info("=" * 80)
logger.info("Configuration Complete")
logger.info("=" * 80)
logger.info("")
logger.info("Next step: Run impersonation test")
logger.info(" uv run python tests/manual/test_impersonation.py")
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
-289
View File
@@ -1,289 +0,0 @@
"""
Manual test for RFC 8693 Token Exchange with USER IMPERSONATION.
This script tests whether Keycloak actually supports the requested_subject
parameter for user impersonation, as claimed in ADR-002 to be unsupported.
Test procedure:
1. Get service account token (client_credentials grant)
2. Attempt to exchange token WITH requested_subject parameter
3. Observe actual behavior (success or error)
4. Decode resulting token to verify sub claim
Usage:
# Start Keycloak and app containers
docker compose up -d keycloak app
# Run the test
uv run python tests/manual/test_impersonation.py
"""
import asyncio
import base64
import json
import logging
import os
import sys
# Add parent directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.client import NextcloudClient
# Setup logging
logging.basicConfig(
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
)
logger = logging.getLogger(__name__)
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification"""
try:
# Split token and get payload (second part)
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
# Decode payload (add padding if needed)
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
async def main():
"""Test token exchange with impersonation"""
# Configuration (matches docker-compose mcp-keycloak service)
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
redirect_uri = "http://localhost:8002/oauth/callback"
target_user = "admin" # User to impersonate
logger.info("=" * 80)
logger.info("RFC 8693 Token Exchange IMPERSONATION Test")
logger.info("=" * 80)
logger.info(f"Keycloak URL: {keycloak_url}")
logger.info(f"Realm: {realm}")
logger.info(f"Client ID: {client_id}")
logger.info(f"Target User: {target_user}")
logger.info(f"Nextcloud: {nextcloud_host}")
logger.info("")
logger.info("⚠️ This test attempts impersonation to verify ADR-002 claims")
logger.info("")
# Step 1: Create Keycloak OAuth client
logger.info("Step 1: Initializing Keycloak OAuth client...")
oauth_client = KeycloakOAuthClient(
keycloak_url=keycloak_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
# Discover endpoints
await oauth_client.discover()
logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}")
logger.info("")
# Step 2: Check token exchange support
logger.info("Step 2: Checking token exchange support...")
supported = await oauth_client.check_token_exchange_support()
if not supported:
logger.error("❌ Token exchange is NOT supported by this Keycloak instance")
logger.error(
" You may need to enable it with: --features=preview --features=token-exchange"
)
return 1
logger.info("✓ Token exchange is supported")
logger.info("")
# Step 3: Get service account token
logger.info("Step 3: Requesting service account token (client_credentials)...")
try:
service_token_response = await oauth_client.get_service_account_token(
scopes=["openid", "profile", "email"]
)
service_token = service_token_response["access_token"]
logger.info("✓ Service account token acquired")
# Decode and show claims
service_claims = decode_jwt(service_token)
logger.info(f" Subject (sub): {service_claims.get('sub')}")
logger.info(f" Preferred username: {service_claims.get('preferred_username')}")
logger.info(f" Client ID (azp): {service_claims.get('azp')}")
except Exception as e:
logger.error(f"❌ Failed to get service account token: {e}")
return 1
logger.info("")
# Step 4: Attempt token exchange WITH impersonation
logger.info(
f"Step 4: Attempting token exchange WITH impersonation (requested_subject={target_user})..."
)
logger.info(
" 🧪 This is the actual test - will Keycloak accept requested_subject?"
)
logger.info("")
try:
user_token_response = await oauth_client.exchange_token_for_user(
subject_token=service_token,
target_user_id=target_user, # ← THE KEY TEST: Request impersonation
audience=None,
scopes=["openid", "profile", "email"],
)
user_token = user_token_response["access_token"]
logger.info("✅ Token exchange with impersonation SUCCEEDED!")
logger.info("")
logger.info("📊 Response details:")
logger.info(
f" Issued token type: {user_token_response.get('issued_token_type')}"
)
logger.info(f" Token type: {user_token_response.get('token_type')}")
logger.info(f" Expires in: {user_token_response.get('expires_in')}s")
logger.info("")
# Decode and analyze the exchanged token
user_claims = decode_jwt(user_token)
logger.info("📋 Token claims analysis:")
logger.info(f" Subject (sub): {user_claims.get('sub')}")
logger.info(f" Preferred username: {user_claims.get('preferred_username')}")
logger.info(f" Client ID (azp): {user_claims.get('azp')}")
logger.info(f" Audience (aud): {user_claims.get('aud')}")
logger.info("")
# Verify if impersonation actually worked
service_sub = service_claims.get("sub")
user_sub = user_claims.get("sub")
if service_sub != user_sub:
logger.info("✅ IMPERSONATION VERIFIED:")
logger.info(f" Original sub: {service_sub}")
logger.info(f" New sub: {user_sub}")
logger.info("")
logger.info(" ➡️ The subject claim CHANGED - impersonation worked!")
impersonation_worked = True
else:
logger.warning("⚠️ IMPERSONATION DID NOT OCCUR:")
logger.warning(f" Subject unchanged: {user_sub}")
logger.warning("")
logger.warning(" ➡️ Token exchange succeeded but sub claim is the same")
logger.warning(
" This is delegation/audience change, not impersonation"
)
impersonation_worked = False
except Exception as e:
logger.error("❌ Token exchange with impersonation FAILED!")
logger.error(f" Error: {e}")
logger.error("")
logger.error("📋 Error analysis:")
# Try to extract detailed error message
error_str = str(e)
if "requested_subject" in error_str.lower():
logger.error(
" ➡️ Error mentions 'requested_subject' - parameter not supported"
)
elif "impersonation" in error_str.lower():
logger.error(" ➡️ Error mentions 'impersonation' - feature not enabled")
elif "permission" in error_str.lower():
logger.error(" ➡️ Error mentions 'permission' - client lacks permissions")
else:
logger.error(" ➡️ Generic error - check Keycloak logs for details")
logger.error("")
logger.error("💡 Possible causes:")
logger.error(" 1. Keycloak Standard V2 doesn't support requested_subject")
logger.error(" 2. Requires Legacy V1 with --features=preview")
logger.error(" 3. Client lacks impersonation permissions")
logger.error(" 4. Target user doesn't exist")
return 1
logger.info("")
# Step 5: Test impersonated token with Nextcloud API
if impersonation_worked:
logger.info("Step 5: Testing impersonated token with Nextcloud API...")
try:
# Create Nextcloud client with exchanged token
nc_client = NextcloudClient.from_token(
base_url=nextcloud_host, token=user_token, username=target_user
)
# Test API call
capabilities = await nc_client.capabilities()
logger.info("✓ Nextcloud API call successful with impersonated token")
logger.info(f" Version: {capabilities.get('version', {}).get('string')}")
await nc_client.close()
except Exception as e:
logger.error(f"❌ Nextcloud API call failed: {e}")
logger.error(" The impersonated token may not be valid for Nextcloud")
return 1
logger.info("")
logger.info("=" * 80)
logger.info("TEST RESULTS SUMMARY")
logger.info("=" * 80)
if impersonation_worked:
logger.info("✅ IMPERSONATION IS SUPPORTED!")
logger.info("")
logger.info("Key findings:")
logger.info(" • Token exchange with requested_subject WORKS")
logger.info(" • Subject claim successfully changed")
logger.info(" • Impersonated token works with Nextcloud APIs")
logger.info("")
logger.info("⚠️ ADR-002 DOCUMENTATION IS INCORRECT")
logger.info(" Current docs claim impersonation doesn't work in Standard V2")
logger.info(" This test proves it DOES work!")
logger.info("")
logger.info("Action items:")
logger.info(" 1. Update ADR-002 to mark Tier 1 as IMPLEMENTED")
logger.info(" 2. Remove 'NOT IMPLEMENTED' warnings from code")
logger.info(" 3. Add automated tests for impersonation")
logger.info(" 4. Update oauth-impersonation-findings.md")
else:
logger.info("❌ IMPERSONATION IS NOT SUPPORTED")
logger.info("")
logger.info("Key findings:")
logger.info(" • Token exchange with requested_subject FAILED")
logger.info(" • Keycloak rejected the parameter")
logger.info(" • Confirms ADR-002 documentation")
logger.info("")
logger.info("✅ ADR-002 DOCUMENTATION IS CORRECT")
logger.info(" Impersonation requires Keycloak Legacy V1")
logger.info("")
logger.info("Action items:")
logger.info(" 1. Add this test as evidence to ADR-002")
logger.info(" 2. Document exact error message")
logger.info(" 3. Add 'Verified by testing' note to docs")
logger.info("")
return 0 if impersonation_worked else 1
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
-227
View File
@@ -1,227 +0,0 @@
"""
Manual test for Nextcloud impersonate API.
This script tests using the Nextcloud impersonate app to allow
admin users to act on behalf of other users.
This is NOT the same as OAuth token exchange, but could serve
as a workaround for background operations.
Usage:
# Start app container
docker compose up -d app
# Run the test
uv run python tests/manual/test_nextcloud_impersonate.py
"""
import asyncio
import logging
import os
import sys
# Add parent directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
import httpx
# Setup logging
logging.basicConfig(
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
"""Test Nextcloud impersonate API"""
# Configuration
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
admin_user = os.getenv("NEXTCLOUD_USERNAME", "admin")
admin_password = os.getenv("NEXTCLOUD_PASSWORD", "admin")
target_user = "testuser" # We'll create this user
logger.info("=" * 80)
logger.info("Nextcloud Impersonate API Test")
logger.info("=" * 80)
logger.info(f"Nextcloud: {nextcloud_host}")
logger.info(f"Admin user: {admin_user}")
logger.info(f"Target user: {target_user}")
logger.info("")
async with httpx.AsyncClient() as client:
# Step 1: Login as admin and get session
logger.info("Step 1: Logging in as admin...")
login_response = await client.post(
f"{nextcloud_host}/login",
data={
"user": admin_user,
"password": admin_password,
},
follow_redirects=True,
)
if login_response.status_code != 200:
logger.error(f"❌ Admin login failed: {login_response.status_code}")
return 1
# Get requesttoken from response
requesttoken = None
for cookie in client.cookies.jar:
if cookie.name == "nc_session":
logger.info(f"✓ Admin logged in, session: {cookie.value[:20]}...")
break
logger.info("")
# Step 2: Create test user if doesn't exist
logger.info(f"Step 2: Creating test user '{target_user}'...")
create_user_response = await client.post(
f"{nextcloud_host}/ocs/v1.php/cloud/users",
auth=(admin_user, admin_password),
data={
"userid": target_user,
"password": "testpassword123",
},
headers={"OCS-APIRequest": "true"},
)
if create_user_response.status_code in (200, 400): # 400 if already exists
logger.info("✓ Test user ready")
else:
logger.warning(
f"User creation response: {create_user_response.status_code}"
)
# Make sure user has logged in at least once (requirement for impersonation)
logger.info(f" Performing initial login for {target_user}...")
await client.post(
f"{nextcloud_host}/login",
data={
"user": target_user,
"password": "testpassword123",
},
follow_redirects=True,
)
logger.info("✓ Test user has logged in")
# Re-login as admin
await client.post(
f"{nextcloud_host}/login",
data={
"user": admin_user,
"password": admin_password,
},
follow_redirects=True,
)
logger.info("")
# Step 3: Get CSRF token for impersonate request
logger.info("Step 3: Getting CSRF token...")
# Try to get token from settings page
settings_response = await client.get(
f"{nextcloud_host}/settings/users",
follow_redirects=True,
)
# Extract requesttoken from HTML
import re
token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text)
if token_match:
requesttoken = token_match.group(1)
logger.info(f"✓ CSRF token acquired: {requesttoken[:20]}...")
else:
logger.error("❌ Could not extract CSRF token from page")
return 1
logger.info("")
# Step 4: Call impersonate API
logger.info(f"Step 4: Impersonating user '{target_user}'...")
impersonate_response = await client.post(
f"{nextcloud_host}/apps/impersonate/user",
data={
"userId": target_user,
},
headers={
"requesttoken": requesttoken,
"Content-Type": "application/x-www-form-urlencoded",
},
)
if impersonate_response.status_code != 200:
logger.error(f"❌ Impersonate failed: {impersonate_response.status_code}")
logger.error(f"Response: {impersonate_response.text}")
return 1
logger.info("✓ Impersonation successful")
logger.info("")
# Step 5: Test API call as impersonated user
logger.info("Step 5: Testing API call as impersonated user...")
capabilities_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"OCS-APIRequest": "true"},
)
if capabilities_response.status_code == 200:
caps = capabilities_response.json()
logger.info(f"✓ API call successful as {target_user}")
logger.info(
f" Version: {caps.get('ocs', {}).get('data', {}).get('version', {}).get('string')}"
)
else:
logger.error(f"❌ API call failed: {capabilities_response.status_code}")
return 1
logger.info("")
# Step 6: Get current user to verify impersonation
logger.info("Step 6: Verifying current user...")
user_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/user",
headers={"OCS-APIRequest": "true"},
)
if user_response.status_code == 200:
user_data = user_response.json()
current_user = user_data.get("ocs", {}).get("data", {}).get("id")
logger.info(f"✓ Current user: {current_user}")
if current_user == target_user:
logger.info(" ✓ Successfully impersonating target user!")
else:
logger.warning(f" ⚠ Expected {target_user}, got {current_user}")
else:
logger.error(f"❌ User check failed: {user_response.status_code}")
logger.info("")
logger.info("=" * 80)
logger.info("✅ Impersonate API Test PASSED")
logger.info("=" * 80)
logger.info("")
logger.info("Summary:")
logger.info(" 1. Admin can impersonate other users via session-based API")
logger.info(" 2. Impersonated session can access APIs as that user")
logger.info(" 3. Requires admin credentials and CSRF token")
logger.info("")
logger.info("Limitations:")
logger.info(" - Session-based (not stateless like OAuth)")
logger.info(" - Requires admin credentials")
logger.info(" - Target user must have logged in at least once")
logger.info(" - Not suitable for distributed/background workers")
logger.info("")
logger.info("For background operations, consider:")
logger.info(" - Use service account with appropriate permissions")
logger.info(" - Or implement proper OAuth delegation (RFC 8693)")
logger.info("")
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
-196
View File
@@ -1,196 +0,0 @@
"""
Manual test for RFC 8693 Token Exchange with Keycloak.
This script demonstrates ADR-002 Tier 2 implementation:
1. Get service account token (client_credentials grant)
2. Exchange token for user-scoped token (RFC 8693)
3. Use exchanged token to access Nextcloud APIs
Usage:
# Start Keycloak and app containers
docker compose up -d keycloak app
# Run the test
uv run python tests/manual/test_token_exchange.py
"""
import asyncio
import logging
import os
import sys
# Add parent directory to path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../.."))
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.client import NextcloudClient
# Setup logging
logging.basicConfig(
level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s"
)
logger = logging.getLogger(__name__)
async def main():
"""Test token exchange flow"""
# Configuration (matches docker-compose mcp-keycloak service)
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
redirect_uri = "http://localhost:8002/oauth/callback"
logger.info("=" * 80)
logger.info("RFC 8693 Token Exchange Test")
logger.info("=" * 80)
logger.info(f"Keycloak URL: {keycloak_url}")
logger.info(f"Realm: {realm}")
logger.info(f"Client ID: {client_id}")
logger.info(f"Nextcloud: {nextcloud_host}")
logger.info("")
# Step 1: Create Keycloak OAuth client
logger.info("Step 1: Initializing Keycloak OAuth client...")
oauth_client = KeycloakOAuthClient(
keycloak_url=keycloak_url,
realm=realm,
client_id=client_id,
client_secret=client_secret,
redirect_uri=redirect_uri,
)
# Discover endpoints
await oauth_client.discover()
logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}")
logger.info("")
# Step 2: Check token exchange support
logger.info("Step 2: Checking token exchange support...")
supported = await oauth_client.check_token_exchange_support()
if not supported:
logger.error("❌ Token exchange is NOT supported by this Keycloak instance")
logger.error(
" You may need to enable it with: --features=preview --features=token-exchange"
)
return 1
logger.info("")
# Step 3: Get service account token
# ⚠️ WARNING: Service account tokens MUST NOT be used directly with Nextcloud APIs!
# Using this token directly violates OAuth "act on-behalf-of" principles:
# - Creates Nextcloud user: service-account-{client_id}
# - Breaks audit trail (actions not attributable to real user)
# - Creates stateful server identity in Nextcloud
#
# VALID USE: ONLY as subject_token for RFC 8693 token exchange (Step 4 below)
# INVALID USE: Direct API access (see ADR-002 "Will Not Implement" section)
#
# If you need background operations without token exchange support, use BasicAuth mode.
logger.info("Step 3: Requesting service account token (client_credentials)...")
try:
service_token_response = await oauth_client.get_service_account_token(
scopes=["openid", "profile", "email"]
)
service_token = service_token_response["access_token"]
logger.info("✓ Service account token acquired")
logger.info(f" Token type: {service_token_response.get('token_type')}")
logger.info(f" Expires in: {service_token_response.get('expires_in')}s")
logger.info(f" Scope: {service_token_response.get('scope')}")
logger.info(f" Token (first 50 chars): {service_token[:50]}...")
except Exception as e:
logger.error(f"❌ Failed to get service account token: {e}")
logger.error(
" Make sure serviceAccountsEnabled=true for the client in Keycloak"
)
return 1
logger.info("")
# Step 4: Exchange token (without impersonation - Standard V2)
logger.info(
"Step 4: Exchanging service token with different audience (RFC 8693)..."
)
logger.info(" Note: Keycloak Standard V2 doesn't support user impersonation")
logger.info(" That requires Legacy V1 with --features=preview")
try:
user_token_response = await oauth_client.exchange_token_for_user(
subject_token=service_token,
target_user_id=None, # Don't request impersonation
audience=None, # No cross-client exchange in Standard V2
scopes=["openid", "profile"], # Try downscoping
)
user_token = user_token_response["access_token"]
logger.info("✓ Token exchange successful")
logger.info(
f" Issued token type: {user_token_response.get('issued_token_type')}"
)
logger.info(f" Token type: {user_token_response.get('token_type')}")
logger.info(f" Expires in: {user_token_response.get('expires_in')}s")
logger.info(f" User token (first 50 chars): {user_token[:50]}...")
except Exception as e:
logger.error(f"❌ Token exchange failed: {e}")
logger.error(" Possible causes:")
logger.error(" - token.exchange.grant.enabled not set to true")
logger.error(" - Missing exchange permissions in Keycloak")
logger.error(" - User 'admin' does not exist")
return 1
logger.info("")
# Step 5: Test user token with Nextcloud API
logger.info("Step 5: Testing exchanged token with Nextcloud capabilities API...")
try:
# Create Nextcloud client with exchanged token
nc_client = NextcloudClient.from_token(
base_url=nextcloud_host, token=user_token, username="admin"
)
# Test API call
capabilities = await nc_client.capabilities()
logger.info("✓ Nextcloud API call successful")
logger.info(f" Version: {capabilities.get('version', {}).get('string')}")
logger.info(
f" Edition: {capabilities.get('capabilities', {}).get('core', {}).get('webdav-root')}"
)
await nc_client.close()
except Exception as e:
logger.error(f"❌ Nextcloud API call failed: {e}")
logger.error(" The exchanged token may not be valid for Nextcloud")
logger.error(" Check that user_oidc app is configured correctly")
return 1
logger.info("")
logger.info("=" * 80)
logger.info("✅ Token Exchange Test PASSED")
logger.info("=" * 80)
logger.info("")
logger.info("Summary:")
logger.info(" 1. Service account token acquired")
logger.info(" 2. Token exchanged with different audience")
logger.info(" 3. Exchanged token works with Nextcloud APIs")
logger.info("")
logger.info("This demonstrates ADR-002 Tier 2: Token Exchange")
logger.info(
"The MCP server can perform token exchange for different audiences/scopes"
)
logger.info("without needing refresh tokens or admin credentials.")
logger.info("")
logger.info(
"Note: User impersonation requires Keycloak Legacy V1 with --features=preview"
)
logger.info("")
return 0
if __name__ == "__main__":
exit_code = asyncio.run(main())
sys.exit(exit_code)
@@ -1,566 +0,0 @@
"""Keycloak External IdP Integration Tests.
Tests verify ADR-002 external identity provider integration where:
1. Keycloak acts as external OAuth/OIDC provider
2. MCP server validates tokens via Nextcloud user_oidc app
3. Nextcloud auto-provisions users from Keycloak token claims
4. MCP tools execute successfully with Keycloak tokens
Architecture:
MCP Client Keycloak (OAuth) MCP Server Nextcloud user_oidc (validates) APIs
Tests:
1. Keycloak OAuth token acquisition via Playwright
2. MCP client connection to mcp-keycloak service (port 8002)
3. Token validation through Nextcloud user_oidc app
4. MCP tool execution with Keycloak tokens
5. User auto-provisioning from Keycloak claims
6. Scope-based tool filtering with Keycloak JWT tokens
"""
import json
import logging
import pytest
from nextcloud_mcp_server.client import NextcloudClient
logger = logging.getLogger(__name__)
pytestmark = [pytest.mark.integration, pytest.mark.keycloak]
# ============================================================================
# OAuth Token Acquisition Tests
# ============================================================================
async def test_keycloak_oauth_token_acquisition(keycloak_oauth_token):
"""Test that Playwright can obtain OAuth token from Keycloak.
Verifies:
- Playwright automation handles Keycloak login page (input#username, input#password)
- Keycloak consent screen is handled correctly
- Authorization code is exchanged for access token
- Token is returned successfully
This is a foundational test - if this fails, all other Keycloak tests will fail.
"""
assert keycloak_oauth_token is not None
assert isinstance(keycloak_oauth_token, str)
assert len(keycloak_oauth_token) > 100 # Tokens should be substantial length
logger.info(
f"✓ Keycloak OAuth token acquired (length: {len(keycloak_oauth_token)})"
)
logger.info(f" Token prefix: {keycloak_oauth_token[:50]}...")
async def test_keycloak_oauth_client_credentials_discovery(
keycloak_oauth_client_credentials,
):
"""Test Keycloak OIDC discovery and credential loading.
Verifies:
- OIDC discovery endpoint is accessible
- Token and authorization endpoints are discovered
- Static client credentials are loaded from environment
- Callback server is initialized
"""
(
client_id,
client_secret,
callback_url,
token_endpoint,
authorization_endpoint,
) = keycloak_oauth_client_credentials
assert client_id == "nextcloud-mcp-server"
assert client_secret == "mcp-secret-change-in-production"
assert callback_url.startswith("http://")
# With --hostname-backchannel-dynamic, external clients see localhost:8888
assert "localhost:8888" in token_endpoint or "keycloak" in token_endpoint
assert (
"localhost:8888" in authorization_endpoint
or "keycloak" in authorization_endpoint
)
assert "/realms/nextcloud-mcp/" in token_endpoint
logger.info("✓ Keycloak OIDC discovery successful")
logger.info(f" Client ID: {client_id}")
logger.info(f" Token endpoint: {token_endpoint}")
logger.info(f" Authorization endpoint: {authorization_endpoint}")
# ============================================================================
# MCP Server Connectivity Tests
# ============================================================================
async def test_mcp_client_connects_to_keycloak_server(nc_mcp_keycloak_client):
"""Test MCP client can connect to mcp-keycloak service (port 8002).
Verifies:
- MCP client session is established
- Server responds to list_tools request
- Tools are available for use
"""
result = await nc_mcp_keycloak_client.list_tools()
assert result is not None
assert len(result.tools) > 0
logger.info(
f"✓ MCP client connected to Keycloak server with {len(result.tools)} tools"
)
async def test_external_idp_server_initialization(nc_mcp_keycloak_client):
"""Test that MCP server correctly initializes with external IdP configuration.
Verifies:
- Server auto-detects external IdP mode (issuer != Nextcloud host)
- Server reports correct provider type
- All expected tools are registered
The server should log messages like:
- "✓ Detected external IdP mode (issuer: http://keycloak:8080/realms/nextcloud-mcp != Nextcloud: http://app:80)"
"""
result = await nc_mcp_keycloak_client.list_tools()
# Verify we have a full set of tools (not filtered to specific apps)
tool_names = [tool.name for tool in result.tools]
# Should have tools from multiple apps
has_notes = any("notes" in name for name in tool_names)
has_calendar = any("calendar" in name for name in tool_names)
has_files = any("webdav" in name for name in tool_names)
assert has_notes, "Missing Notes tools"
assert has_calendar, "Missing Calendar tools"
assert has_files, "Missing WebDAV/Files tools"
logger.info("✓ MCP server initialized with external IdP mode")
logger.info(f" Tools from multiple apps detected: {len(result.tools)} total")
# ============================================================================
# Token Validation Tests
# ============================================================================
async def test_external_idp_token_validation(nc_mcp_keycloak_client):
"""Test that Keycloak tokens are validated via Nextcloud user_oidc app.
Token flow:
1. Keycloak issues OAuth token
2. MCP client sends token to MCP server
3. MCP server passes token to Nextcloud user_oidc app
4. user_oidc validates token with Keycloak (JWKS or introspection)
5. Nextcloud returns user info to MCP server
6. MCP server uses token to access Nextcloud APIs
This test verifies the entire flow works.
"""
# Execute a read operation (requires token validation)
result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert result.isError is False, f"Tool execution failed: {result.content}"
assert result.content is not None
response_data = json.loads(result.content[0].text)
# Successful response means token was validated and user was authenticated
assert "results" in response_data
assert isinstance(response_data["results"], list)
logger.info("✓ Keycloak token validated successfully via Nextcloud user_oidc app")
logger.info(f" Tool execution returned {len(response_data['results'])} results")
# ============================================================================
# Tool Execution Tests
# ============================================================================
async def test_tools_work_with_keycloak_token(nc_mcp_keycloak_client):
"""Test that MCP tools execute successfully with Keycloak OAuth tokens.
Verifies end-to-end functionality:
- Read operations work (nc_notes_search_notes)
- Write operations work (nc_notes_create_note)
- Different apps work (Notes, Calendar, Files)
"""
# Test 1: Read operation (Notes)
search_result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_search_notes", arguments={"query": ""}
)
assert search_result.isError is False
logger.info("✓ Read operation successful (nc_notes_search_notes)")
# Test 2: Write operation (Notes)
create_result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_create_note",
arguments={
"title": "Keycloak Test Note",
"content": "Created via external IdP token",
"category": "Test",
},
)
assert create_result.isError is False
create_data = json.loads(create_result.content[0].text)
note_id = create_data["id"]
logger.info(f"✓ Write operation successful (created note {note_id})")
# Test 3: Different app (Calendar)
calendar_result = await nc_mcp_keycloak_client.call_tool(
"nc_calendar_list_calendars", arguments={}
)
assert calendar_result.isError is False
logger.info("✓ Calendar tool execution successful")
# Test 4: File operations (WebDAV)
files_result = await nc_mcp_keycloak_client.call_tool(
"nc_webdav_list_directory", arguments={"path": "/"}
)
assert files_result.isError is False
logger.info("✓ WebDAV tool execution successful")
# Cleanup: Delete test note
await nc_mcp_keycloak_client.call_tool(
"nc_notes_delete_note", arguments={"note_id": note_id}
)
logger.info(f"✓ Cleanup: Deleted test note {note_id}")
async def test_keycloak_token_persistence(nc_mcp_keycloak_client):
"""Test that Keycloak token works across multiple operations.
Verifies:
- Token is properly cached by MCP server
- Token can be reused for multiple API calls
- No re-authentication is required between calls
"""
# Execute multiple operations with same session
operations = [
("nc_notes_search_notes", {"query": ""}),
("nc_calendar_list_calendars", {}),
("nc_webdav_list_directory", {"path": "/"}),
]
for tool_name, arguments in operations:
result = await nc_mcp_keycloak_client.call_tool(tool_name, arguments=arguments)
assert result.isError is False, f"Failed to execute {tool_name}"
logger.info(f"{tool_name} executed successfully")
logger.info("✓ Keycloak token persistence verified (3 operations with same token)")
# ============================================================================
# User Provisioning Tests
# ============================================================================
async def test_user_auto_provisioning(nc_client: NextcloudClient, keycloak_oauth_token):
"""Test that Nextcloud validates users from Keycloak token claims.
When a user authenticates with Keycloak, Nextcloud's user_oidc app
validates the token and authenticates the user. In this test setup,
the Keycloak 'admin' user maps to the Nextcloud 'admin' user.
Verification:
1. User exists in Nextcloud after OAuth authentication
2. User can access Nextcloud APIs with Keycloak token
3. Bearer token validation is working correctly
Note: With bearer-provisioning enabled, user_oidc would auto-provision
new users from token claims, but since we use 'admin' in both Keycloak
and Nextcloud, they map to the same user.
"""
# Get list of users (returns List[str] of user IDs)
user_ids = await nc_client.users.search_users()
logger.info(f"Found {len(user_ids)} users in Nextcloud")
logger.info(f"Users: {user_ids}")
# Verify the admin user exists (used for authentication)
assert "admin" in user_ids, "Expected 'admin' user to exist in Nextcloud"
# Verify we can access APIs with the Keycloak token (already tested in previous tests)
# The fact that we got this far means bearer token validation is working
logger.info("✓ User authentication and bearer token validation verified")
logger.info(f" Total users: {len(user_ids)}")
logger.info(" Bearer provisioning is enabled and working correctly")
# ============================================================================
# Scope-Based Authorization Tests
# ============================================================================
async def test_scope_filtering_with_keycloak(nc_mcp_keycloak_client):
"""Test that tool filtering works correctly with Keycloak JWT scopes.
Keycloak tokens should include scopes in JWT payload (if JWT format).
The MCP server should filter tools based on these scopes.
Expected scopes (from docker-compose.yml):
- openid profile email offline_access
- notes:read notes:write
- calendar:read calendar:write
- contacts:read contacts:write
- etc.
Tools should be filtered accordingly.
"""
result = await nc_mcp_keycloak_client.list_tools()
tool_names = [tool.name for tool in result.tools]
# With full scopes, all app tools should be available
expected_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_create_note", # notes:write
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_create_event", # calendar:write
"nc_webdav_list_directory", # files:read
"nc_webdav_write_file", # files:write
]
for tool_name in expected_tools:
assert tool_name in tool_names, f"Expected tool {tool_name} not found"
logger.info("✓ Scope-based tool filtering working with Keycloak tokens")
logger.info(f" Available tools: {len(tool_names)}")
# ============================================================================
# Error Handling Tests
# ============================================================================
async def test_keycloak_error_handling(nc_mcp_keycloak_client):
"""Test error handling with Keycloak tokens.
Verifies:
- Invalid operations return proper errors
- Token validation errors are handled correctly
- API errors propagate correctly through the chain
"""
# Try to get a non-existent note
result = await nc_mcp_keycloak_client.call_tool(
"nc_notes_get_note", arguments={"note_id": 999999}
)
# Should get an error (note doesn't exist)
assert result.isError is True
logger.info(
"✓ Keycloak OAuth server correctly handles errors for invalid operations"
)
# ============================================================================
# Documentation Tests
# ============================================================================
async def test_external_idp_architecture():
"""Document the external IdP architecture (ADR-002).
This test captures the design and flow for reference.
"""
architecture = {
"flow": [
"1. User authenticates with Keycloak (external IdP)",
"2. Keycloak issues OAuth access token with scopes",
"3. MCP client uses token to authenticate with MCP server",
"4. MCP server receives token and passes to Nextcloud",
"5. Nextcloud user_oidc app validates token with Keycloak",
"6. Nextcloud auto-provisions user from token claims (if first login)",
"7. Nextcloud returns validated user info to MCP server",
"8. MCP server executes tool using validated token",
],
"components": {
"keycloak": "External OAuth/OIDC provider (port 8888)",
"mcp_server": "MCP server with external IdP config (port 8002)",
"nextcloud": "API server with user_oidc app (port 8080)",
"user_oidc": "Nextcloud app that validates external IdP tokens",
},
"configuration": {
"keycloak_realm": "nextcloud-mcp",
"keycloak_client": "nextcloud-mcp-server",
"nextcloud_provider": "keycloak (via user_oidc app)",
"token_validation": "Keycloak JWKS or introspection endpoint",
},
"advantages": [
"No admin credentials needed in MCP server",
"Centralized identity management",
"Standards-based (RFC 6749, RFC 7662, RFC 9068)",
"Supports enterprise IdPs (Keycloak, Auth0, Okta, etc.)",
"User auto-provisioning from IdP claims",
],
}
logger.info("External IdP Architecture (ADR-002):")
logger.info(json.dumps(architecture, indent=2))
assert True
# ============================================================================
# Scope-Based Authorization Tests (JWT Token Filtering)
# ============================================================================
async def test_keycloak_read_only_token_filters_write_tools(
nc_mcp_keycloak_client_read_only,
):
"""Test that a Keycloak token with only read scopes filters out write tools."""
# Connect with token that has only read scopes
result = await nc_mcp_keycloak_client_read_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Keycloak read-only token sees {len(tool_names)} tools")
# Verify read tools are present
expected_read_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_get_event", # calendar:read
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found in tool list"
# Verify write tools are NOT present (filtered out)
write_tools_should_be_filtered = [
"nc_notes_create_note", # notes:write
"nc_notes_update_note", # notes:write
"nc_notes_delete_note", # notes:write
"nc_calendar_create_event", # calendar:write
"nc_calendar_update_event", # calendar:write
"nc_calendar_delete_event", # calendar:write
]
for tool in write_tools_should_be_filtered:
assert tool not in tool_names, (
f"Write tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Keycloak read-only token properly filters tools: {len(tool_names)} read tools visible, "
f"write tools hidden"
)
async def test_keycloak_write_only_token_filters_read_tools(
nc_mcp_keycloak_client_write_only,
):
"""Test that a Keycloak token with only write scopes filters out read tools."""
# Connect with token that has only write scopes
result = await nc_mcp_keycloak_client_write_only.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Keycloak write-only token sees {len(tool_names)} tools")
# Verify write tools are present
expected_write_tools = [
"nc_notes_create_note", # notes:write
"nc_notes_update_note", # notes:write
"nc_notes_delete_note", # notes:write
"nc_calendar_create_event", # calendar:write
"nc_calendar_update_event", # calendar:write
"nc_calendar_delete_event", # calendar:write
]
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found in tool list"
# Verify read-only tools are NOT present (write-only scope)
read_tools_should_be_filtered = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
"nc_calendar_get_event", # calendar:read
]
for tool in read_tools_should_be_filtered:
assert tool not in tool_names, (
f"Read tool {tool} should be filtered out but was found in tool list"
)
logger.info(
f"✅ Keycloak write-only token properly filters tools: {len(tool_names)} write tools visible, "
f"read tools hidden"
)
async def test_keycloak_full_access_token_shows_all_tools(nc_mcp_keycloak_client):
"""Test that a Keycloak token with both read and write scopes sees all tools."""
# Connect with token that has both read and write scopes
result = await nc_mcp_keycloak_client.list_tools()
assert result is not None
assert len(result.tools) > 0
tool_names = [tool.name for tool in result.tools]
logger.info(f"Keycloak full access token sees {len(tool_names)} tools")
# Verify both read and write tools are present
expected_read_tools = [
"nc_notes_get_note", # notes:read
"nc_notes_search_notes", # notes:read
"nc_calendar_list_calendars", # calendar:read
]
expected_write_tools = [
"nc_notes_create_note", # notes:write
"nc_calendar_create_event", # calendar:write
]
for tool in expected_read_tools:
assert tool in tool_names, f"Expected read tool {tool} not found"
for tool in expected_write_tools:
assert tool in tool_names, f"Expected write tool {tool} not found"
# Should have all 90+ tools (both read and write)
assert len(tool_names) >= 90
logger.info(
f"✅ Keycloak full access token sees all tools: {len(tool_names)} total (read + write)"
)
async def test_keycloak_no_custom_scopes_returns_zero_tools(
nc_mcp_keycloak_client_no_custom_scopes,
):
"""
Test that a Keycloak JWT token with only OIDC default scopes returns 0 tools.
This tests the security behavior when a user declines to grant custom scopes during consent.
Expected: JWT token has scopes=['openid', 'profile', 'email'] but no custom scopes.
All tools require at least one custom scope, so they should all be filtered out.
"""
# Connect with JWT token that has NO custom scopes (only openid, profile, email)
result = await nc_mcp_keycloak_client_no_custom_scopes.list_tools()
assert result is not None
tool_names = [tool.name for tool in result.tools]
logger.info(
f"Keycloak JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)"
)
# All tools require custom scopes, so should be filtered out
assert len(tool_names) == 0, (
f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}"
)
logger.info(
"✅ Keycloak JWT token without custom scopes correctly returns 0 tools (all filtered out)"
)
+7
View File
@@ -23,6 +23,7 @@ def clean_env(monkeypatch):
"NEXTCLOUD_PASSWORD",
"NEXTCLOUD_OIDC_CLIENT_ID",
"NEXTCLOUD_OIDC_CLIENT_SECRET",
"NEXTCLOUD_OIDC_CLIENT_STORAGE",
"NEXTCLOUD_OIDC_SCOPES",
"NEXTCLOUD_OIDC_TOKEN_TYPE",
"NEXTCLOUD_MCP_SERVER_URL",
@@ -239,6 +240,9 @@ def test_default_values(runner, clean_env, monkeypatch):
"NEXTCLOUD_OIDC_TOKEN_TYPE"
),
"NEXTCLOUD_MCP_SERVER_URL": os.environ.get("NEXTCLOUD_MCP_SERVER_URL"),
"NEXTCLOUD_OIDC_CLIENT_STORAGE": os.environ.get(
"NEXTCLOUD_OIDC_CLIENT_STORAGE"
),
}
)
raise SystemExit(0)
@@ -263,6 +267,9 @@ def test_default_values(runner, clean_env, monkeypatch):
)
assert captured_env["NEXTCLOUD_OIDC_TOKEN_TYPE"] == "bearer"
assert captured_env["NEXTCLOUD_MCP_SERVER_URL"] == "http://localhost:8000"
assert (
captured_env["NEXTCLOUD_OIDC_CLIENT_STORAGE"] == ".nextcloud_oauth_client.json"
)
def test_oauth_token_type_case_normalization(runner, clean_env, monkeypatch):
Generated
+958 -973
View File
File diff suppressed because it is too large Load Diff