Skip to content

Commit 34df5f5

Browse files
cbcoutinhoclaude
andcommitted
feat: Implement dual-tier token exchange (Standard V2 + Legacy V1 impersonation)
This commit implements and documents both RFC 8693 token exchange tiers from ADR-002, enabling both production-ready delegation and advanced impersonation capabilities. - Enable Keycloak preview features (`--features=preview`) to support both Standard V2 and Legacy V1 token exchange modes - Update Tier 1 status from "NOT IMPLEMENTED" to "IMPLEMENTED (Legacy V1)" - Add detailed empirical testing results showing: - Standard V2 rejects `requested_subject` parameter - Legacy V1 accepts parameter but requires impersonation permissions - Complete configuration steps for enabling impersonation - Add comparison table showing when to use each tier - Add "When to Use" guidance for both tiers - Document that Tier 2 (Delegation) is the recommended default - Update docstring to document both Tier 1 and Tier 2 support - Add tier-specific logging (shows which tier is being used) - Document permission requirements for Tier 1 impersonation **tests/integration/auth/test_token_exchange_standard_v2.py**: - Test delegation without impersonation (Tier 2) - Verify sub claim remains unchanged (service account identity) - Verify no special permissions required - Test exchanged tokens work with Nextcloud APIs - All tests PASS ✅ **tests/integration/auth/test_token_exchange_legacy_v1.py**: - Test impersonation with `requested_subject` (Tier 1) - Verify sub claim changes to target user - Auto-skip if impersonation permissions not configured - Document permission requirements in test docstrings - Test exchanged tokens work with Nextcloud APIs **tests/manual/test_impersonation.py**: - Comprehensive impersonation validation script - Tests both Standard V2 and Legacy V1 behavior - Decodes JWT tokens to verify sub claim changes - Validates tokens against Nextcloud APIs **tests/manual/configure_impersonation.py**: - Automated permission configuration helper - Documents manual Keycloak CLI configuration steps Both token exchange tiers are now fully implemented and tested: - **Tier 2 (Delegation)** - ✅ RECOMMENDED - Standard V2 (production-ready) - No special permissions required - Service account identity preserved - **Tier 1 (Impersonation)** - ✅ Advanced use only - Legacy V1 (--features=preview required) - Requires manual permission grant via Keycloak CLI - Subject claim changes to target user 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent e26c512 commit 34df5f5

File tree

7 files changed

+1215
-35
lines changed

7 files changed

+1215
-35
lines changed

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ services:
116116
- "--hostname=http://localhost:8888"
117117
- "--hostname-strict=false"
118118
- "--hostname-backchannel-dynamic=true"
119+
- "--features=preview" # Enable Legacy V1 token exchange (supports both Standard V2 and Legacy V1)
119120
ports:
120121
- 127.0.0.1:8888:8080
121122
environment:

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

Lines changed: 172 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -139,47 +139,160 @@ We will implement a **tiered OAuth authentication strategy** for background oper
139139

140140
## Implementation Details
141141

142-
### 1. Token Exchange with Impersonation (Tier 1) ⚠️ NOT IMPLEMENTED
142+
### 1. Token Exchange with Impersonation (Tier 1) IMPLEMENTED (Legacy V1 only)
143143

144-
This tier is documented for completeness but is not currently implemented due to lack of provider support.
144+
**Status**: Implemented and working with Keycloak Legacy V1 (`--features=preview`). Requires additional permission configuration. Recommended for advanced use cases only.
145145

146-
#### 1.1 Impersonation Flow (Conceptual)
146+
**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.
147+
148+
#### 1.1 Impersonation Flow
147149

148150
```python
149-
async def exchange_for_impersonated_user_token(
150-
service_token: str,
151+
async def exchange_token_for_user(
152+
subject_token: str,
151153
target_user_id: str,
152-
scopes: list[str]
153-
) -> str:
154-
"""Exchange service token to impersonate specific user (NOT IMPLEMENTED)"""
154+
audience: str | None = None,
155+
scopes: list[str] | None = None,
156+
) -> dict:
157+
"""Exchange service token to impersonate specific user.
158+
159+
Requires Keycloak Legacy V1 (--features=preview) and impersonation permissions.
160+
The returned token will have the target_user_id as the 'sub' claim.
161+
"""
162+
data = {
163+
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
164+
"subject_token": subject_token,
165+
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
166+
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
167+
"requested_subject": target_user_id, # ← KEY: Impersonate this user
168+
}
169+
170+
if audience:
171+
data["audience"] = audience
172+
if scopes:
173+
data["scope"] = " ".join(scopes)
174+
175+
response = await self._http_client.post(
176+
self.token_endpoint,
177+
data=data,
178+
auth=(self.client_id, self.client_secret),
179+
)
180+
response.raise_for_status()
181+
return response.json()
182+
```
155183

