From 9d514f52b0ca21660b46a0d08752ddc08d9b792c Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Mon, 3 Nov 2025 02:55:27 +0100 Subject: [PATCH] docs: Refactor ADR-004 to Progressive Consent architecture with dual OAuth flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hybrid flow model with true progressive consent where MCP client authenticates directly to IdP (Flow 1) and server requests separate explicit provisioning for Nextcloud access (Flow 2). This separates client authentication from resource authorization, uses distinct client_id for each flow, and keeps server stateless by default until user explicitly grants offline access via provision_nextcloud_access tool. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/ADR-004-mcp-application-oauth.md | 846 +++++++++++++------------- 1 file changed, 431 insertions(+), 415 deletions(-) diff --git a/docs/ADR-004-mcp-application-oauth.md b/docs/ADR-004-mcp-application-oauth.md index 7db6437..a1f793d 100644 --- a/docs/ADR-004-mcp-application-oauth.md +++ b/docs/ADR-004-mcp-application-oauth.md @@ -43,71 +43,122 @@ The MCP server will: ## Architecture -### Token Broker Architecture with Linked Authorization +### Progressive Consent Architecture with Dual OAuth Flows -The MCP server acts as a **token broker** using a linked authorization pattern: +The MCP server implements **progressive consent** - separate authorization flows for client authentication and resource access. This architecture uses **two distinct, sequential OAuth flows**: -#### The Core Challenge -When the MCP client authenticates to the MCP server, we need to: -1. Authenticate the client to the MCP server (audience: "mcp-server") -2. Obtain refresh tokens for Nextcloud access (audience: "nextcloud") -3. Do this in a single OAuth flow from the user's perspective +#### The Core Principle -#### Solution: Linked Authorization with Scope-Based Audiences +Three separate OAuth clients are registered at the Identity Provider (IdP): +1. **MCP Client** (e.g., `client_id="claude-desktop"`) - The native application +2. **MCP Server** (e.g., `client_id="mcp-server"`) - The intermediary server +3. **Nextcloud** (e.g., `client_id="nextcloud"`) - The resource server -During initial OAuth authorization, the MCP server requests: -- **Scopes**: `openid profile offline_access nextcloud:*` -- **Initial audience**: `mcp-server` (for client authentication) -- **Linked resources**: Configured in Keycloak to allow refresh tokens to mint tokens for Nextcloud +**CRITICAL**: Each flow uses a DIFFERENT `client_id` for proper OAuth delegation. -The IdP (Keycloak) is configured to: -1. Issue initial access token with `audience: "mcp-server"` -2. Issue refresh token that can obtain tokens for BOTH audiences based on requested scopes -3. Allow the MCP server to request different audiences when using the refresh token +#### Flow 1: Client Authentication (Always Required) + +The MCP client authenticates itself to the MCP server using its own OAuth credentials: + +- **Initiator**: MCP Client +- **Client ID**: `claude-desktop` (the MCP client's own ID) +- **Scopes**: `openid profile mcp-server:api` +- **Flow**: Standard PKCE OAuth 2.0 +- **User Consent**: "Allow **Claude Desktop** to access **MCP Server**?" +- **Result**: Access token with `aud: "mcp-server"` +- **Server State**: **STATELESS** - server just validates tokens, has no Nextcloud access + +At this point: +- βœ… MCP client can authenticate to MCP server +- ❌ MCP server CANNOT access Nextcloud APIs +- ❌ No refresh tokens stored anywhere + +#### Flow 2: Resource Provisioning (Triggered Explicitly) + +When the user attempts to use a Nextcloud tool, the server initiates a second OAuth flow to obtain delegated access: + +- **Trigger**: User calls a Nextcloud tool (e.g., `list_notes`) and server has no refresh token +- **Server Response**: Error message directing user to call `provision_nextcloud_access` tool +- **Initiator**: MCP Server (on user's explicit request) +- **Client ID**: `mcp-server` (the SERVER's own ID) +- **Scopes**: `openid offline_access nextcloud:api nextcloud:notes:* nextcloud:calendar:*` +- **Flow**: Standard OAuth 2.0 authorization code flow +- **User Consent**: "Allow **MCP Server** to access **Nextcloud** offline on your behalf?" +- **Result**: Refresh token with `aud: "nextcloud"` +- **Server State**: **STATEFUL** - server stores encrypted refresh token + +After provisioning: +- βœ… MCP client still authenticates with `aud: "mcp-server"` tokens +- βœ… MCP server can now access Nextcloud APIs using stored refresh token +- βœ… Background workers can operate offline #### Token Types and Lifecycles 1. **MCP Access Tokens** (audience: "mcp-server") - - Initial token from OAuth flow - - Authenticates MCP clients to MCP server + - Issued to MCP client in Flow 1 + - Authenticates MCP client to MCP server - Short-lived (1 hour) - Cannot access Nextcloud directly + - MCP client sends with every request 2. **Nextcloud Access Tokens** (audience: "nextcloud") - - Obtained by MCP server using refresh token with audience parameter + - Obtained by MCP server using stored refresh token - Used for Nextcloud API access - Never exposed to MCP clients - - Refreshed as needed using stored refresh token + - Short-lived (5-15 minutes), cached by server -3. **Master Refresh Token** - - Issued during initial OAuth with `offline_access` scope - - Can mint tokens for multiple configured audiences - - Stored encrypted by MCP server - - Enables both MCP authentication and Nextcloud access +3. **Master Refresh Token** (can mint audience: "nextcloud" tokens) + - Issued to MCP server in Flow 2 + - Stored encrypted in server's database + - Enables offline access to Nextcloud + - Used by server to mint Nextcloud access tokens ``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ MCP Client │◄──────401──────│ MCP Server │◄───Exchange────│ Shared IdP │──Validates──►│ Nextcloud β”‚ -β”‚ (Native) β”‚ β”‚ (Token Broker) β”‚ Tokens β”‚ (Keycloak) β”‚ Tokens β”‚(Resource) β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ - β”‚ β”‚ β”‚ - β”‚ Token (aud: mcp-server) β”‚ β”‚ - β”‚ Via PKCE OAuth β”œβ”€β”€ Refresh Token ───────────────── - β–Ό β”‚ β”‚ -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”œβ”€β”€ Get Token (aud: nextcloud) ──── -β”‚ Validate β”‚ β”‚ β”‚ -β”‚ aud == "mcp"β”‚ β–Ό β–Ό -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” - β”‚Refresh Tokens β”‚ β”‚Token Exchangeβ”‚ - β”‚ (Encrypted) β”‚ β”‚ Endpoint β”‚ - β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Client │──────Flow 1: Authenticate───────►│ Shared IdP β”‚ +β”‚ (Native App) β”‚ client_id="claude-desktop" β”‚ (Keycloak) β”‚ +β”‚ β”‚ scope="mcp-server:api" β”‚ β”‚ +β”‚ │◄────────────────────────────────── β”‚ +β”‚ β”‚ access_token (aud: mcp-server) β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ Bearer token + β”‚ (aud: mcp-server) + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Server β”‚ 1. Validate aud == "mcp-server" βœ“ +β”‚ (Stateless) β”‚ 2. Check for Nextcloud refresh token βœ— +β”‚ β”‚ 3. Return: "Not provisioned - run provision_nextcloud_access" +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ User calls provision_nextcloud_access tool + β”‚ + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Server │──────Flow 2: Provision Access───►│ Shared IdP β”‚ +β”‚ β”‚ client_id="mcp-server" β”‚ (Keycloak) β”‚ +β”‚ β”‚ scope="offline_access nextcloud:*" β”‚ β”‚ +β”‚ │◄────────────────────────────────── β”‚ +β”‚ β”‚ refresh_token (aud: nextcloud) β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ + β”‚ Store encrypted refresh token + β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ MCP Server β”‚ Now STATEFUL - can access Nextcloud +β”‚ (Stateful) β”‚ β”œβ”€ Validate MCP tokens (aud: mcp-server) +β”‚ β”‚ β”œβ”€ Mint Nextcloud tokens (aud: nextcloud) +β”‚ β”‚ └─ Access Nextcloud APIs +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -**Key Components:** -- **MCP Client**: Native application using PKCE flow, receives tokens with `aud: "mcp-server"` -- **MCP Server**: Token broker that validates MCP tokens, exchanges for Nextcloud tokens -- **Shared IdP**: Issues audience-specific tokens, supports token exchange/refresh -- **Nextcloud**: Validates tokens with `aud: "nextcloud"` for API access +**Key Innovations:** + +1. **Separate Client Identities**: MCP client uses its own `client_id`, not the server's +2. **Stateless by Default**: Server starts with zero stored state +3. **Explicit Provisioning**: Resource access requested via separate tool call +4. **Progressive Consent**: Users understand what they're authorizing at each step +5. **SSO Efficiency**: If authenticated in Flow 1, Flow 2 only needs consent (no re-login) ### MCP Client Registration @@ -149,55 +200,66 @@ async def register_with_idp(): ### Authentication Flows -#### Initial Setup with Progressive Consent (One-Time Per User) +#### Flow 1: MCP Client Authentication (Always Required) -This flow demonstrates **progressive consent**: separate authorization for MCP client authentication and Nextcloud resource access. - -**Phase 1: MCP Client Authentication (Always Required)** +This is a standard OAuth 2.0 PKCE flow where the MCP client authenticates to the MCP server. **The server has no involvement in this flow beyond returning the challenge endpoint.** ```mermaid sequenceDiagram participant User participant MCPClient as MCP Client
(Claude Desktop) - participant MCPServer as MCP Server - participant IdP as Shared IdP
(Nextcloud OIDC) + participant MCPServer as MCP Server
(Stateless) + participant IdP as Shared IdP
(Keycloak) User->>MCPClient: Connect to MCP Server MCPClient->>MCPServer: Initial request - MCPServer-->>MCPClient: 401 + OAuth endpoints + MCPServer-->>MCPClient: 401 Unauthorized
WWW-Authenticate: Bearer realm="Keycloak"
auth_endpoint=https://keycloak/auth Note over MCPClient: Generate PKCE:
verifier + challenge MCPClient->>MCPClient: Start local callback server
http://localhost:51234/callback - MCPClient->>MCPServer: GET /oauth/authorize
client_id="claude-desktop"
redirect_uri=http://localhost:51234/callback
code_challenge + S256 + MCPClient->>IdP: GET /auth
client_id="claude-desktop" ← Client's own ID!
redirect_uri=http://localhost:51234/callback
scope=openid profile mcp-server:api
code_challenge + S256
response_type=code - MCPServer->>IdP: Validate client_id exists
(query registration endpoint) - IdP-->>MCPServer: Client valid βœ“ + Note over IdP: Standard OAuth flow
MCP Server NOT involved! - MCPServer->>MCPServer: Store session:
- mcp_client_id="claude-desktop"
- client_redirect_uri
- code_challenge
- consent_stage="client_auth" - - MCPServer->>MCPClient: 302 Redirect to IdP
client_id="claude-desktop" ← MCP client's ID!
redirect_uri=https://mcp-server/oauth/callback
scope=openid profile email - - Note over MCPServer,IdP: MCP Server intercepts with its own
callback to manage token flow - - MCPClient->>IdP: Follow redirect - IdP->>User: Login page (if not SSO) - User->>IdP: Authenticate + IdP->>User: Login page (if not authenticated) + User->>IdP: Authenticate (username + password) IdP->>User: Consent: "Allow Claude Desktop
to access MCP Server?" User->>IdP: Approve - IdP->>MCPServer: 302 to /oauth/callback
code={client_auth_code} + IdP->>MCPClient: 302 to http://localhost:51234/callback
code={authorization_code} - MCPServer->>IdP: POST /token
code={client_auth_code}
client_id="claude-desktop"
redirect_uri=https://mcp-server/oauth/callback + MCPClient->>IdP: POST /token
code={authorization_code}
client_id="claude-desktop"
code_verifier={verifier}
redirect_uri=http://localhost:51234/callback - IdP->>MCPServer: Tokens:
- access_token (aud: mcp-server)
- id_token (user_id) + IdP->>MCPClient: Tokens:
- access_token (aud: mcp-server)
- id_token
- token_type: Bearer
- expires_in: 3600 - MCPServer->>MCPServer: Extract user_id from id_token
Store MCP client access token + Note over MCPClient: Client has token with
aud: "mcp-server" + + MCPClient->>MCPServer: MCP request
Authorization: Bearer {token} + + MCPServer->>MCPServer: Validate:
1. aud == "mcp-server" βœ“
2. Scopes include required scope βœ“ + + MCPServer-->>MCPClient: Success (or 403 if wrong audience) ``` -**Phase 2: Conditional Nextcloud Consent (Only If No Refresh Token)** +**Key Points:** +- **No server interception**: MCP server is NOT involved in the OAuth flow +- **Client's credentials**: Flow uses `client_id="claude-desktop"`, not the server's ID +- **Direct callback**: IdP redirects directly to client's localhost callback +- **Stateless server**: Server just validates the resulting token's audience + +**After Flow 1:** +- βœ… MCP client authenticated with token (aud: "mcp-server") +- ❌ MCP server has NO Nextcloud access +- ❌ MCP server has NO stored tokens + +--- + +#### Flow 2: Nextcloud Resource Provisioning (Triggered Explicitly) + +This flow is initiated when the user calls a Nextcloud tool and the server discovers it has no refresh token. **This is a completely separate OAuth flow using the server's credentials.** ```mermaid sequenceDiagram @@ -208,118 +270,119 @@ sequenceDiagram participant IdP as Shared IdP participant Nextcloud + Note over User,MCPClient: User authenticated from Flow 1 + + MCPClient->>MCPServer: list_notes()
Authorization: Bearer {mcp_token} + + MCPServer->>MCPServer: Validate token:
aud == "mcp-server" βœ“ + + MCPServer->>MCPServer: Extract user_id from token + MCPServer->>TokenStore: Check: Has refresh token
for user_id? + TokenStore-->>MCPServer: NOT FOUND - alt Refresh Token EXISTS - Note over MCPServer: Skip Nextcloud consent βœ“ - MCPServer->>MCPServer: Generate mcp_auth_code - MCPServer->>MCPClient: 302 to client callback
code=mcp-code-xyz - Note over MCPServer,MCPClient: Jump to Phase 3 - else NO Refresh Token - MCPServer->>MCPServer: Update session:
consent_stage="nextcloud_access"
Store intermediate state + MCPServer-->>MCPClient: Error Response:
"Not provisioned for Nextcloud access.
Please call provision_nextcloud_access tool." - MCPServer->>MCPClient: 302 to IdP (SECOND OAuth!)
client_id="nextcloud-mcp-server" ← MCP server's ID!
redirect_uri=https://mcp-server/oauth/callback_nextcloud
scope=openid offline_access notes:* calendar:* + Note over User,MCPClient: User explicitly calls provisioning - MCPClient->>IdP: Follow redirect - Note over IdP: User may already be logged in (SSO)
Only need consent, not re-auth + MCPClient->>MCPServer: provision_nextcloud_access()
Authorization: Bearer {mcp_token} - IdP->>User: Consent: "Allow MCP Server
to access Nextcloud offline?" - User->>IdP: Approve offline_access + MCPServer->>MCPServer: Validate token:
aud == "mcp-server" βœ“ - IdP->>MCPServer: 302 to /oauth/callback_nextcloud
code={nextcloud_auth_code} + MCPServer->>MCPServer: Generate state linking to user_id - MCPServer->>IdP: POST /token
code={nextcloud_auth_code}
client_id="nextcloud-mcp-server"
client_secret={mcp_server_secret} + MCPServer-->>MCPClient: Return OAuth URL:
{
"auth_url": "https://keycloak/auth?...",
"message": "Open this URL to authorize"
} - IdP->>MCPServer: Tokens:
- access_token (aud: nextcloud)
- refresh_token ← MASTER TOKEN! + Note over MCPClient,IdP: This is a SECOND, SEPARATE OAuth flow!
Uses MCP server's client_id! - MCPServer->>TokenStore: Store refresh token
(encrypted, user_id) + User->>IdP: Navigate to auth_url:
client_id="mcp-server" ← Server's ID!
redirect_uri=https://mcp-server.com/callback-nextcloud
scope=openid offline_access nextcloud:api
state={user_id_link} - MCPServer->>MCPServer: Retrieve intermediate state
Generate mcp_auth_code + Note over IdP: User may already be logged in (SSO)
Just need consent, not re-authentication - MCPServer->>MCPClient: 302 to client callback
code=mcp-code-xyz - end + IdP->>User: Consent: "Allow MCP Server
to access Nextcloud offline
on your behalf?" + User->>IdP: Approve offline_access + + IdP->>MCPServer: 302 to /callback-nextcloud
code={authorization_code}
state={user_id_link} + + MCPServer->>MCPServer: Validate state,
extract user_id + + MCPServer->>IdP: POST /token
code={authorization_code}
client_id="mcp-server"
client_secret={server_secret}
redirect_uri=https://mcp-server.com/callback-nextcloud + + IdP->>MCPServer: Tokens:
- access_token (aud: nextcloud)
- refresh_token ← MASTER TOKEN!
- id_token + + MCPServer->>TokenStore: Store refresh token
(encrypted, linked to user_id) + + MCPServer-->>User: "Provisioning complete!
You can now access Nextcloud." + + Note over MCPServer: Server is now STATEFUL
Has refresh token for this user ``` -**Phase 3: Complete MCP Client Flow (Standard PKCE)** +**Key Points:** +- **Separate flow**: This is Flow 2, completely independent from Flow 1 +- **Server's credentials**: Uses `client_id="mcp-server"`, the SERVER's own ID +- **Server callback**: IdP redirects to server's callback endpoint +- **User intent**: User explicitly requested this provisioning +- **SSO benefit**: If user authenticated in Flow 1, only consent needed here + +**After Flow 2:** +- βœ… MCP client still uses same token (aud: "mcp-server") +- βœ… MCP server now has refresh token (can mint aud: "nextcloud" tokens) +- βœ… Background workers can operate offline + +--- + +#### Subsequent Sessions (Using Provisioned Access) + +Once provisioned, subsequent MCP sessions work seamlessly: ```mermaid sequenceDiagram participant MCPClient as MCP Client - participant MCPServer as MCP Server - participant TokenStore as Token Storage - - MCPClient->>MCPClient: Callback received:
code=mcp-code-xyz - - MCPClient->>MCPServer: POST /oauth/token
code=mcp-code-xyz
code_verifier + PKCE
client_id="claude-desktop" - - MCPServer->>MCPServer: Validate PKCE:
SHA256(verifier) == challenge - - MCPServer->>TokenStore: Retrieve MCP client
access token from Phase 1 - - MCPServer-->>MCPClient: Response:
- access_token (aud: mcp-server)
- token_type: Bearer
- expires_in: 3600 - - Note over MCPClient: Client NEVER sees
master refresh token! - - MCPClient->>MCPServer: Connect MCP session
Authorization: Bearer {token} - - MCPServer->>MCPServer: Validate token audience - - MCPServer->>MCPServer: For Nextcloud API calls,
use stored refresh token -``` - -**Key Innovations in Progressive Consent:** - -1. **Dual OAuth Flows**: - - Flow 1: Authenticate MCP client with IdP using client's own `client_id` - - Flow 2: Obtain Nextcloud permissions with MCP server's `client_id` (conditional) - -2. **IdP-Level Client Validation**: MCP clients are registered at IdP, validated against registry - -3. **Conditional Consent**: Nextcloud access only requested once per user, reused for subsequent sessions - -4. **Token Isolation**: - - MCP client receives: `access_token` (aud: mcp-server) - - MCP server stores: `refresh_token` for Nextcloud access - - Complete separation of concerns - -5. **SSO Efficiency**: If user authenticated in Phase 1, Phase 2 only requires consent (no re-login) - -#### Subsequent MCP Sessions (Token Broker Pattern) - -```mermaid -sequenceDiagram - participant MCPClient as MCP Client - participant MCPServer as MCP Server + participant MCPServer as MCP Server
(Stateful) participant TokenStore as Token Storage participant IdP as Shared IdP participant Nextcloud - MCPClient->>MCPServer: Request with token
(aud: mcp-server) - MCPServer->>MCPServer: Validate token audience
Must be "mcp-server" + Note over MCPClient: User reconnects (new MCP session) - Note over MCPServer: MCP auth valid,
need Nextcloud token + MCPClient->>MCPServer: list_notes()
Authorization: Bearer {new_mcp_token} - MCPServer->>TokenStore: Get master refresh token - TokenStore-->>MCPServer: Encrypted refresh token + MCPServer->>MCPServer: Validate token:
aud == "mcp-server" βœ“ - MCPServer->>MCPServer: Check cached
Nextcloud token expiry + MCPServer->>MCPServer: Extract user_id from token + + MCPServer->>TokenStore: Check: Has refresh token
for user_id? + TokenStore-->>MCPServer: FOUND βœ“ + + MCPServer->>MCPServer: Check cached Nextcloud token alt Nextcloud Token Expired or Missing - MCPServer->>IdP: POST /token
grant_type=refresh_token
audience=nextcloud - IdP->>MCPServer: New access token ONLY
(aud: nextcloud) - Note over IdP,MCPServer: NO refresh token rotation here!
Master refresh token unchanged - MCPServer->>TokenStore: Cache Nextcloud access token
(5 min TTL) + MCPServer->>IdP: POST /token
grant_type=refresh_token
refresh_token={stored_token}
audience=nextcloud + + IdP->>MCPServer: access_token (aud: nextcloud) + + MCPServer->>MCPServer: Cache token (5 min TTL) end - MCPServer->>Nextcloud: API call with token
(aud: nextcloud) - Nextcloud->>IdP: Validate token + audience - IdP-->>Nextcloud: Valid for Nextcloud - Nextcloud-->>MCPServer: API response - MCPServer-->>MCPClient: MCP response + MCPServer->>Nextcloud: GET /notes
Authorization: Bearer {nextcloud_token} - Note over MCPClient,MCPServer: Client only sees
aud:"mcp-server" tokens + Nextcloud->>IdP: Validate token + audience + IdP-->>Nextcloud: Valid, aud: nextcloud βœ“ + + Nextcloud-->>MCPServer: [list of notes] + + MCPServer-->>MCPClient: MCP response with notes + + Note over MCPClient: Client only sees MCP response,
never sees Nextcloud token! ``` +**Key Points:** +- **No re-provisioning**: Refresh token persists across MCP sessions +- **Token caching**: Nextcloud access tokens cached to reduce IdP calls +- **Audience isolation**: MCP client never sees Nextcloud tokens + +--- + #### Background Operations ```mermaid @@ -497,256 +560,169 @@ class MCPTokenVerifier(TokenVerifier): return None ``` -### 2. OAuth Endpoints with PKCE (Native Client Support) +### 2. MCP Tool for Resource Provisioning + +**CRITICAL**: Flow 1 (client authentication) does NOT use MCP server endpoints. The MCP client authenticates directly with the IdP using its own `client_id`. The server only validates the resulting tokens. + +For Flow 2 (Nextcloud provisioning), we provide an MCP tool that returns an OAuth URL: ```python -import hashlib -import secrets from urllib.parse import urlencode +import secrets -@app.get("/oauth/authorize") -async def oauth_authorize( - response_type: str = "code", - client_id: str = None, - redirect_uri: str = None, - scope: str = None, - state: str = None, - code_challenge: str = None, # PKCE - code_challenge_method: str = "S256" # PKCE -): - """MCP Server OAuth endpoint with PKCE support.""" - # Validate redirect_uri is localhost (native client) - if not redirect_uri or not redirect_uri.startswith(('http://localhost:', 'http://127.0.0.1:')): - return {"error": "invalid_request", "error_description": "Invalid redirect_uri for native client"} - - # Store MCP client details with PKCE - session_id = str(uuid4()) - mcp_authorization_code = f"mcp-code-{secrets.token_urlsafe(32)}" - - await store_oauth_session( - session_id=session_id, - client_id=client_id, - client_redirect_uri=redirect_uri, # Store client's redirect URI - state=state, - code_challenge=code_challenge, - code_challenge_method=code_challenge_method, - mcp_authorization_code=mcp_authorization_code # Pre-generate MCP code - ) - - # Build IdP authorization URL - # CRITICAL: Use MCP server's callback URL, NOT the client's! - idp_params = { - "client_id": MCP_SERVER_CLIENT_ID, - "redirect_uri": f"{MCP_SERVER_URL}/oauth/callback", # Server's callback! - "response_type": "code", - "scope": "openid profile email offline_access " # Identity + offline - "nextcloud:notes:read nextcloud:notes:write " # Nextcloud scopes - "nextcloud:calendar:read nextcloud:calendar:write", - "state": f"{session_id}:{state}", # Preserve client state - "prompt": "consent" # Ensure refresh token - } - - idp_auth_url = f"{IDP_AUTHORIZATION_ENDPOINT}?{urlencode(idp_params)}" - return RedirectResponse(idp_auth_url) - -@app.get("/oauth/callback") -async def oauth_callback(code: str, state: str): +@mcp.tool() +@required_scopes("mcp:provision") # Or allow all authenticated users +async def provision_nextcloud_access(ctx: Context) -> dict: """ - Handle IdP callback - the server receives the IdP code! - This is the CRITICAL difference in the Hybrid Flow. + Initiate OAuth flow to grant MCP server access to Nextcloud on your behalf. + + This starts Flow 2, where you authorize the MCP server to access Nextcloud + resources offline (when you're not connected). + + Returns: + dict: OAuth authorization URL to visit in your browser """ - # Extract session ID and original client state - try: - session_id, client_state = state.split(":", 1) - except ValueError: - return {"error": "invalid_state"} - - oauth_session = await get_oauth_session(session_id) - if not oauth_session: - return {"error": "invalid_session"} - - # STEP 1: Exchange IdP code for master tokens - # The server gets the master refresh token! - tokens = await idp_client.exchange_code( - code=code, # IdP authorization code - redirect_uri=f"{MCP_SERVER_URL}/oauth/callback", - client_id=MCP_SERVER_CLIENT_ID, - client_secret=MCP_SERVER_CLIENT_SECRET # Server has client secret - ) - - # Verify the access token has correct audience - payload = jwt.decode( - tokens.access_token, + # Get user_id from MCP token (already validated by required_scopes) + token_payload = jwt.decode( + ctx.authorization.token, options={"verify_signature": False} ) + user_id = token_payload['sub'] - audiences = payload.get('aud', []) - if isinstance(audiences, str): - audiences = [audiences] - - if 'mcp-server' not in audiences: - logger.error(f"IdP returned token with wrong audience: {audiences}") - return {"error": "invalid_token", "error_description": "Wrong audience"} - - # Decode ID token to get user info - userinfo = decode_id_token(tokens.id_token) - - # Create or update user account - user = await create_or_update_user( - idp_sub=userinfo.sub, - username=userinfo.preferred_username, - email=userinfo.email - ) - - # Generate new token family for rotation - token_family_id = str(uuid4()) - - # STEP 2: Store master tokens (encrypted) - # These are the IdP tokens with offline_access! - await token_storage.store_tokens( - user_id=user.id, - token_family_id=token_family_id, - access_token=tokens.access_token, # Initial MCP access token - refresh_token=tokens.refresh_token, # Master refresh token! - status='active', - scopes=tokens.scope, - idp_subject=userinfo.sub - ) - - # Link session to user and store the access token for later - await update_oauth_session( - session_id, - user_id=user.id, - idp_access_token=tokens.access_token # Store for /oauth/token endpoint - ) - - # STEP 3: Redirect to native client with MCP-generated code - # Client will exchange this code for tokens at /oauth/token - redirect_params = { - "code": oauth_session.mcp_authorization_code, # MCP code, NOT IdP code! - "state": client_state # Return original client state - } - - redirect_url = f"{oauth_session.client_redirect_uri}?{urlencode(redirect_params)}" - return RedirectResponse(redirect_url, status_code=302) - -@app.post("/oauth/token") -async def oauth_token( - grant_type: str = Form(...), - code: str = Form(None), - code_verifier: str = Form(None), # PKCE - redirect_uri: str = Form(None), - client_id: str = Form(None), - refresh_token: str = Form(None) -): - """ - Token endpoint - client exchanges MCP code for tokens. - CRITICAL: The client sends the MCP-generated code, NOT the IdP code! - """ - - if grant_type == "authorization_code": - # Find session by MCP authorization code (e.g., mcp-code-xyz...) - oauth_session = await get_oauth_session_by_mcp_code(code) - if not oauth_session: - return JSONResponse( - {"error": "invalid_grant", "error_description": "Invalid authorization code"}, - status_code=400 - ) - - # Verify PKCE - if oauth_session.code_challenge: - if not code_verifier: - return JSONResponse( - {"error": "invalid_request", "error_description": "code_verifier required"}, - status_code=400 - ) - - # Compute challenge from verifier - computed_challenge = base64.urlsafe_b64encode( - hashlib.sha256(code_verifier.encode()).digest() - ).decode().rstrip('=') - - if computed_challenge != oauth_session.code_challenge: - return JSONResponse( - {"error": "invalid_grant", "error_description": "PKCE verification failed"}, - status_code=400 - ) - - # Verify redirect_uri matches - if redirect_uri != oauth_session.client_redirect_uri: - return JSONResponse( - {"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, - status_code=400 - ) - - # Get the IdP access token that was stored during /oauth/callback - # This token was already obtained when the server exchanged the IdP code - idp_access_token = oauth_session.idp_access_token - - # Get user's refresh token from storage (for creating response) - # But DO NOT return the master refresh token to the client! - user_tokens = await get_user_tokens(oauth_session.user_id) - - # Invalidate MCP authorization code (one-time use) - await invalidate_oauth_session(oauth_session.session_id) - - # Return tokens to client - # CRITICAL: Client gets access token but NOT the master refresh token + # Check if already provisioned + token_storage = get_token_storage(ctx) + existing_token = await token_storage.get_refresh_token(user_id) + if existing_token: return { - "access_token": idp_access_token, # IdP token with aud: mcp-server - "token_type": "Bearer", - "expires_in": 3600, - "scope": user_tokens.scope, - # Optional: Return an MCP session refresh token (NOT the master token!) - # This allows the client to refresh without re-auth - "refresh_token": await generate_mcp_session_refresh_token(oauth_session.user_id) + "status": "already_provisioned", + "message": "MCP server already has Nextcloud access for this user." } - elif grant_type == "refresh_token": - # Refresh with IdP for new MCP-audience token - try: - # Use master refresh token to get new MCP token - response = await idp_client.refresh_token( - refresh_token=refresh_token, - audience='mcp-server' # Request MCP audience - ) - - # Verify audience - payload = jwt.decode( - response.access_token, - options={"verify_signature": False} - ) - - audiences = payload.get('aud', []) - if isinstance(audiences, str): - audiences = [audiences] - - if 'mcp-server' not in audiences: - return JSONResponse( - {"error": "invalid_grant", "error_description": "Refreshed token missing MCP audience"}, - status_code=400 - ) - - return { - "access_token": response.access_token, - "token_type": "Bearer", - "expires_in": response.expires_in, - "scope": response.scope, - "refresh_token": response.refresh_token # New refresh token if rotated - } - except Exception as e: - return JSONResponse( - {"error": "invalid_grant", "error_description": str(e)}, - status_code=400 - ) - - return JSONResponse( - {"error": "unsupported_grant_type"}, - status_code=400 + # Generate state to link callback to this user + state = secrets.token_urlsafe(32) + await store_provisioning_session( + state=state, + user_id=user_id, + created_at=datetime.utcnow() ) + + # Build OAuth URL for Flow 2 + # CRITICAL: This uses the MCP server's client_id, not the MCP client's! + idp_params = { + "client_id": MCP_SERVER_CLIENT_ID, # Server's ID! + "redirect_uri": f"{MCP_SERVER_URL}/oauth/callback-nextcloud", + "response_type": "code", + "scope": "openid offline_access " + "nextcloud:notes:read nextcloud:notes:write " + "nextcloud:calendar:read nextcloud:calendar:write", + "state": state, + "prompt": "consent" # Ensure user sees consent screen + } + + auth_url = f"{IDP_AUTHORIZATION_ENDPOINT}?{urlencode(idp_params)}" + + return { + "status": "pending", + "auth_url": auth_url, + "message": "Please open the URL in your browser to authorize Nextcloud access.", + "instructions": "After authorizing, the MCP server will be able to access Nextcloud on your behalf." + } + ``` -### 3. MCP Tool Token Verification with Audience Check +### 3. OAuth Callback for Resource Provisioning (Flow 2) + +```python +@app.get("/oauth/callback-nextcloud") +async def oauth_callback_nextcloud(code: str, state: str): + """ + Handle IdP callback for Flow 2 (Nextcloud resource provisioning). + + This endpoint receives the authorization code after the user consents to + the MCP server accessing Nextcloud on their behalf. + """ + # Retrieve provisioning session + session = await get_provisioning_session(state) + if not session: + return HTMLResponse( + "" + "

Error

" + "

Invalid or expired authorization request.

" + "", + status_code=400 + ) + + user_id = session.user_id + + try: + # Exchange authorization code for tokens + # CRITICAL: This uses the MCP server's client credentials! + tokens = await idp_client.exchange_code( + code=code, + redirect_uri=f"{MCP_SERVER_URL}/oauth/callback-nextcloud", + client_id=MCP_SERVER_CLIENT_ID, + client_secret=MCP_SERVER_CLIENT_SECRET + ) + + # Verify the refresh token has correct audience + # NOTE: The access token will have aud: "nextcloud", and the + # refresh token should be able to mint tokens with that audience + payload = jwt.decode( + tokens.access_token, + options={"verify_signature": False} + ) + + audiences = payload.get('aud', []) + if isinstance(audiences, str): + audiences = [audiences] + + if 'nextcloud' not in audiences: + raise ValueError(f"IdP returned token with wrong audience: {audiences}") + + # Generate new token family for rotation + token_family_id = str(uuid4()) + + # Store master refresh token (encrypted) + # This token can mint tokens with aud: "nextcloud" + token_storage = get_token_storage() + await token_storage.store_tokens( + user_id=user_id, + token_family_id=token_family_id, + access_token=tokens.access_token, # Initial Nextcloud access token + refresh_token=tokens.refresh_token, # Master refresh token! + status='active', + scopes=tokens.scope, + idp_subject=payload['sub'] + ) + + # Delete provisioning session + await delete_provisioning_session(state) + + # Return success page + return HTMLResponse( + "" + "

Success!

" + "

MCP server has been granted access to Nextcloud on your behalf.

" + "

You can close this window and return to your MCP client.

" + "" + ) + + except Exception as e: + logger.error(f"Failed to exchange authorization code: {e}") + return HTMLResponse( + "" + "

Error

" + "

Failed to complete authorization. Please try again.

" + f"

Error: {str(e)}

" + "", + status_code=500 + ) + +``` + +**NOTE**: The MCP server does NOT need to provide `/oauth/authorize` or `/oauth/token` endpoints for Flow 1. The MCP client authenticates directly with the IdP using its own `client_id`, and the IdP issues tokens directly to the client. The MCP server only validates these tokens. + +### 4. MCP Tool Token Verification with Audience Check ```python from functools import wraps @@ -1041,6 +1017,39 @@ async def setup_idp_client(): ## Security Considerations +### Progressive Consent Security Model + +The dual OAuth flow architecture provides enhanced security through: + +1. **Separate Client Identities**: + - MCP client authenticates with its own `client_id` (e.g., "claude-desktop") + - MCP server has its own `client_id` (e.g., "mcp-server") + - Each entity's permissions are independently managed at IdP level + - Compromised client credentials don't grant server-level access + +2. **Explicit User Consent**: + - Flow 1: User consents to "Claude Desktop accessing MCP Server" + - Flow 2: User consents to "MCP Server accessing Nextcloud offline" + - Two separate, understandable authorization decisions + - Users understand the security model + +3. **Stateless by Default**: + - Server starts with zero stored credentials + - No automatic provisioning of resource access + - User must explicitly authorize offline access via `provision_nextcloud_access` tool + - Reduces attack surface for initial deployment + +4. **Least Privilege**: + - Flow 1 only grants MCP server authentication, no resource access + - Flow 2 only requested when user actually needs Nextcloud functionality + - Unused features don't get provisioned + +5. **Defense in Depth**: + - Even if MCP client is compromised, attacker only gets `aud:"mcp-server"` tokens + - Cannot directly access Nextcloud without server's stored refresh token + - Server-side refresh token is encrypted at rest + - Multiple layers of protection + ### Audience Isolation Architecture #### Core Security Principle: Token Audience Separation @@ -1077,20 +1086,22 @@ The MCP server acts as a **secure token broker**: "exp": 1234567890 } -# Master Refresh Token Claims +# Master Refresh Token Claims (stored by server) { "sub": "user-123", - "scope": "openid profile offline_access nextcloud:*", - "allowed_audiences": ["mcp-server", "nextcloud"] # Can mint both + "scope": "openid offline_access nextcloud:notes:read nextcloud:calendar:write", + # Can mint tokens with aud: "nextcloud" via refresh grant } ``` -### PKCE Protection +### PKCE Protection (Flow 1) - **Mandatory for native clients** (RFC 7636) +- Applied in Flow 1 (MCP client β†’ IdP authentication) - Code verifier: 43-128 character random string - Code challenge: SHA256(code_verifier) - Prevents authorization code interception -- Validated before token issuance +- Validated by IdP before token issuance +- **Note**: Flow 2 uses confidential client pattern (server has client_secret) ### Native Client Security - **Localhost redirect only** (RFC 8252) @@ -1315,41 +1326,46 @@ grant_type=urn:ietf:params:oauth:grant-type:token-exchange ## Decision Outcome -The Token Broker Architecture with **Hybrid Flow** and Audience Isolation provides a secure, enterprise-ready solution for offline access while maintaining strict security boundaries. By using a shared identity provider with audience-specific tokens, we achieve: +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. **Security through isolation**: Different audiences prevent token misuse -2. **Single authentication**: Users authenticate once to the IdP +1. **Security through separation**: Two distinct OAuth flows with different client identities +2. **Explicit user consent**: Users understand exactly what they're authorizing 3. **Offline capabilities**: Master refresh tokens enable background operations 4. **Enterprise compliance**: Follows OAuth best practices and security standards +5. **Stateless by default**: Server only stores credentials when explicitly provisioned -### Key Implementation: Progressive Consent with Dual OAuth Flows +### Key Implementation: Two Completely Separate OAuth Flows -The **Progressive Consent architecture** (see "Initial Setup with Progressive Consent" above) solves the critical challenges of token brokering while maintaining standards compliance: +The **Progressive Consent architecture** solves the critical challenges of token brokering while maintaining standards compliance: -1. **MCP Client Authentication** (Phase 1): - - MCP clients are registered at IdP level (DCR or pre-configured) - - Client uses own `client_id` (e.g., "claude-desktop") for authentication - - MCP server validates client exists at IdP before proceeding - - User consents to "Claude Desktop accessing MCP Server" +#### Flow 1: MCP Client Authentication (Always Required) +- **Purpose**: Authenticate MCP client to MCP server +- **Participants**: MCP Client (e.g., Claude Desktop) ↔ IdP +- **Client ID**: MCP client's own ID (e.g., "claude-desktop") +- **Scopes**: `openid profile mcp-server:api` +- **User Consent**: "Allow **Claude Desktop** to access **MCP Server**?" +- **Result**: Access token with `aud: "mcp-server"` +- **Server State**: STATELESS - server just validates tokens +- **Key Point**: **MCP server is NOT involved in this flow** - client authenticates directly with IdP -2. **Conditional Nextcloud Consent** (Phase 2): - - Only triggered if MCP server doesn't have refresh token for user - - MCP server uses own `client_id` ("nextcloud-mcp-server") to request Nextcloud access - - User consents to "MCP Server accessing Nextcloud offline" - - MCP server stores master refresh token (encrypted) - -3. **Token Exchange** (Phase 3): - - Standard PKCE flow between MCP client and MCP server - - Client exchanges MCP authorization code for access token - - Client never sees master refresh token - - Complete token isolation +#### Flow 2: Resource Provisioning (Explicit, On-Demand) +- **Purpose**: Grant MCP server offline access to Nextcloud +- **Trigger**: User calls `provision_nextcloud_access` tool +- **Participants**: User β†’ MCP Server ↔ IdP +- **Client ID**: MCP server's own ID (e.g., "mcp-server") +- **Scopes**: `openid offline_access nextcloud:*` +- **User Consent**: "Allow **MCP Server** to access **Nextcloud** offline?" +- **Result**: Refresh token with `aud: "nextcloud"` +- **Server State**: STATEFUL - server stores encrypted refresh token +- **Key Point**: **This is a separate, independent OAuth flow** initiated by server **Benefits:** -- **Standards-compliant**: Proper OAuth 2.0 patterns throughout -- **Secure**: Client validation at IdP level, not local storage +- **Standards-compliant**: Two proper OAuth 2.0 flows, no server interception +- **Secure**: Separate client identities, no credential sharing +- **Transparent**: Users explicitly understand each authorization - **Efficient**: Nextcloud consent only needed once per user -- **Transparent**: Users understand what they're authorizing at each step -- **SSO-friendly**: If authenticated in Phase 1, Phase 2 only requires consent +- **SSO-friendly**: If authenticated in Flow 1, Flow 2 only requires consent (no re-login) +- **Least privilege**: Flow 2 only triggered when user needs Nextcloud functionality ### Token Lifecycle Clarification