Service account tokens (client_credentials grant) violate OAuth "act on-behalf-of" principles and have been moved to ADR-002's "Will Not Implement" section. ## Problem Discovery Testing revealed that service account tokens create Nextcloud user accounts (e.g., `service-account-nextcloud-mcp-server`) due to user_oidc's bearer provisioning feature. This violates core OAuth principles: - ❌ Creates stateful server identity in Nextcloud - ❌ All actions attributed to service account, not real user - ❌ Breaks audit trail and user attribution - ❌ Service account becomes "admin by another name" ## Changes ### Documentation (ADR-002) - Moved service account (old Tier 1) to "Will Not Implement" section - Added "OAuth Act On-Behalf-Of Principle" section - Renumbered tiers: - Tier 1: Impersonation (NOT IMPLEMENTED) - Tier 2: Delegation via token exchange (IMPLEMENTED) - Updated status to reflect rejection of service accounts ### Code Warnings - Added comprehensive warning to KeycloakOAuthClient.get_service_account_token() - Clarified VALID use: only as subject_token for RFC 8693 token exchange - Clarified INVALID use: direct API access with service account token ### Supporting Documentation - CLAUDE.md: Removed outdated "Tier 1" references, added rejection note - oauth-impersonation-findings.md: Added prominent update banner - audience-validation-setup.md: Updated tier numbers, added rejection note - tests/manual/test_token_exchange.py: Added warning comment ## Valid Patterns (ADR-002) ✅ Foreground operations: User's access token from MCP request ✅ Background operations: Token exchange (impersonation/delegation) ✅ Offline access: Refresh tokens with user consent ❌ Service accounts: Creates independent server identity (REJECTED) ## Alternative If service account pattern is truly needed, use BasicAuth mode instead of OAuth mode. OAuth mode MUST maintain "act on-behalf-of" semantics. Related: c12df98 (revert of service account test) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
12 KiB
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_credentialsgrant) - ✅
get_service_account_token()method inKeycloakOAuthClient - ✅
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_credentialsgrant - ✅ 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:
"attributes": {
"token.exchange.grant.enabled": "true",
"client.token.exchange.standard.enabled": "true"
}
Limitations
Keycloak Standard V2 does NOT support:
- ❌ User impersonation (
requested_subjectparameter) - ❌ 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-exchangeflag - ❌ Not suitable for production
- ❌ Deprecated and being phased out
Decision: Not viable for production use.
2. Nextcloud OIDC App Token Exchange
Discovery Endpoint Analysis
{
"grant_types_supported": [
"authorization_code",
"implicit"
]
}
Findings
❌ Nextcloud OIDC app does NOT support:
- RFC 8693 token exchange
client_credentialsgrantrefresh_tokengrant (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/userwithuserIdparameter
How It Works
// 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:
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:
- Create
mcp-syncuser in Nextcloud - Grant specific permissions (group memberships, shares)
- 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:
# 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:
- Use identity provider that supports RFC 8693 properly (e.g., Auth0, Okta)
- Or implement custom delegation endpoint in Nextcloud
- 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
- ✅
RefreshTokenStorage- SQLite + encryption (ready for future use) - ✅
KeycloakOAuthClient.get_service_account_token()- Works - ✅
KeycloakOAuthClient.exchange_token_for_user()- Implemented but non-functional - ✅ Token exchange configuration - Keycloak realm updated
- ✅ Test scripts - Comprehensive testing completed
What to Use
For Background Operations:
# 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:
# 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 methodsnextcloud_mcp_server/auth/refresh_token_storage.py- Token storage (ready for future)nextcloud_mcp_server/app.py- OAuth configuration updateskeycloak/realm-export.json- Token exchange enabledpyproject.toml- Added aiosqlite dependency
Documentation
docs/oauth-impersonation-findings.md- This documentdocs/ADR-002-vector-sync-authentication.md- Original architecture decision
Tests
tests/manual/test_token_exchange.py- Keycloak RFC 8693 testingtests/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
{
"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:
"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:
$ 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- Addedclient.token.exchange.standard.enabledattributedocs/oauth-impersonation-findings.md- Updated with resolution
Testing
Run the complete token exchange flow:
uv run python tests/manual/test_token_exchange.py