156-
async with httpx.AsyncClient() as client:
157-
response = await client.post(
158-
token_endpoint,
159-
data={
160-
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
161-
"subject_token": service_token,
162-
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
163-
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
164-
"requested_subject": target_user_id, # Impersonate this user
165-
"audience": "nextcloud",
166-
"scope": " ".join(scopes)
167-
},
168-
auth=(client_id, client_secret)
169-
)
184+
**Implementation Requirements**:
185+
- ✅ Keycloak Legacy V1 with `--features=preview` flag
186+
- ✅ Impersonation role granted to service account (see configuration below)
187+
- ❌ NOT supported in Keycloak Standard V2 (rejects `requested_subject` parameter)
188+
- ⚠️ Very few OIDC providers support user impersonation via token exchange
170189

171-
response.raise_for_status()
172-
return response.json()["access_token"]
190+
**Empirical Testing (2025-11-02)**:
191+
192+
Tested impersonation with `requested_subject` parameter against Keycloak 26.4.2:
193+
194+
**Test Command**: `uv run python tests/manual/test_impersonation.py`
195+
196+
**Keycloak Standard V2 Result**:
197+
```
198+
HTTP/1.1 400 Bad Request
199+
{
200+
"error": "invalid_request",
201+
"error_description": "Parameter 'requested_subject' is not supported for standard token exchange"
202+
}
173203
```
174204

175-
**Why Not Implemented**:
176-
- Keycloak Standard V2 doesn't support `requested_subject` parameter
177-
- Requires Legacy Keycloak V1 with preview features (not production-ready)
178-
- Very few OIDC providers support user impersonation via token exchange
205+
**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.
179206

180-
**See**: `docs/oauth-impersonation-findings.md` for detailed investigation
207+
**Keycloak Legacy V1 Result - Initial Test** (with `--features=preview`):
208+
```
209+
HTTP/1.1 403 Forbidden
210+
{
211+
"error": "access_denied",
212+
"error_description": "Client not allowed to exchange"
213+
}
214+
215+
Keycloak logs:
216+
reason="subject not allowed to impersonate"
217+
impersonator="service-account-nextcloud-mcp-server"
218+
requested_subject="admin"
219+
```
220+
221+
**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.
222+
223+
**Configuration Steps to Enable Impersonation**:
224+
225+
1. **Enable Keycloak preview features** (in docker-compose.yml):
226+
```yaml
227+
command:
228+
- "start-dev"
229+
- "--features=preview" # Required for Legacy V1 token exchange
230+
```
231+
232+
2. **Grant impersonation role to service account** (using Keycloak CLI):
233+
```bash
234+
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \
235+
--server http://localhost:8080 \
236+
--realm master \
237+
--user admin \
238+
--password admin
239+
240+
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \
241+
-r nextcloud-mcp \
242+
--uusername service-account-nextcloud-mcp-server \
243+
--cclientid realm-management \
244+
--rolename impersonation
245+
```
181246

182-
### 2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED
247+
**Keycloak Legacy V1 Result - After Permission Grant**:
248+
```
249+
✅ Token exchange with impersonation SUCCEEDED!
250+
251+
📊 Response details:
252+
Issued token type: urn:ietf:params:oauth:token-type:access_token
253+
Token type: Bearer
254+
Expires in: 300s
255+
256+
📋 Token claims analysis:
257+
Subject (sub): 47c3ba5a-9104-45e0-b84e-0e39ab942c9c (admin user)
258+
Preferred username: admin
259+
Client ID (azp): nextcloud-mcp-server
260+
261+
✅ IMPERSONATION VERIFIED:
262+
Original sub: service-account-nextcloud-mcp-server
263+
New sub: 47c3ba5a-9104-45e0-b84e-0e39ab942c9c
264+
➡️ The subject claim CHANGED - impersonation worked!
265+
```
266+
267+
**Nextcloud API Validation**:
268+
The impersonated token successfully authenticated with Nextcloud APIs, confirming the token is valid and properly represents the target user.
269+
270+
**Implementation Status**: Impersonation **IS IMPLEMENTED** and working with Keycloak Legacy V1. The implementation has been tested and verified to work correctly when properly configured.
271+
272+
**Production Considerations**:
273+
- ⚠️ Requires preview features (`--features=preview`) - not production-ready
274+
- ⚠️ Requires Legacy V1 token exchange (may be deprecated in future Keycloak versions)
275+
- ⚠️ Requires manual CLI configuration for each service account
276+
- ⚠️ More complex permission model compared to delegation
277+
278+
**When to Use Tier 1 (Impersonation)**:
279+
- ✅ You need the exchanged token to have the exact same identity as the target user
280+
- ✅ You want the cleanest separation (sub claim changes completely)
281+
- ✅ Your environment can support preview features
282+
- ✅ You have operational processes to manage impersonation permissions
283+
284+
**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.
285+
286+
**Test Scripts**:
287+
- `tests/manual/test_impersonation.py` - Complete impersonation test with validation
288+
- `tests/manual/configure_impersonation.py` - Automated permission configuration helper
289+
- **See**: `docs/oauth-impersonation-findings.md` for detailed investigation
290+
291+
### 2. Token Exchange with Delegation (Tier 2) ✅ IMPLEMENTED (Standard V2)
292+
293+
**Status**: Implemented and working with Keycloak Standard V2 (production-ready). This is the **recommended** approach for most use cases.
294+
295+
**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.
183296

