refactor: integrate token exchange into unified get_client() pattern
Resolves the token exchange implementation gap where get_session_client() was implemented but never used by tools. Unifies token acquisition into a single async get_client() method that handles both pass-through and token exchange modes transparently. Core Changes: - Make get_client() async and merge token exchange logic into it - Remove scopes parameter from token exchange (Nextcloud doesn't support OAuth scopes) - Update all 8 tool modules to use await get_client(ctx) - Fix provisioning decorator to skip checks in BasicAuth mode Token Acquisition Modes: 1. BasicAuth: Returns shared client (no token operations) 2. OAuth pass-through (default): Verifies and passes Flow 1 token to Nextcloud 3. OAuth token exchange (opt-in): Exchanges Flow 1 token for ephemeral token via RFC 8693 Key Architectural Clarifications: - Progressive Consent (Flow 1/2) = Authorization architecture - Token Exchange = Token acquisition pattern during tool execution - Refresh tokens from Flow 2 are NEVER used for tool calls (only background jobs) - Nextcloud scopes are "soft-scopes" enforced by MCP server, not IdP Documentation Updates: - ADR-004: Added comprehensive token acquisition patterns section - CRITICAL-TOKEN-EXCHANGE-PATTERN.md: Updated to reflect implementation status - CLAUDE.md: Updated architectural patterns with async get_client() Testing: - All 36 unit tests passing - All 4 smoke tests passing (BasicAuth mode) - Linting issues fixed (ruff) Configuration: ENABLE_TOKEN_EXCHANGE=false (default) - pass-through mode ENABLE_TOKEN_EXCHANGE=true (opt-in) - token exchange mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1324,6 +1324,160 @@ grant_type=urn:ietf:params:oauth:grant-type:token-exchange
|
||||
- Configure audience claim per API
|
||||
- Use inline hooks for dynamic audiences
|
||||
|
||||
## Token Acquisition Patterns for MCP Tool Calls
|
||||
|
||||
### Progressive Consent vs Token Exchange
|
||||
|
||||
**IMPORTANT**: Progressive Consent and Token Exchange are complementary patterns that serve different purposes:
|
||||
|
||||
- **Progressive Consent** = Authorization architecture (when and why users grant access)
|
||||
- Flow 1: MCP client authenticates to MCP server
|
||||
- Flow 2: MCP server provisions Nextcloud access
|
||||
- Results in stored refresh tokens for background jobs
|
||||
|
||||
- **Token Exchange** = Token acquisition pattern (how tokens are obtained during tool execution)
|
||||
- Pass-through mode: Verify and pass Flow 1 token to Nextcloud
|
||||
- Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
|
||||
- Results in short-lived, operation-specific tokens
|
||||
|
||||
**Key Principle**: Refresh tokens from Progressive Consent (Flow 2) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs. This maintains clear separation between user-initiated operations and offline background work.
|
||||
|
||||
### Two Token Acquisition Modes
|
||||
|
||||
The MCP server supports two modes for obtaining Nextcloud tokens during tool execution:
|
||||
|
||||
#### Mode 1: Pass-Through (Default - ENABLE_TOKEN_EXCHANGE=false)
|
||||
|
||||
**How it works:**
|
||||
1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call
|
||||
2. MCP server validates token audience and scopes
|
||||
3. MCP server passes the same token to Nextcloud
|
||||
4. Nextcloud validates token with IdP
|
||||
|
||||
**Characteristics:**
|
||||
- Simple, stateless operation
|
||||
- Single token flows through the system
|
||||
- Lower latency (no token exchange round-trip)
|
||||
- Token lifetime determined by IdP's Flow 1 token settings
|
||||
|
||||
**Use case**: Simple deployments where Flow 1 tokens are trusted to access Nextcloud directly.
|
||||
|
||||
#### Mode 2: Token Exchange (Opt-In - ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
**How it works:**
|
||||
1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call
|
||||
2. MCP server validates token audience and scopes
|
||||
3. MCP server exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
||||
4. MCP server uses ephemeral token for Nextcloud API call
|
||||
5. Ephemeral token is discarded (not cached)
|
||||
|
||||
**Characteristics:**
|
||||
- Enhanced security through token delegation
|
||||
- Ephemeral tokens with minimal lifetime (5 minutes default)
|
||||
- Token exchange provides audit trail
|
||||
- Fallback to refresh grant if RFC 8693 not supported
|
||||
- Tokens never cached or stored
|
||||
|
||||
**Use case**: High-security environments requiring token delegation, audit trails, and minimal token lifetimes.
|
||||
|
||||
### Implementation in get_client()
|
||||
|
||||
The token acquisition mode is handled transparently by `get_client()`:
|
||||
|
||||
```python
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
This function handles three modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Verifies Flow 1 token and passes it to Nextcloud
|
||||
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
||||
"""
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# BasicAuth mode
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
|
||||
# OAuth mode
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
if settings.enable_token_exchange:
|
||||
# Token exchange mode
|
||||
return await get_session_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
else:
|
||||
# Pass-through mode (default)
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
```
|
||||
|
||||
### Nextcloud Scope Limitation
|
||||
|
||||
**CRITICAL**: Nextcloud does not support OAuth scopes natively. The scopes used in this architecture (e.g., "notes:read", "calendar:write") are **soft-scopes** enforced by the MCP server via the `@require_scopes` decorator, **not by the IdP or Nextcloud**.
|
||||
|
||||
**Implications:**
|
||||
1. Token exchange requests don't pass scopes to the IdP (Nextcloud doesn't validate them)
|
||||
2. The MCP server's `@require_scopes` decorator handles authorization checks
|
||||
3. All Nextcloud tokens have equivalent permissions at the Nextcloud level
|
||||
4. Fine-grained access control is enforced by the MCP server, not Nextcloud
|
||||
|
||||
**Why this matters:**
|
||||
- You cannot request a "notes-only" token from the IdP
|
||||
- Token exchange provides audit and delegation benefits, not scope restriction
|
||||
- Scopes are a convenience for MCP server authorization logic, not a security boundary
|
||||
|
||||
### Background Job Pattern
|
||||
|
||||
Background jobs use a **completely different** token acquisition pattern:
|
||||
|
||||
```python
|
||||
class BackgroundSyncWorker:
|
||||
async def sync_user_data(self, user_id: str):
|
||||
"""Background workers use refresh tokens from Flow 2, never from tool calls."""
|
||||
|
||||
# Get refresh token stored during Flow 2 (Progressive Consent)
|
||||
refresh_token = await self.storage.get_refresh_token(user_id)
|
||||
|
||||
# Use refresh token to get Nextcloud access token
|
||||
response = await self.idp_client.refresh_token(
|
||||
refresh_token=refresh_token,
|
||||
audience='nextcloud'
|
||||
)
|
||||
|
||||
# Use access token for background operations
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=self.nextcloud_url,
|
||||
token=response.access_token,
|
||||
username=user_id
|
||||
)
|
||||
|
||||
await self.sync_notes(user_id, client)
|
||||
```
|
||||
|
||||
**Key differences:**
|
||||
- Uses refresh tokens from Flow 2 (Progressive Consent provisioning)
|
||||
- Tokens can be cached for efficiency (longer-lived operations)
|
||||
- No user interaction possible (offline)
|
||||
- Different scopes than tool calls (e.g., "notes:sync" vs "notes:read")
|
||||
|
||||
### When to Enable Token Exchange
|
||||
|
||||
**Enable token exchange when:**
|
||||
- You need audit trails showing token delegation
|
||||
- You want minimal token lifetimes for security
|
||||
- Your IdP supports RFC 8693
|
||||
- You operate in a high-security environment
|
||||
|
||||
**Use pass-through mode when:**
|
||||
- Simplicity is more important than token delegation
|
||||
- Your IdP doesn't support RFC 8693
|
||||
- You trust Flow 1 tokens to access Nextcloud directly
|
||||
- Lower latency is a priority
|
||||
|
||||
**Both modes maintain the same security boundary**: Refresh tokens from Flow 2 are never used for tool calls, only for background jobs.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
The **Progressive Consent Architecture with Dual OAuth Flows** provides a secure, enterprise-ready solution for offline access while maintaining strict security boundaries and user transparency. By using separate OAuth flows for client authentication and resource provisioning, we achieve:
|
||||
|
||||
@@ -1,28 +1,40 @@
|
||||
# CRITICAL: Token Exchange Pattern for ADR-004
|
||||
# Token Acquisition Patterns for ADR-004 Progressive Consent
|
||||
|
||||
## Problem Statement
|
||||
## Overview
|
||||
|
||||
The current implementation of ADR-004 Progressive Consent does **NOT** correctly implement the token exchange pattern. This is a **critical architectural flaw** that must be corrected.
|
||||
ADR-004 Progressive Consent establishes the authorization architecture (Flow 1 for client auth, Flow 2 for resource provisioning). This document describes **how tokens are acquired for different operational contexts** within that architecture.
|
||||
|
||||
## Current (Incorrect) Implementation
|
||||
**Key Principle**: Refresh tokens from Flow 2 (Progressive Consent) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs.
|
||||
|
||||
### What Happens Now:
|
||||
## Implementation Status
|
||||
|
||||
**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
|
||||
|
||||
The MCP server supports two token acquisition modes:
|
||||
1. **Pass-through mode** (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
|
||||
2. **Token exchange mode** (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security with token delegation
|
||||
|
||||
Both modes maintain the critical separation: **refresh tokens are never used for tool calls**.
|
||||
|
||||
## Current Default (Pass-Through Mode)
|
||||
|
||||
### What Happens (ENABLE_TOKEN_EXCHANGE=false):
|
||||
1. Client gets Flow 1 token (`aud: "mcp-server"`)
|
||||
2. Client calls MCP tool
|
||||
3. Server validates Flow 1 token
|
||||
4. **WRONG**: Server uses stored refresh token to get Nextcloud token
|
||||
5. **WRONG**: Same refresh token used for all sessions and background jobs
|
||||
4. Server passes Flow 1 token to Nextcloud
|
||||
5. Nextcloud validates token with IdP
|
||||
6. Refresh tokens (from Flow 2) used **only** for background jobs
|
||||
|
||||
### Problems:
|
||||
- ❌ No separation between session tokens and background tokens
|
||||
- ❌ Refresh tokens are reused across different contexts
|
||||
- ❌ Session tokens could have different scope requirements than background tokens
|
||||
- ❌ No on-demand delegation during tool calls
|
||||
- ❌ Violates principle of least privilege
|
||||
### Characteristics:
|
||||
- ✅ Simple, stateless operation
|
||||
- ✅ Clear separation: Flow 1 tokens for sessions, refresh tokens for background
|
||||
- ✅ Lower latency (no token exchange round-trip)
|
||||
- ✅ Works with any OAuth IdP
|
||||
|
||||
## Correct Implementation Required
|
||||
## Optional Token Exchange Mode
|
||||
|
||||
### Token Exchange Pattern
|
||||
### Token Exchange Pattern (ENABLE_TOKEN_EXCHANGE=true)
|
||||
|
||||
**MCP Session (Foreground Operations)**:
|
||||
|
||||
@@ -119,116 +131,136 @@ Implement RFC 8693 Token Exchange:
|
||||
|
||||
async def exchange_token_for_delegation(
|
||||
flow1_token: str,
|
||||
requested_scopes: list[str],
|
||||
requested_audience: str = "nextcloud"
|
||||
requested_audience: str = "nextcloud",
|
||||
requested_scopes: list[str] | None = None
|
||||
) -> tuple[str, int]:
|
||||
"""
|
||||
Exchange Flow 1 MCP token for delegated Nextcloud token.
|
||||
|
||||
This implements RFC 8693 Token Exchange for on-behalf-of delegation.
|
||||
|
||||
IMPORTANT: Nextcloud doesn't support OAuth scopes natively. Scopes are
|
||||
soft-scopes enforced by the MCP server via @require_scopes decorator,
|
||||
not by the IdP or Nextcloud. Therefore, requested_scopes are not passed
|
||||
to the IdP during token exchange.
|
||||
|
||||
Args:
|
||||
flow1_token: The MCP session token (aud: "mcp-server")
|
||||
requested_scopes: Scopes needed for this operation
|
||||
requested_audience: Target audience (usually "nextcloud")
|
||||
requested_scopes: Ignored (Nextcloud doesn't support scopes)
|
||||
|
||||
Returns:
|
||||
Tuple of (delegated_token, expires_in)
|
||||
"""
|
||||
# 1. Validate Flow 1 token
|
||||
# 1. Validate Flow 1 token (audience check)
|
||||
# 2. Check user has provisioned Nextcloud access (Flow 2)
|
||||
# 3. Request token exchange from IdP
|
||||
# 3. Request token exchange from IdP (without scopes - Nextcloud doesn't support them)
|
||||
# 4. Return ephemeral delegated token
|
||||
```
|
||||
|
||||
### 2. Context-Aware Token Broker
|
||||
### 2. Unified get_client() Pattern
|
||||
|
||||
Update Token Broker to distinguish contexts:
|
||||
The token acquisition mode is handled transparently by `get_client()`:
|
||||
|
||||
```python
|
||||
class TokenBrokerService:
|
||||
async def get_session_token(
|
||||
self,
|
||||
flow1_token: str,
|
||||
required_scopes: list[str]
|
||||
) -> str:
|
||||
"""Get ephemeral token for MCP session (on-demand)."""
|
||||
# Exchange Flow 1 token for delegated token
|
||||
# DO NOT use stored refresh token
|
||||
# Return short-lived token
|
||||
# nextcloud_mcp_server/context.py
|
||||
|
||||
async def get_background_token(
|
||||
self,
|
||||
user_id: str,
|
||||
required_scopes: list[str]
|
||||
) -> str:
|
||||
"""Get token for background job (uses refresh token)."""
|
||||
# Use stored refresh token from Flow 2
|
||||
# Different scope requirements
|
||||
# Longer-lived token
|
||||
async def get_client(ctx: Context) -> NextcloudClient:
|
||||
"""
|
||||
Get the appropriate Nextcloud client based on authentication mode.
|
||||
|
||||
This function handles three modes:
|
||||
1. BasicAuth mode: Returns shared client from lifespan context
|
||||
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
||||
Verifies Flow 1 token and passes it to Nextcloud
|
||||
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
||||
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
||||
"""
|
||||
settings = get_settings()
|
||||
lifespan_ctx = ctx.request_context.lifespan_context
|
||||
|
||||
# BasicAuth mode - use shared client (no token exchange)
|
||||
if hasattr(lifespan_ctx, "client"):
|
||||
return lifespan_ctx.client
|
||||
|
||||
# OAuth mode (has 'nextcloud_host' attribute)
|
||||
if hasattr(lifespan_ctx, "nextcloud_host"):
|
||||
# Check if token exchange is enabled
|
||||
if settings.enable_token_exchange:
|
||||
# Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
|
||||
return await get_session_client_from_context(
|
||||
ctx, lifespan_ctx.nextcloud_host
|
||||
)
|
||||
else:
|
||||
# Pass-through mode (default): Verify and pass Flow 1 token to Nextcloud
|
||||
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
||||
```
|
||||
|
||||
### 3. Update MCP Tool Pattern
|
||||
### 3. MCP Tool Pattern (No Changes Required!)
|
||||
|
||||
Tools should request token exchange:
|
||||
Tools use the same pattern regardless of token acquisition mode:
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
@require_scopes("notes:read")
|
||||
@require_scopes("notes:read") # Soft-scope enforced by MCP server, not Nextcloud
|
||||
@require_provisioning
|
||||
async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse:
|
||||
"""Search notes by title or content."""
|
||||
|
||||
# Extract Flow 1 token from context
|
||||
flow1_token = ctx.authorization.token
|
||||
|
||||
# Get Token Broker
|
||||
broker = get_token_broker()
|
||||
|
||||
# CRITICAL: Exchange for delegated token
|
||||
nextcloud_token = await broker.get_session_token(
|
||||
flow1_token=flow1_token,
|
||||
required_scopes=["notes:read"] # Minimal scopes for this operation
|
||||
)
|
||||
|
||||
# Create Nextcloud client with delegated token
|
||||
client = await create_nextcloud_client(
|
||||
host=NEXTCLOUD_HOST,
|
||||
token=nextcloud_token # Ephemeral delegated token
|
||||
)
|
||||
# get_client() handles both pass-through and token exchange modes
|
||||
client = await get_client(ctx)
|
||||
|
||||
# Execute operation
|
||||
results = await client.notes_search_notes(query=query)
|
||||
results = await client.notes.search_notes(query=query)
|
||||
|
||||
# Token automatically expires - NOT stored
|
||||
# In token exchange mode, ephemeral token is automatically discarded
|
||||
# In pass-through mode, Flow 1 token was validated and passed through
|
||||
return SearchNotesResponse(results=results)
|
||||
```
|
||||
|
||||
**Key Benefit**: Tools don't need to know which mode is active. The token acquisition pattern is configured at the server level via `ENABLE_TOKEN_EXCHANGE`.
|
||||
|
||||
### 4. Background Job Pattern
|
||||
|
||||
Background jobs use a **different token acquisition pattern** - they use refresh tokens from Flow 2:
|
||||
|
||||
```python
|
||||
# Background worker
|
||||
async def sync_notes_job(user_id: str):
|
||||
"""Background job to sync notes."""
|
||||
|
||||
broker = get_token_broker()
|
||||
# Get refresh token stored during Flow 2 (Progressive Consent)
|
||||
token_storage = get_token_storage()
|
||||
refresh_token = await token_storage.get_refresh_token(user_id)
|
||||
|
||||
# CRITICAL: Use background token pattern
|
||||
background_token = await broker.get_background_token(
|
||||
user_id=user_id,
|
||||
required_scopes=["notes:sync", "files:sync"] # Background-specific scopes
|
||||
if not refresh_token:
|
||||
logger.warning(f"No refresh token for user {user_id}")
|
||||
return
|
||||
|
||||
# Use refresh token to get Nextcloud access token
|
||||
idp_client = get_idp_client()
|
||||
response = await idp_client.refresh_token(
|
||||
refresh_token=refresh_token,
|
||||
audience='nextcloud'
|
||||
)
|
||||
|
||||
# Create client with background token
|
||||
client = await create_nextcloud_client(
|
||||
host=NEXTCLOUD_HOST,
|
||||
token=background_token
|
||||
# Create client with background token (can be cached)
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=NEXTCLOUD_HOST,
|
||||
token=response.access_token,
|
||||
username=user_id
|
||||
)
|
||||
|
||||
# Perform background sync
|
||||
await client.notes.sync_all()
|
||||
```
|
||||
|
||||
**Key differences from tool calls:**
|
||||
- Uses refresh tokens from Flow 2 (Progressive Consent provisioning)
|
||||
- Tokens can be cached for efficiency (longer-lived operations)
|
||||
- No user interaction possible (offline)
|
||||
- Never triggered during MCP tool execution
|
||||
|
||||
## Security Benefits
|
||||
|
||||
### Proper Token Exchange:
|
||||
@@ -276,15 +308,41 @@ async def sync_notes_job(user_id: str):
|
||||
|
||||
## Status
|
||||
|
||||
**Current Status**: ❌ CRITICAL ISSUE - Token exchange not implemented
|
||||
**Target Status**: ✅ Proper token exchange with session/background separation
|
||||
**Priority**: **P0 - Blocker for production use**
|
||||
**Current Status**: ✅ Token exchange infrastructure implemented, available as opt-in feature
|
||||
**Modes Available**:
|
||||
- ✅ Pass-through mode (default, `ENABLE_TOKEN_EXCHANGE=false`): Simple, stateless
|
||||
- ✅ Token exchange mode (opt-in, `ENABLE_TOKEN_EXCHANGE=true`): Enhanced security
|
||||
|
||||
## Next Actions
|
||||
**Implementation Complete**:
|
||||
- ✅ `token_exchange.py` module with RFC 8693 support
|
||||
- ✅ Fallback to refresh grant when RFC 8693 not supported
|
||||
- ✅ `get_client()` unified pattern (handles both modes transparently)
|
||||
- ✅ Tokens never cached in token exchange mode (ephemeral)
|
||||
- ✅ Background jobs use separate pattern (refresh tokens from Flow 2)
|
||||
|
||||
1. [ ] Implement `token_exchange.py` module with RFC 8693 support
|
||||
2. [ ] Update `TokenBrokerService` with session vs background methods
|
||||
3. [ ] Refactor MCP tools to use token exchange pattern
|
||||
4. [ ] Add integration tests for token exchange
|
||||
5. [ ] Document background job patterns
|
||||
6. [ ] Update ADR-004 with implementation details
|
||||
## Configuration
|
||||
|
||||
To enable token exchange mode:
|
||||
|
||||
```bash
|
||||
# docker-compose.yml or .env
|
||||
ENABLE_TOKEN_EXCHANGE=true
|
||||
```
|
||||
|
||||
When enabled, all MCP tool calls will use token exchange (RFC 8693) to obtain ephemeral Nextcloud tokens. When disabled (default), Flow 1 tokens are passed through to Nextcloud.
|
||||
|
||||
## Nextcloud Scope Limitation
|
||||
|
||||
**IMPORTANT**: Nextcloud does not support OAuth scopes natively. Scopes like "notes:read" are **soft-scopes** enforced by the MCP server via `@require_scopes` decorator, not by the IdP or Nextcloud.
|
||||
|
||||
This means:
|
||||
- Token exchange provides audit and delegation benefits, not scope restriction
|
||||
- All Nextcloud tokens have equivalent permissions at the Nextcloud level
|
||||
- Fine-grained access control is enforced by MCP server, not Nextcloud
|
||||
|
||||
## Next Actions (Optional Enhancements)
|
||||
|
||||
1. [ ] Add integration tests for token exchange mode with actual MCP tools
|
||||
2. [ ] Document background job patterns for scheduled sync operations
|
||||
3. [ ] Add metrics for token exchange performance
|
||||
4. [ ] Consider making token exchange the default in future major version
|
||||
|
||||
Reference in New Issue
Block a user