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