184297
#### 2.1 Capability Detection
185298
```python
@@ -230,6 +343,35 @@ async def exchange_for_user_token(
230343

231344
**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)
232345

346+
### 3. Comparison: When to Use Each Tier
347+
348+
| Feature | Tier 1: Impersonation | Tier 2: Delegation (Recommended) |
349+
|---------|----------------------|-----------------------------------|
350+
| **Status** | ✅ Implemented (Legacy V1) | ✅ Implemented (Standard V2) |
351+
| **Token Identity** | Target user (`sub` changes) | Service account (`sub` unchanged) |
352+
| **Keycloak Version** | Legacy V1 (`--features=preview`) | Standard V2 (production-ready) |
353+
| **Setup Complexity** | High (manual permissions) | Low (automatic) |
354+
| **Production Ready** | ⚠️ Preview features required | ✅ Fully production-ready |
355+
| **Permission Grant** | Manual CLI per service account | Automatic via token exchange |
356+
| **Audit Trail** | Shows as target user | Shows as service account acting for user |
357+
| **Token Claims** | `sub: user-id` | `sub: service-account-id` |
358+
| **Provider Support** | Rare (Keycloak Legacy V1 only) | Common (Keycloak, Auth0, Okta) |
359+
| **Use Case** | Need exact user identity | Standard OAuth workflows |
360+
| **Recommendation** | Advanced use only | **Default choice** |
361+
362+
**Decision Guide**:
363+
-**Use Tier 2 (Delegation)** for:
364+
- Production deployments
365+
- Standard OAuth workflows
366+
- Clear audit trails (service account visible)
367+
- Maximum provider compatibility
368+
369+
- ⚠️ **Use Tier 1 (Impersonation)** only if:
370+
- You specifically need exact user identity (sub claim must match)
371+
- You can accept preview/experimental features
372+
- You have operational processes for permission management
373+
- Your IdP supports `requested_subject` parameter
374+
233375
### 4. Sync Worker with Tiered Authentication
234376

235377
```python

nextcloud_mcp_server/auth/keycloak_oauth.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -455,11 +455,27 @@ async def exchange_token_for_user(
455455
)
456456
457457
Note:
458-
This implements ADR-002 Tier 2. Requires:
459-
- Keycloak Standard Token Exchange V2 enabled (default in modern Keycloak)
458+
This implements BOTH ADR-002 tiers:
459+
460+
**Tier 2 (Delegation - Recommended)**: When target_user_id is None
461+
- Uses Keycloak Standard V2 (production-ready)
462+
- Service account maintains its identity (sub claim unchanged)
463+
- No special permissions required
464+
465+
**Tier 1 (Impersonation - Advanced)**: When target_user_id is provided
466+
- Requires Keycloak Legacy V1 (--features=preview)
467+
- Subject claim changes to target user
468+
- Requires impersonation role granted via Keycloak CLI:
469+
```
470+
kcadm.sh add-roles -r <realm> \
471+
--uusername service-account-<client-id> \
472+
--cclientid realm-management \
473+
--rolename impersonation
474+
```
475+
476+
Both tiers require:
460477
- Client has token.exchange.grant.enabled=true
461478
- Client has serviceAccountsEnabled=true
462-
- Appropriate exchange permissions configured in Keycloak
463479
"""
464480
if not self.token_endpoint:
465481
await self.discover()
@@ -483,10 +499,17 @@ async def exchange_token_for_user(
483499
data["scope"] = " ".join(scopes)
484500

485501
if target_user_id:
502+
# Tier 1: Impersonation (Legacy V1)
486503
# Use requested_subject for user impersonation
487504
data["requested_subject"] = target_user_id
488-
489-
logger.info(f"Exchanging token for user: {target_user_id or 'current'}")
505+
logger.info(
506+
f"Exchanging token with impersonation (Tier 1): target_user={target_user_id}"
507+
)
508+
else:
509+
# Tier 2: Delegation (Standard V2)
510+
logger.info(
511+
"Exchanging token with delegation (Tier 2): service account identity preserved"
512+
)
490513

491514
client = await self._get_http_client()
492515
response = await client.post(

0 commit comments

Comments
 (0)