Files
nextcloud-mcp-server/docs/CRITICAL-TOKEN-EXCHANGE-PATTERN.md
Chris Coutinho 71e77e95bc 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>
2025-11-03 20:33:56 +01:00

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:

  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. Server passes Flow 1 token to Nextcloud
  5. Nextcloud validates token with IdP
  6. 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:read for 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:

  1. Least Privilege: Each operation gets only needed scopes
  2. Time-Limited: Session tokens expire quickly
  3. Audit Trail: Each exchange can be logged
  4. Token Isolation: Session ≠ Background tokens
  5. Revocation: Can revoke background access without affecting active sessions

Current Incorrect Pattern:

  1. Over-Privileged: Refresh token has all scopes
  2. Long-Lived: Same token reused indefinitely
  3. No Separation: Sessions and background jobs use same credential
  4. Revocation Issues: Revoking affects everything

Implementation Steps

Phase 1: Token Exchange (High Priority)

  1. Implement RFC 8693 token exchange endpoint
  2. Update Token Broker with get_session_token() vs get_background_token()
  3. Modify tool pattern to use token exchange

Phase 2: Scope Separation (High Priority)

  1. Define session scopes vs background scopes
  2. Update provisioning flow to request appropriate scopes
  3. Validate scopes in token exchange

Phase 3: Background Jobs (Medium Priority)

  1. Implement background worker pattern
  2. Create scheduled jobs (note sync, etc.)
  3. Use background token pattern

Phase 4: Testing (High Priority)

  1. Test token exchange flow end-to-end
  2. Verify session tokens are ephemeral
  3. Verify background tokens are separate
  4. 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.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)

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)

  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