docs: Reject service account tokens as OAuth authentication pattern

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>
This commit is contained in:
Chris Coutinho
2025-11-02 22:03:22 +01:00
co-authored by Claude
parent ed813af45c
commit e26c5128b7
6 changed files with 102 additions and 75 deletions
+4 -2
View File
@@ -421,7 +421,7 @@ curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
# 3. Verify user_oidc provider is configured # 3. Verify user_oidc provider is configured
docker compose exec app php occ user_oidc:provider keycloak docker compose exec app php occ user_oidc:provider keycloak
# 4. Generate encryption key for refresh token storage (optional, for ADR-002 Tier 1) # 4. Generate encryption key for refresh token storage (optional, for offline access)
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
# Set in environment: export TOKEN_ENCRYPTION_KEY='<key>' # Set in environment: export TOKEN_ENCRYPTION_KEY='<key>'
@@ -507,13 +507,15 @@ docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-kno
``` ```
**ADR-002 Offline Access Testing:** **ADR-002 Offline Access Testing:**
The Keycloak integration enables testing ADR-002 Tier 1 (offline access with refresh tokens): 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`) 1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`)
2. **Token refresh**: Access tokens refreshed automatically when expired 2. **Token refresh**: Access tokens refreshed automatically when expired
3. **Background workers**: Can access APIs using stored refresh tokens 3. **Background workers**: Can access APIs using stored refresh tokens
4. **No admin credentials**: All operations use user's OAuth 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. See `docs/ADR-002-vector-sync-authentication.md` for architectural details.
**Audience Validation:** **Audience Validation:**
+42 -64
View File
@@ -1,7 +1,9 @@
# ADR-002: Vector Database Background Sync Authentication # ADR-002: Vector Database Background Sync Authentication
## Status ## Status
Accepted - Tier 2 (Token Exchange) Implemented 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.
## Context ## Context
@@ -43,26 +45,28 @@ We will implement a **tiered OAuth authentication strategy** for background oper
**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. **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.
### Tier 1: Service Account Token (client_credentials) ✅ **IMPLEMENTED** ### OAuth "Act On-Behalf-Of" Principle
**Most Compatible Option** - Works with all OIDC providers supporting `client_credentials` **Core Requirement**: The MCP server must NEVER create its own user identity in Nextcloud when operating in OAuth mode.
- MCP server obtains service account token via `client_credentials` grant **Valid Patterns**:
- Background worker uses service account token directly - **Foreground operations**: Use user's access token from MCP request (currently implemented)
- No user-specific delegation or impersonation - **Background operations**: Token exchange to impersonate/delegate as user (requires provider support)
- **Implementation**: `KeycloakOAuthClient.get_service_account_token()` (keycloak_oauth.py:341-395) - **Service account**: Creates independent identity in Nextcloud (violates OAuth principles)
- **Testing**: Manual test in `tests/manual/test_token_exchange.py`
- **TODO**: Automated integration tests needed for both Keycloak and Nextcloud OIDC app
**Trade-offs**: **Why This Matters**:
- ✅ Works with nearly all OIDC providers 1. **Audit Trail**: All operations must be attributable to the actual user, not a service account
- ✅ Simple implementation and configuration 2. **Stateless Server**: MCP server should not have persistent identity/state in Nextcloud
- ✅ No additional provider features required 3. **Security Model**: Avoid creating "admin by another name" with broad cross-user permissions
- ❌ Service account needs broad permissions across users 4. **OAuth Design**: OAuth tokens represent user authorization, not server authorization
- ❌ Less granular audit trail (all actions attributed to service account)
- ❌ No per-user permission enforcement
### Tier 2: Token Exchange with Impersonation (RFC 8693) ⚠️ **NOT IMPLEMENTED** **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 **Better Security** - Requires provider support for user impersonation
@@ -79,7 +83,7 @@ We will implement a **tiered OAuth authentication strategy** for background oper
**Status**: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation **Status**: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation
**Reference**: See `docs/oauth-impersonation-findings.md` for investigation details **Reference**: See `docs/oauth-impersonation-findings.md` for investigation details
### Tier 3: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED** ### Tier 2: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED**
**Best Security** - Requires provider support for delegation with `act` claim **Best Security** - Requires provider support for delegation with `act` claim
@@ -100,13 +104,26 @@ We will implement a **tiered OAuth authentication strategy** for background oper
### ❌ Will Not Implement ### ❌ Will Not Implement
**1. Offline Access with Refresh Tokens** **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 - **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) - **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 - **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 - **Alternative**: Token exchange provides similar benefits without violating OAuth security model
**2. Admin Credentials Fallback** **3. Admin Credentials Fallback**
- **Out of Scope**: This ADR focuses on OAuth mode only - **Out of Scope**: This ADR focuses on OAuth mode only
- **Not Appropriate**: Admin credentials bypass OAuth security model - **Not Appropriate**: Admin credentials bypass OAuth security model
- **BasicAuth Mode**: For single-user deployments needing background operations, use BasicAuth mode instead - **BasicAuth Mode**: For single-user deployments needing background operations, use BasicAuth mode instead
@@ -122,50 +139,11 @@ We will implement a **tiered OAuth authentication strategy** for background oper
## Implementation Details ## Implementation Details
### 1. Service Account Token (Tier 1 - Primary) ✅ IMPLEMENTED ### 1. Token Exchange with Impersonation (Tier 1) ⚠️ NOT IMPLEMENTED
#### 1.1 Service Account Token Acquisition
```python
async def get_service_token() -> str:
"""Get token for MCP server's service account"""
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"]
```
**Implementation**: `KeycloakOAuthClient.get_service_account_token()` (keycloak_oauth.py:341-395)
**Usage**:
```python
# Background worker uses service account token directly
service_token_data = await oauth_client.get_service_account_token(
scopes=["notes:read", "files:read", "calendar:read"]
)
client = NextcloudClient.from_token(
base_url=nextcloud_host,
token=service_token_data["access_token"],
username="service-account"
)
# All operations are performed as the service account
notes = await client.notes.list_notes()
```
### 2. Token Exchange with Impersonation (Tier 2) ⚠️ NOT IMPLEMENTED
This tier is documented for completeness but is not currently implemented due to lack of provider support. This tier is documented for completeness but is not currently implemented due to lack of provider support.
#### 2.1 Impersonation Flow (Conceptual) #### 1.1 Impersonation Flow (Conceptual)
```python ```python
async def exchange_for_impersonated_user_token( async def exchange_for_impersonated_user_token(
@@ -201,9 +179,9 @@ async def exchange_for_impersonated_user_token(
**See**: `docs/oauth-impersonation-findings.md` for detailed investigation **See**: `docs/oauth-impersonation-findings.md` for detailed investigation
### 3. Token Exchange with Delegation (Tier 3) ✅ IMPLEMENTED ### 2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED
#### 3.1 Capability Detection #### 2.1 Capability Detection
```python ```python
async def check_token_exchange_support(discovery_url: str) -> bool: async def check_token_exchange_support(discovery_url: str) -> bool:
"""Check if OIDC provider supports RFC 8693 token exchange""" """Check if OIDC provider supports RFC 8693 token exchange"""
@@ -217,7 +195,7 @@ async def check_token_exchange_support(discovery_url: str) -> bool:
return "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types return "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
``` ```
#### 3.2 Delegation Token Exchange #### 2.2 Delegation Token Exchange
```python ```python
async def exchange_for_user_token( async def exchange_for_user_token(
service_token: str, service_token: str,
+5 -3
View File
@@ -389,9 +389,11 @@ Security: Refresh tokens stored encrypted, rotated on use
## Authentication Strategies for Background Jobs ## Authentication Strategies for Background Jobs
### Current Approach: Offline Access with Refresh Tokens (Tier 1) > **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.
The MCP server currently uses **offline_access** scope to enable background operations: ### Current Approach: Offline Access with Refresh Tokens
The MCP server uses **offline_access** scope to enable background operations:
**How it works:** **How it works:**
1. User grants `offline_access` scope during OAuth consent 1. User grants `offline_access` scope during OAuth consent
@@ -412,7 +414,7 @@ The MCP server currently uses **offline_access** scope to enable background oper
- ⚠️ Weak audit trail - API requests appear to come from user directly - ⚠️ Weak audit trail - API requests appear to come from user directly
- ⚠️ No visibility that MCP Server is the actual actor - ⚠️ No visibility that MCP Server is the actual actor
### Future Enhancement: Token Exchange with Delegation (Tier 2) ### Token Exchange with Delegation (ADR-002 Tier 2 - Implemented)
**RFC 8693 Delegation** would provide better audit trail and security: **RFC 8693 Delegation** would provide better audit trail and security:
+17
View File
@@ -5,6 +5,23 @@
**Status**: Implementation Complete - Token Exchange Working **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. **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 ## Summary
We investigated options for implementing user impersonation to enable background operations without requiring admin credentials (ADR-002 Tier 2). Here are the findings: We investigated options for implementing user impersonation to enable background operations without requiring admin credentials (ADR-002 Tier 2). Here are the findings:
+24 -6
View File
@@ -342,9 +342,27 @@ class KeycloakOAuthClient:
""" """
Get a service account token using client_credentials grant. Get a service account token using client_credentials grant.
This requires the client to have serviceAccountsEnabled=true in Keycloak. ⚠️ **WARNING: DO NOT USE FOR DIRECT API ACCESS IN OAUTH MODE** ⚠️
The service account token can be used for server-initiated operations
or as the subject_token for token exchange. 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: Args:
scopes: Optional list of scopes to request (default: openid profile email) scopes: Optional list of scopes to request (default: openid profile email)
@@ -359,9 +377,9 @@ class KeycloakOAuthClient:
Raises: Raises:
httpx.HTTPError: If token request fails httpx.HTTPError: If token request fails
Note: See Also:
This is used for ADR-002 Tier 2 (Token Exchange). The service account - ADR-002 "Will Not Implement" section for detailed critique
token is exchanged for user-scoped tokens via RFC 8693. - exchange_token_for_user() for proper token exchange usage
""" """
if not self.token_endpoint: if not self.token_endpoint:
await self.discover() await self.discover()
+10
View File
@@ -83,6 +83,16 @@ async def main():
logger.info("") logger.info("")
# Step 3: Get service account token # 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)...") logger.info("Step 3: Requesting service account token (client_credentials)...")
try: try:
service_token_response = await oauth_client.get_service_account_token( service_token_response = await oauth_client.get_service_account_token(