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>
15 KiB
Token Acquisition Patterns for ADR-004 Progressive Consent
Overview
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.
Key Principle: Refresh tokens from Flow 2 (Progressive Consent) should NEVER be used for MCP tool calls - they are exclusively for background jobs.
Implementation Status
Current Status: ✅ Token exchange infrastructure implemented, available as opt-in feature
The MCP server supports two token acquisition modes:
- Pass-through mode (default,
ENABLE_TOKEN_EXCHANGE=false): Simple, stateless - 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):
- Client gets Flow 1 token (
aud: "mcp-server") - Client calls MCP tool
- Server validates Flow 1 token
- Server passes Flow 1 token to Nextcloud
- Nextcloud validates token with IdP
- Refresh tokens (from Flow 2) used only for background jobs
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
Optional Token Exchange Mode
Token Exchange Pattern (ENABLE_TOKEN_EXCHANGE=true)
MCP Session (Foreground Operations):
┌─────────────┐ Flow 1 Token ┌──────────────┐
│ MCP Client │ ───(aud: mcp-server)──> │ MCP Server │
└─────────────┘ └──────────────┘
│
Tool Call │
"search_notes()" │
▼
┌─────────────────────┐
│ Token Exchange │
│ 1. Validate Flow 1 │
│ 2. Check permission │
│ 3. Request delegated│
│ Nextcloud token │
└─────────────────────┘
│
│ Exchange Request
▼
┌─────────────────────┐
│ IdP Token Endpoint │
│ (Token Exchange) │
└─────────────────────┘
│
│ Delegated Token
│ (aud: nextcloud)
│ (limited scopes)
│ (short-lived)
▼
┌─────────────────────┐
│ Nextcloud API Call │
│ GET /notes │
└─────────────────────┘
Key Properties of Session Tokens:
- ✅ Generated on-demand during tool execution
- ✅ Ephemeral - used only for current operation
- ✅ NOT stored - discarded after use
- ✅ Limited scopes - only what tool needs (e.g.,
notes:readfor search) - ✅ Short-lived - expires quickly (e.g., 5 minutes)
Background Jobs (Offline Operations):
┌─────────────────┐ Scheduled Job ┌──────────────┐
│ Background │ ──────────────────────> │ Worker │
│ Scheduler │ │ Process │
└─────────────────┘ └──────────────┘
│
│ Use stored
│ refresh token
▼
┌─────────────────────┐
│ Refresh Token Store │
│ (Flow 2 provisioned)│
└─────────────────────┘
│
│ Refresh Token
▼
┌─────────────────────┐
│ IdP Token Endpoint │
│ (Refresh Grant) │
└─────────────────────┘
│
│ Background Token
│ (aud: nextcloud)
│ (different scopes)
│ (longer-lived)
▼
┌─────────────────────┐
│ Nextcloud API │
│ (Background Sync) │
└─────────────────────┘
Key Properties of Background Tokens:
- ✅ Obtained from stored refresh token (Flow 2)
- ✅ Different scopes than session tokens (e.g.,
notes:sync,files:sync) - ✅ Longer-lived for background operations
- ✅ Never used for MCP sessions
- ✅ Only for offline/background jobs
Implementation Requirements
1. Token Exchange Endpoint
Implement RFC 8693 Token Exchange:
# nextcloud_mcp_server/auth/token_exchange.py
async def exchange_token_for_delegation(
flow1_token: str,
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_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 (audience check)
# 2. Check user has provisioned Nextcloud access (Flow 2)
# 3. Request token exchange from IdP (without scopes - Nextcloud doesn't support them)
# 4. Return ephemeral delegated token
2. Unified get_client() Pattern
The token acquisition mode is handled transparently by get_client():
# nextcloud_mcp_server/context.py
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. MCP Tool Pattern (No Changes Required!)
Tools use the same pattern regardless of token acquisition mode:
@mcp.tool()
@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."""
# 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)
# 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:
# Background worker
async def sync_notes_job(user_id: str):
"""Background job to sync notes."""
# Get refresh token stored during Flow 2 (Progressive Consent)
token_storage = get_token_storage()
refresh_token = await token_storage.get_refresh_token(user_id)
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 (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:
- ✅ Least Privilege: Each operation gets only needed scopes
- ✅ Time-Limited: Session tokens expire quickly
- ✅ Audit Trail: Each exchange can be logged
- ✅ Token Isolation: Session ≠ Background tokens
- ✅ Revocation: Can revoke background access without affecting active sessions
Current Incorrect Pattern:
- ❌ Over-Privileged: Refresh token has all scopes
- ❌ Long-Lived: Same token reused indefinitely
- ❌ No Separation: Sessions and background jobs use same credential
- ❌ Revocation Issues: Revoking affects everything
Implementation Steps
Phase 1: Token Exchange (High Priority)
- Implement RFC 8693 token exchange endpoint
- Update Token Broker with
get_session_token()vsget_background_token() - Modify tool pattern to use token exchange
Phase 2: Scope Separation (High Priority)
- Define session scopes vs background scopes
- Update provisioning flow to request appropriate scopes
- Validate scopes in token exchange
Phase 3: Background Jobs (Medium Priority)
- Implement background worker pattern
- Create scheduled jobs (note sync, etc.)
- Use background token pattern
Phase 4: Testing (High Priority)
- Test token exchange flow end-to-end
- Verify session tokens are ephemeral
- Verify background tokens are separate
- Load test token exchange performance
References
- RFC 8693: OAuth 2.0 Token Exchange
- RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens
- ADR-004: Progressive Consent OAuth Flows
- OAuth 2.0 Delegation: On-Behalf-Of vs Impersonation patterns
Status
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
Implementation Complete:
- ✅
token_exchange.pymodule 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)
Configuration
To enable token exchange mode:
# 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)
- Add integration tests for token exchange mode with actual MCP tools
- Document background job patterns for scheduled sync operations
- Add metrics for token exchange performance
- Consider making token exchange the default in future major version