docs: Update ADR-004 with progressive consent architecture

Refactor ADR-004 to document the proper OAuth architecture where MCP
clients are registered at the IdP level (not with MCP server) and use
a progressive consent pattern with dual OAuth flows.

## Key Changes

### MCP Client Registration
- Document that MCP clients (Claude Desktop, etc.) register at IdP level
- Show DCR and pre-registration options
- Clarify client validation happens against IdP registry

### Progressive Consent Architecture
Replace single "Hybrid Flow" with three-phase progressive consent:

**Phase 1: MCP Client Authentication** (Always)
- MCP client uses own client_id (e.g., "claude-desktop")
- User consents to "Claude Desktop accessing MCP Server"
- MCP server validates client exists at IdP
- Stores MCP client access token

**Phase 2: Nextcloud Consent** (Conditional)
- Only if MCP server doesn't have refresh token for user
- MCP server uses own client_id ("nextcloud-mcp-server")
- User consents to "MCP Server accessing Nextcloud offline"
- MCP server stores master refresh token
- SSO: If already authenticated, only consent needed

**Phase 3: Token Exchange** (Standard PKCE)
- Client exchanges MCP authorization code
- Validates PKCE code_verifier
- Returns access token (aud: mcp-server)
- Client never sees master refresh token

### Implementation Status Section
- Document current implementation as "simplified hybrid flow"
- List what's implemented vs what needs refactoring
- Clarify current tests use simplified version
- Note progressive consent is target architecture

## Benefits of Progressive Consent

 Standards-compliant: Proper OAuth clients at IdP level
 Secure: Client validation against IdP registry
 Efficient: Nextcloud consent only once per user
 Transparent: Users understand each authorization step
 SSO-friendly: Minimal re-authentication in Phase 2

## Implementation Tracking

