Skip to content

Commit e26c512

Browse files
cbcoutinhoclaude
andcommitted
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 <[email protected]>
1 parent ed813af commit e26c512

File tree

6 files changed

+102
-75
lines changed

6 files changed

+102
-75
lines changed

CLAUDE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration
421421
# 3. Verify user_oidc provider is configured
422422
docker compose exec app php occ user_oidc:provider keycloak
423423

424-
# 4. Generate encryption key for refresh token storage (optional, for ADR-002 Tier 1)
424+
# 4. Generate encryption key for refresh token storage (optional, for offline access)
425425
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
426426
# Set in environment: export TOKEN_ENCRYPTION_KEY='<key>'
427427

@@ -507,13 +507,15 @@ docker compose exec app curl http://keycloak:8080/realms/nextcloud-mcp/.well-kno
507507
```
508508

509509
**ADR-002 Offline Access Testing:**
510-
The Keycloak integration enables testing ADR-002 Tier 1 (offline access with refresh tokens):
510+
The Keycloak integration enables testing ADR-002's primary authentication pattern (offline access with refresh tokens):
511511

512512
1. **Refresh token storage**: Tokens stored encrypted in SQLite (`/app/data/tokens.db`)
513513
2. **Token refresh**: Access tokens refreshed automatically when expired
514514
3. **Background workers**: Can access APIs using stored refresh tokens
515515
4. **No admin credentials**: All operations use user's OAuth tokens
516516

517+
**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.
518+
517519
See `docs/ADR-002-vector-sync-authentication.md` for architectural details.
518520

519521
**Audience Validation:**

docs/ADR-002-vector-sync-authentication.md

Lines changed: 42 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
# ADR-002: Vector Database Background Sync Authentication
22

33
## Status
4-
Accepted - Tier 2 (Token Exchange) Implemented
4+
Accepted - Tier 2 (Token Exchange with Delegation) Implemented
5+
6+
**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.
57

68
## Context
79

@@ -43,26 +45,28 @@ We will implement a **tiered OAuth authentication strategy** for background oper
4345

4446
**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.
4547

46-
### Tier 1: Service Account Token (client_credentials) ✅ **IMPLEMENTED**
48+
### OAuth "Act On-Behalf-Of" Principle
4749

48-
**Most Compatible Option** - Works with all OIDC providers supporting `client_credentials`
50+
**Core Requirement**: The MCP server must NEVER create its own user identity in Nextcloud when operating in OAuth mode.
4951

50-
- MCP server obtains service account token via `client_credentials` grant
51-
- Background worker uses service account token directly
52-
- No user-specific delegation or impersonation
53-
- **Implementation**: `KeycloakOAuthClient.get_service_account_token()` (keycloak_oauth.py:341-395)
54-
- **Testing**: Manual test in `tests/manual/test_token_exchange.py`
55-
- **TODO**: Automated integration tests needed for both Keycloak and Nextcloud OIDC app
52+
**Valid Patterns**:
53+
-**Foreground operations**: Use user's access token from MCP request (currently implemented)
54+
-**Background operations**: Token exchange to impersonate/delegate as user (requires provider support)
55+
-**Service account**: Creates independent identity in Nextcloud (violates OAuth principles)
56+
57+
**Why This Matters**:
58+
1. **Audit Trail**: All operations must be attributable to the actual user, not a service account
59+
2. **Stateless Server**: MCP server should not have persistent identity/state in Nextcloud
60+
3. **Security Model**: Avoid creating "admin by another name" with broad cross-user permissions
61+
4. **OAuth Design**: OAuth tokens represent user authorization, not server authorization
5662

57-
**Trade-offs**:
58-
- ✅ Works with nearly all OIDC providers
59-
- ✅ Simple implementation and configuration
60-
- ✅ No additional provider features required
61-
- ❌ Service account needs broad permissions across users
62-
- ❌ Less granular audit trail (all actions attributed to service account)
63-
- ❌ No per-user permission enforcement
63+
**If Token Exchange Not Available**:
64+
- Background operations simply cannot happen in OAuth mode
65+
- This is correct behavior - not a limitation to work around
66+
- Don't create service accounts as "workaround" - this defeats OAuth's purpose
67+
- Use BasicAuth mode if background operations are critical to your deployment
6468

65-
### Tier 2: Token Exchange with Impersonation (RFC 8693) ⚠️ **NOT IMPLEMENTED**
69+
### Tier 1: Token Exchange with Impersonation (RFC 8693) ⚠️ **NOT IMPLEMENTED**
6670

6771
**Better Security** - Requires provider support for user impersonation
6872

@@ -79,7 +83,7 @@ We will implement a **tiered OAuth authentication strategy** for background oper
7983
**Status**: ⚠️ Not implemented - Keycloak Standard V2 doesn't support impersonation
8084
**Reference**: See `docs/oauth-impersonation-findings.md` for investigation details
8185

82-
### Tier 3: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED**
86+
### Tier 2: Token Exchange with Delegation (RFC 8693) ✅ **IMPLEMENTED**
8387

8488
**Best Security** - Requires provider support for delegation with `act` claim
8589

@@ -100,13 +104,26 @@ We will implement a **tiered OAuth authentication strategy** for background oper
100104

101105
### ❌ Will Not Implement
102106

103-
**1. Offline Access with Refresh Tokens**
107+
**1. Service Account with Independent Identity (client_credentials)**
108+
- **Status**: Previously proposed as Tier 1, now rejected
109+
- **Why Invalid**: Creates Nextcloud user account for MCP server (e.g., `service-account-nextcloud-mcp-server`)
110+
- **Problems**:
111+
- **Violates OAuth "act on-behalf-of" principle**: Actions attributed to service account instead of real user
112+
- **Breaks audit trail**: Can't determine which user initiated the action
113+
- **Creates stateful server identity**: MCP server has persistent identity/data in Nextcloud
114+
- **Security risk**: Service account becomes "admin by another name" with broad cross-user permissions
115+
- **User provisioning side effect**: Nextcloud's `user_oidc` app auto-provisions service account as real user
116+
- **Code Status**: Implementation exists (`KeycloakOAuthClient.get_service_account_token()`) but marked with warnings
117+
- **Alternative**: If service account pattern truly needed, use BasicAuth mode instead of OAuth mode
118+
- **Reference**: See commit c12df98 for detailed analysis of why this approach was rejected
119+
120+
**2. Offline Access with Refresh Tokens**
104121
- **MCP Protocol Architecture**: FastMCP SDK manages OAuth where MCP Client handles refresh tokens
105122
- **Security Model**: Refresh tokens must never be shared between client and server (OAuth best practice)
106123
- **Technical Impossibility**: MCP Server has no access to refresh tokens from the OAuth callback
107124
- **Alternative**: Token exchange provides similar benefits without violating OAuth security model
108125

109-
**2. Admin Credentials Fallback**
126+
**3. Admin Credentials Fallback**
110127
- **Out of Scope**: This ADR focuses on OAuth mode only
111128
- **Not Appropriate**: Admin credentials bypass OAuth security model
112129
- **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
122139

123140
## Implementation Details
124141

125-
### 1. Service Account Token (Tier 1 - Primary) ✅ IMPLEMENTED
126-
127-
#### 1.1 Service Account Token Acquisition
128-
```python
129-
async def get_service_token() -> str:
130-
"""Get token for MCP server's service account"""
131-
132-
async with httpx.AsyncClient() as client:
133-
response = await client.post(
134-
token_endpoint,
135-
data={
136-
"grant_type": "client_credentials",
137-
"scope": "notes:read files:read calendar:read"
138-
},
139-
auth=(client_id, client_secret)
140-
)
141-
response.raise_for_status()
142-
return response.json()["access_token"]
143-
```
144-
145-
**Implementation**: `KeycloakOAuthClient.get_service_account_token()` (keycloak_oauth.py:341-395)
146-
147-
**Usage**:
148-
```python
149-
# Background worker uses service account token directly
150-
service_token_data = await oauth_client.get_service_account_token(
151-
scopes=["notes:read", "files:read", "calendar:read"]
152-
)
153-
154-
client = NextcloudClient.from_token(
155-
base_url=nextcloud_host,
156-
token=service_token_data["access_token"],
157-
username="service-account"
158-
)
159-
160-
# All operations are performed as the service account
161-
notes = await client.notes.list_notes()
162-
```
163-
164-
### 2. Token Exchange with Impersonation (Tier 2) ⚠️ NOT IMPLEMENTED
142+
### 1. Token Exchange with Impersonation (Tier 1) ⚠️ NOT IMPLEMENTED
165143

166144
This tier is documented for completeness but is not currently implemented due to lack of provider support.
167145

168-
#### 2.1 Impersonation Flow (Conceptual)
146+
#### 1.1 Impersonation Flow (Conceptual)
169147

170148
```python
171149
async def exchange_for_impersonated_user_token(
@@ -201,9 +179,9 @@ async def exchange_for_impersonated_user_token(
201179

202180
**See**: `docs/oauth-impersonation-findings.md` for detailed investigation
203181

204-
### 3. Token Exchange with Delegation (Tier 3) ✅ IMPLEMENTED
182+
### 2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED
205183

206-
#### 3.1 Capability Detection
184+
#### 2.1 Capability Detection
207185
```python
208186
async def check_token_exchange_support(discovery_url: str) -> bool:
209187
"""Check if OIDC provider supports RFC 8693 token exchange"""
@@ -217,7 +195,7 @@ async def check_token_exchange_support(discovery_url: str) -> bool:
217195
return "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types
218196
```
219197

220-
#### 3.2 Delegation Token Exchange
198+
#### 2.2 Delegation Token Exchange
221199
```python
222200
async def exchange_for_user_token(
223201
service_token: str,

docs/audience-validation-setup.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -389,9 +389,11 @@ Security: Refresh tokens stored encrypted, rotated on use
389389

390390
## Authentication Strategies for Background Jobs
391391

392-
### Current Approach: Offline Access with Refresh Tokens (Tier 1)
392+
> **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.
393393
394-
The MCP server currently uses **offline_access** scope to enable background operations:
394+
### Current Approach: Offline Access with Refresh Tokens
395+
396+
The MCP server uses **offline_access** scope to enable background operations:
395397

396398
**How it works:**
397399
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
412414
- ⚠️ Weak audit trail - API requests appear to come from user directly
413415
- ⚠️ No visibility that MCP Server is the actual actor
414416

415-
### Future Enhancement: Token Exchange with Delegation (Tier 2)
417+
### Token Exchange with Delegation (ADR-002 Tier 2 - Implemented)
416418

417419
**RFC 8693 Delegation** would provide better audit trail and security:
418420

docs/oauth-impersonation-findings.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@
55
**Status**: Implementation Complete - Token Exchange Working
66
**Conclusion**: Keycloak Standard Token Exchange (RFC 8693) working for internal-to-internal token exchange. User impersonation requires Legacy V1.
77

8+
---
9+
10+
## ⚠️ IMPORTANT UPDATE (2025-11-02)
11+
12+
**This document contains outdated information regarding service account tokens.**
13+
14+
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.
15+
16+
**Key Changes:**
17+
-**Service account tokens (client_credentials) are INVALID** - Creates user accounts, breaks audit trail
18+
-**Token exchange (RFC 8693) is the correct approach** - Implemented and working (ADR-002 Tier 2)
19+
-**Offline access with refresh tokens** - Still valid for background operations (ADR-002 primary approach)
20+
21+
**For current architecture, see**: `docs/ADR-002-vector-sync-authentication.md`
22+
23+
---
24+
825
## Summary
926

1027
We investigated options for implementing user impersonation to enable background operations without requiring admin credentials (ADR-002 Tier 2). Here are the findings:

nextcloud_mcp_server/auth/keycloak_oauth.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -342,9 +342,27 @@ async def get_service_account_token(self, scopes: list[str] | None = None) -> di
342342
"""
343343
Get a service account token using client_credentials grant.
344344
345-
This requires the client to have serviceAccountsEnabled=true in Keycloak.
346-
The service account token can be used for server-initiated operations
347-
or as the subject_token for token exchange.
345+
⚠️ **WARNING: DO NOT USE FOR DIRECT API ACCESS IN OAUTH MODE** ⚠️
346+
347+
This method creates a service account user in Nextcloud which VIOLATES
348+
OAuth "act on-behalf-of" principles. Using this token directly for API
349+
access will:
350+
- Create a Nextcloud user: `service-account-{client_id}`
351+
- Attribute all actions to service account instead of real user
352+
- Break audit trail and user attribution
353+
- Create stateful server identity in Nextcloud
354+
- Violate OAuth security model
355+
356+
**Valid Use Case**: ONLY as subject_token for RFC 8693 token exchange
357+
(ADR-002 Tier 2) where it's immediately exchanged for a user token.
358+
359+
**Invalid Use Case**: Direct API access with this token (ADR-002 rejected
360+
this as "Tier 1" - see docs/ADR-002-vector-sync-authentication.md).
361+
362+
**Alternative**: Use token exchange (impersonation/delegation) for
363+
background operations, or use BasicAuth mode if truly need service account.
364+
365+
This requires the client to have serviceAccountsEnabled=true in provider.
348366
349367
Args:
350368
scopes: Optional list of scopes to request (default: openid profile email)
@@ -359,9 +377,9 @@ async def get_service_account_token(self, scopes: list[str] | None = None) -> di
359377
Raises:
360378
httpx.HTTPError: If token request fails
361379
362-
Note:
363-
This is used for ADR-002 Tier 2 (Token Exchange). The service account
364-
token is exchanged for user-scoped tokens via RFC 8693.
380+
See Also:
381+
- ADR-002 "Will Not Implement" section for detailed critique
382+
- exchange_token_for_user() for proper token exchange usage
365383
"""
366384
if not self.token_endpoint:
367385
await self.discover()

tests/manual/test_token_exchange.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ async def main():
8383
logger.info("")
8484

8585
# Step 3: Get service account token
86+
# ⚠️ WARNING: Service account tokens MUST NOT be used directly with Nextcloud APIs!
87+
# Using this token directly violates OAuth "act on-behalf-of" principles:
88+
# - Creates Nextcloud user: service-account-{client_id}
89+
# - Breaks audit trail (actions not attributable to real user)
90+
# - Creates stateful server identity in Nextcloud
91+
#
92+
# VALID USE: ONLY as subject_token for RFC 8693 token exchange (Step 4 below)
93+
# INVALID USE: Direct API access (see ADR-002 "Will Not Implement" section)
94+
#
95+
# If you need background operations without token exchange support, use BasicAuth mode.
8696
logger.info("Step 3: Requesting service account token (client_credentials)...")
8797
try:
8898
service_token_response = await oauth_client.get_service_account_token(

0 commit comments

Comments
 (0)