The refactoring from simplified hybrid flow to progressive consent will
be tracked in a separate issue. Current implementation demonstrates:
- MCP server can intercept OAuth callbacks
- Refresh tokens stored securely
- PKCE flow works end-to-end
- Tool execution succeeds

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-11-03 02:34:30 +01:00
co-authored by Claude
parent babd60e08b
commit 0d45120470
+223 -61
View File
@@ -109,83 +109,180 @@ The IdP (Keycloak) is configured to:
- **Shared IdP**: Issues audience-specific tokens, supports token exchange/refresh - **Shared IdP**: Issues audience-specific tokens, supports token exchange/refresh
- **Nextcloud**: Validates tokens with `aud: "nextcloud"` for API access - **Nextcloud**: Validates tokens with `aud: "nextcloud"` for API access
### MCP Client Registration
**IMPORTANT**: MCP SDK clients (like Claude Desktop) are **proper OAuth clients registered at the IdP level**, not with the MCP server itself.
#### Client Registration Options
**Option 1: Dynamic Client Registration (DCR) - Recommended**
```python
# MCP client registers itself at IdP startup
import httpx
async def register_with_idp():
response = await httpx.post(
"https://idp.example.com/register",
json={
"client_name": "Claude Desktop",
"redirect_uris": ["http://localhost:51234/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"token_endpoint_auth_method": "none", # Public client with PKCE
"application_type": "native",
}
)
client_id = response.json()["client_id"]
# Store client_id for subsequent OAuth flows
```
**Option 2: Pre-registered Client**
```bash
# Admin pre-registers known MCP clients in Keycloak/Nextcloud
# Client IDs: "claude-desktop", "continue-dev", "zed-editor", etc.
```
**Key Points:**
- MCP clients are registered at **IdP level** (Nextcloud OIDC, Keycloak, Auth0, etc.)
- MCP server validates `client_id` against IdP registry during authorization
- Public clients use PKCE (no client_secret) per RFC 8252
- Each MCP client has its own identity and permissions
### Authentication Flows ### Authentication Flows
#### Initial Setup with Hybrid Flow (One-Time) #### Initial Setup with Progressive Consent (One-Time Per User)
This flow demonstrates **progressive consent**: separate authorization for MCP client authentication and Nextcloud resource access.
**Phase 1: MCP Client Authentication (Always Required)**
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
participant User participant User
participant MCPClient as MCP Client<br/>(Native App) participant MCPClient as MCP Client<br/>(Claude Desktop)
participant MCPServer as MCP Server participant MCPServer as MCP Server
participant IdP as Shared IdP (Keycloak) participant IdP as Shared IdP<br/>(Nextcloud OIDC)
participant Nextcloud
User->>MCPClient: Connect to MCP User->>MCPClient: Connect to MCP Server
MCPClient->>MCPServer: Initial request MCPClient->>MCPServer: Initial request
MCPServer-->>MCPClient: 401 Unauthorized + OAuth config MCPServer-->>MCPClient: 401 + OAuth endpoints
Note over MCPClient: Generate PKCE values:<br/>code_verifier = random string<br/>code_challenge = SHA256(code_verifier) Note over MCPClient: Generate PKCE:<br/>verifier + challenge
MCPClient->>MCPClient: Start local HTTP server<br/>on random port (e.g., :51234) MCPClient->>MCPClient: Start local callback server<br/>http://localhost:51234/callback
MCPClient->>MCPServer: GET /oauth/authorize<br/>+ code_challenge<br/>+ redirect_uri=http://localhost:51234/callback MCPClient->>MCPServer: GET /oauth/authorize<br/>client_id="claude-desktop"<br/>redirect_uri=http://localhost:51234/callback<br/>code_challenge + S256
MCPServer->>MCPServer: Store session with:<br/>- client_redirect_uri<br/>- code_challenge<br/>- state MCPServer->>IdP: Validate client_id exists<br/>(query registration endpoint)
MCPServer->>MCPClient: 302 Redirect to IdP<br/>redirect_uri=https://mcp-server.com/oauth/callback IdP-->>MCPServer: Client valid ✓
Note over MCPServer,IdP: CRITICAL: Server's callback URL,<br/>NOT client's! MCPServer->>MCPServer: Store session:<br/>- mcp_client_id="claude-desktop"<br/>- client_redirect_uri<br/>- code_challenge<br/>- consent_stage="client_auth"
MCPClient->>IdP: Authorization Request<br/>redirect_uri=https://mcp-server.com/oauth/callback MCPServer->>MCPClient: 302 Redirect to IdP<br/>client_id="claude-desktop" ← MCP client's ID!<br/>redirect_uri=https://mcp-server/oauth/callback<br/>scope=openid profile email
Note over IdP: Requested scopes:<br/>- openid profile email<br/>- offline_access<br/>- nextcloud:notes:*
IdP->>User: Login page Note over MCPServer,IdP: MCP Server intercepts with its own<br/>callback to manage token flow
User->>IdP: Authenticate once
IdP->>User: Consent screen MCPClient->>IdP: Follow redirect
Note over IdP: "Allow MCP Server to:<br/>- Authenticate you<br/>- Access data offline<br/>- Access Nextcloud on your behalf" IdP->>User: Login page (if not SSO)
User->>IdP: Authenticate
User->>IdP: Grant consent IdP->>User: Consent: "Allow Claude Desktop<br/>to access MCP Server?"
IdP->>MCPServer: 302 Redirect to MCP server<br/>with IdP authorization code User->>IdP: Approve
Note over MCPServer: Server receives IdP code! IdP->>MCPServer: 302 to /oauth/callback<br/>code={client_auth_code}
MCPServer->>IdP: Exchange IdP code for tokens<br/>+ client_secret MCPServer->>IdP: POST /token<br/>code={client_auth_code}<br/>client_id="claude-desktop"<br/>redirect_uri=https://mcp-server/oauth/callback
IdP->>MCPServer: Master tokens:<br/>- Access token (aud: mcp-server)<br/>- Master refresh token
MCPServer->>MCPServer: 1. Store master refresh token (encrypted)<br/>2. Generate MCP auth code: mcp-code-xyz<br/>3. Link to stored code_challenge IdP->>MCPServer: Tokens:<br/>- access_token (aud: mcp-server)<br/>- id_token (user_id)
MCPServer->>MCPClient: 302 Redirect to client<br/>http://localhost:51234/callback<br/>?code=mcp-code-xyz&state=... MCPServer->>MCPServer: Extract user_id from id_token<br/>Store MCP client access token
Note over MCPClient: Client receives MCP code<br/>(not IdP code!)
MCPClient->>MCPServer: POST /oauth/token<br/>code=mcp-code-xyz<br/>+ code_verifier
MCPServer->>MCPServer: 1. Find session by mcp-code-xyz<br/>2. Verify PKCE: SHA256(code_verifier) == code_challenge<br/>3. Get stored access token from step 4
MCPServer-->>MCPClient: Return:<br/>- Access token (aud: mcp-server)<br/>- NO master refresh token!<br/>- Optional: MCP session refresh token
MCPClient->>MCPServer: API call with token<br/>(aud: mcp-server)
MCPServer->>MCPServer: Validate audience
Note over MCPServer: Need Nextcloud access,<br/>use stored master refresh token
MCPServer->>IdP: POST /token<br/>refresh_token + audience=nextcloud
IdP->>MCPServer: New token (aud: nextcloud)
MCPServer->>Nextcloud: API call with token<br/>(aud: nextcloud)
Nextcloud->>IdP: Validate token + audience
IdP-->>Nextcloud: Valid for Nextcloud
Nextcloud-->>MCPServer: API response
MCPServer-->>MCPClient: Success
``` ```
**Key Changes in the Hybrid Flow:** **Phase 2: Conditional Nextcloud Consent (Only If No Refresh Token)**
1. **Server Intercepts Code**: The IdP redirects to the MCP server's `/oauth/callback`, not the client's
2. **Token Swap**: The server exchanges the IdP code for master tokens and stores them ```mermaid
3. **Client Handoff**: The server generates its own code (`mcp-code-xyz`) and redirects the client with it sequenceDiagram
4. **PKCE Completion**: The client exchanges the server's code using the original code_verifier participant User
5. **Master Token Protection**: The client never receives the master refresh token participant MCPClient as MCP Client
participant MCPServer as MCP Server
participant TokenStore as Token Storage
participant IdP as Shared IdP
participant Nextcloud
MCPServer->>TokenStore: Check: Has refresh token<br/>for user_id?
alt Refresh Token EXISTS
Note over MCPServer: Skip Nextcloud consent ✓
MCPServer->>MCPServer: Generate mcp_auth_code
MCPServer->>MCPClient: 302 to client callback<br/>code=mcp-code-xyz
Note over MCPServer,MCPClient: Jump to Phase 3
else NO Refresh Token
MCPServer->>MCPServer: Update session:<br/>consent_stage="nextcloud_access"<br/>Store intermediate state
MCPServer->>MCPClient: 302 to IdP (SECOND OAuth!)<br/>client_id="nextcloud-mcp-server" ← MCP server's ID!<br/>redirect_uri=https://mcp-server/oauth/callback_nextcloud<br/>scope=openid offline_access notes:* calendar:*
MCPClient->>IdP: Follow redirect
Note over IdP: User may already be logged in (SSO)<br/>Only need consent, not re-auth
IdP->>User: Consent: "Allow MCP Server<br/>to access Nextcloud offline?"
User->>IdP: Approve offline_access
IdP->>MCPServer: 302 to /oauth/callback_nextcloud<br/>code={nextcloud_auth_code}
MCPServer->>IdP: POST /token<br/>code={nextcloud_auth_code}<br/>client_id="nextcloud-mcp-server"<br/>client_secret={mcp_server_secret}
IdP->>MCPServer: Tokens:<br/>- access_token (aud: nextcloud)<br/>- refresh_token ← MASTER TOKEN!
MCPServer->>TokenStore: Store refresh token<br/>(encrypted, user_id)
MCPServer->>MCPServer: Retrieve intermediate state<br/>Generate mcp_auth_code
MCPServer->>MCPClient: 302 to client callback<br/>code=mcp-code-xyz
end
```
**Phase 3: Complete MCP Client Flow (Standard PKCE)**
```mermaid
sequenceDiagram
participant MCPClient as MCP Client
participant MCPServer as MCP Server
participant TokenStore as Token Storage
MCPClient->>MCPClient: Callback received:<br/>code=mcp-code-xyz
MCPClient->>MCPServer: POST /oauth/token<br/>code=mcp-code-xyz<br/>code_verifier + PKCE<br/>client_id="claude-desktop"
MCPServer->>MCPServer: Validate PKCE:<br/>SHA256(verifier) == challenge
MCPServer->>TokenStore: Retrieve MCP client<br/>access token from Phase 1
MCPServer-->>MCPClient: Response:<br/>- access_token (aud: mcp-server)<br/>- token_type: Bearer<br/>- expires_in: 3600
Note over MCPClient: Client NEVER sees<br/>master refresh token!
MCPClient->>MCPServer: Connect MCP session<br/>Authorization: Bearer {token}
MCPServer->>MCPServer: Validate token audience
MCPServer->>MCPServer: For Nextcloud API calls,<br/>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) #### Subsequent MCP Sessions (Token Broker Pattern)
@@ -1225,15 +1322,34 @@ The Token Broker Architecture with **Hybrid Flow** and Audience Isolation provid
3. **Offline capabilities**: Master refresh tokens enable background operations 3. **Offline capabilities**: Master refresh tokens enable background operations
4. **Enterprise compliance**: Follows OAuth best practices and security standards 4. **Enterprise compliance**: Follows OAuth best practices and security standards
### Key Implementation: The Hybrid Flow ### Key Implementation: Progressive Consent with Dual OAuth Flows
The **Hybrid Flow** solves the critical problem of getting the master refresh token to the server while maintaining PKCE security for the client: The **Progressive Consent architecture** (see "Initial Setup with Progressive Consent" above) solves the critical challenges of token brokering while maintaining standards compliance:
1. **Server Intercepts Code**: The IdP redirects to the MCP server's `/oauth/callback`, not the client's 1. **MCP Client Authentication** (Phase 1):
2. **Server Gets Master Token**: The server exchanges the IdP code for the master refresh token and stores it - MCP clients are registered at IdP level (DCR or pre-configured)
3. **Client Handoff**: The server generates its own authorization code and redirects the client - Client uses own `client_id` (e.g., "claude-desktop") for authentication
4. **PKCE Completion**: The client exchanges the server's code using the original PKCE verifier - MCP server validates client exists at IdP before proceeding
5. **Token Protection**: The client never sees or handles the master refresh token - User consents to "Claude Desktop accessing MCP Server"
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
**Benefits:**
- **Standards-compliant**: Proper OAuth 2.0 patterns throughout
- **Secure**: Client validation at IdP level, not local storage
- **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
### Token Lifecycle Clarification ### Token Lifecycle Clarification
@@ -1243,9 +1359,55 @@ The **Hybrid Flow** solves the critical problem of getting the master refresh to
This architecture follows industry best practices for federated systems and positions the MCP server as a secure token broker in an enterprise identity ecosystem. This architecture follows industry best practices for federated systems and positions the MCP server as a secure token broker in an enterprise identity ecosystem.
## Implementation Status
**Current Status**: Partially Implemented (Refactoring Required)
The current implementation (`nextcloud_mcp_server/auth/oauth_routes.py`) implements a **simplified hybrid flow** but needs refactoring to match the progressive consent architecture documented above:
### What's Currently Implemented ✅
1. **Basic OAuth endpoints**: `/oauth/authorize`, `/oauth/callback`, `/oauth/token`
2. **PKCE validation**: Code challenge/verifier flow works
3. **Session storage**: OAuth sessions stored in SQLite
4. **Token storage**: Master refresh tokens stored encrypted
5. **Integration tests**: Playwright-based tests pass (3/3)
### What Needs Refactoring 🔄
1. **Client Validation**:
- Current: `client_id` is ignored
- Needed: Validate `client_id` exists at IdP registry
2. **Dual OAuth Flow**:
- Current: Single OAuth using MCP server's `client_id`
- Needed: Phase 1 (MCP client auth) + Phase 2 (conditional Nextcloud consent)
3. **Consent Separation**:
- Current: Monolithic consent screen
- Needed: Separate consents for client authentication vs resource access
4. **Intermediate Session State**:
- Current: Simple session with MCP code generated upfront
- Needed: Store state between OAuth phases, support `consent_stage` field
5. **New Callback Endpoint**:
- Current: Single `/oauth/callback`
- Needed: Add `/oauth/callback_nextcloud` for Phase 2
### Migration Plan
The refactoring will be tracked in a separate issue. The current implementation serves as a proof-of-concept for the hybrid flow pattern and demonstrates:
- MCP server can intercept OAuth callbacks
- Refresh tokens can be securely stored
- MCP clients can connect using PKCE
- End-to-end tool execution works
The progressive consent architecture documented here represents the **target state** for production deployments.
## Testing ## Testing
The ADR-004 Hybrid Flow is fully tested via automated integration tests: The ADR-004 Hybrid Flow is currently tested via automated integration tests (using the simplified implementation):
### Integration Tests ### Integration Tests