71e77e95bc
Resolves the token exchange implementation gap where get_session_client() was implemented but never used by tools. Unifies token acquisition into a single async get_client() method that handles both pass-through and token exchange modes transparently. Core Changes: - Make get_client() async and merge token exchange logic into it - Remove scopes parameter from token exchange (Nextcloud doesn't support OAuth scopes) - Update all 8 tool modules to use await get_client(ctx) - Fix provisioning decorator to skip checks in BasicAuth mode Token Acquisition Modes: 1. BasicAuth: Returns shared client (no token operations) 2. OAuth pass-through (default): Verifies and passes Flow 1 token to Nextcloud 3. OAuth token exchange (opt-in): Exchanges Flow 1 token for ephemeral token via RFC 8693 Key Architectural Clarifications: - Progressive Consent (Flow 1/2) = Authorization architecture - Token Exchange = Token acquisition pattern during tool execution - Refresh tokens from Flow 2 are NEVER used for tool calls (only background jobs) - Nextcloud scopes are "soft-scopes" enforced by MCP server, not IdP Documentation Updates: - ADR-004: Added comprehensive token acquisition patterns section - CRITICAL-TOKEN-EXCHANGE-PATTERN.md: Updated to reflect implementation status - CLAUDE.md: Updated architectural patterns with async get_client() Testing: - All 36 unit tests passing - All 4 smoke tests passing (BasicAuth mode) - Linting issues fixed (ruff) Configuration: ENABLE_TOKEN_EXCHANGE=false (default) - pass-through mode ENABLE_TOKEN_EXCHANGE=true (opt-in) - token exchange mode 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1628 lines
63 KiB
Markdown
1628 lines
63 KiB
Markdown
# ADR-004: Federated Authentication Architecture for Offline Access
|
|
|
|
**Status**: Draft
|
|
**Date**: 2025-11-02
|
|
**Supersedes**: ADR-002
|
|
|
|
## Context
|
|
|
|
ADR-002 attempted to solve the problem of background workers accessing user data by proposing token exchange patterns. However, it fundamentally misunderstood the MCP protocol's authentication architecture and OAuth delegation patterns.
|
|
|
|
The real challenge is that:
|
|
1. The MCP server needs to access Nextcloud APIs on behalf of users
|
|
2. Background workers need to operate when users are offline
|
|
3. We need proper OAuth compliance with user consent
|
|
4. Modern enterprise environments use federated identity providers
|
|
|
|
The solution is a **Federated Authentication Architecture** where both the MCP server and Nextcloud trust the same Identity Provider (IdP).
|
|
|
|
## Problem Statement
|
|
|
|
We need a way for:
|
|
1. Users to authenticate once to a central identity provider
|
|
2. The MCP server to obtain delegated access to Nextcloud resources
|
|
3. Background workers to access user data using stored refresh tokens
|
|
4. Clean separation between identity management and resource access
|
|
|
|
The core issue: **How can the MCP server obtain refresh tokens from a shared IdP to access Nextcloud on behalf of users?**
|
|
|
|
## Decision
|
|
|
|
We will implement a **Federated Authentication Architecture using a Shared Identity Provider** where:
|
|
|
|
1. **Shared IdP**: A central identity provider (e.g., Keycloak, Okta, Azure AD) manages user authentication
|
|
2. **MCP Server as OAuth Client**: The MCP server registers with the shared IdP to request tokens
|
|
3. **Nextcloud as Resource Server**: Nextcloud validates tokens issued by the shared IdP
|
|
4. **On-Behalf-Of Flow**: The MCP server requests tokens scoped for Nextcloud access
|
|
|
|
The MCP server will:
|
|
- Act as an OAuth client to the shared IdP
|
|
- Request tokens on behalf of users, scoped for Nextcloud API access
|
|
- Store refresh tokens securely with rotation
|
|
- Use stored tokens for both MCP sessions and background operations
|
|
|
|
## Architecture
|
|
|
|
### Progressive Consent Architecture with Dual OAuth Flows
|
|
|
|
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 Principle
|
|
|
|
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
|
|
|
|
**CRITICAL**: Each flow uses a DIFFERENT `client_id` for proper OAuth delegation.
|
|
|
|
#### 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")
|
|
- 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 stored refresh token
|
|
- Used for Nextcloud API access
|
|
- Never exposed to MCP clients
|
|
- Short-lived (5-15 minutes), cached by server
|
|
|
|
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 │──────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 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
|
|
|
|
**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
|
|
|
|
#### Flow 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<br/>(Claude Desktop)
|
|
participant MCPServer as MCP Server<br/>(Stateless)
|
|
participant IdP as Shared IdP<br/>(Keycloak)
|
|
|
|
User->>MCPClient: Connect to MCP Server
|
|
MCPClient->>MCPServer: Initial request
|
|
MCPServer-->>MCPClient: 401 Unauthorized<br/>WWW-Authenticate: Bearer realm="Keycloak"<br/>auth_endpoint=https://keycloak/auth
|
|
|
|
Note over MCPClient: Generate PKCE:<br/>verifier + challenge
|
|
|
|
MCPClient->>MCPClient: Start local callback server<br/>http://localhost:51234/callback
|
|
|
|
MCPClient->>IdP: GET /auth<br/>client_id="claude-desktop" ← Client's own ID!<br/>redirect_uri=http://localhost:51234/callback<br/>scope=openid profile mcp-server:api<br/>code_challenge + S256<br/>response_type=code
|
|
|
|
Note over IdP: Standard OAuth flow<br/>MCP Server NOT involved!
|
|
|
|
IdP->>User: Login page (if not authenticated)
|
|
User->>IdP: Authenticate (username + password)
|
|
|
|
IdP->>User: Consent: "Allow Claude Desktop<br/>to access MCP Server?"
|
|
User->>IdP: Approve
|
|
|
|
IdP->>MCPClient: 302 to http://localhost:51234/callback<br/>code={authorization_code}
|
|
|
|
MCPClient->>IdP: POST /token<br/>code={authorization_code}<br/>client_id="claude-desktop"<br/>code_verifier={verifier}<br/>redirect_uri=http://localhost:51234/callback
|
|
|
|
IdP->>MCPClient: Tokens:<br/>- access_token (aud: mcp-server)<br/>- id_token<br/>- token_type: Bearer<br/>- expires_in: 3600
|
|
|
|
Note over MCPClient: Client has token with<br/>aud: "mcp-server"
|
|
|
|
MCPClient->>MCPServer: MCP request<br/>Authorization: Bearer {token}
|
|
|
|
MCPServer->>MCPServer: Validate:<br/>1. aud == "mcp-server" ✓<br/>2. Scopes include required scope ✓
|
|
|
|
MCPServer-->>MCPClient: Success (or 403 if wrong audience)
|
|
```
|
|
|
|
**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
|
|
participant User
|
|
participant MCPClient as MCP Client
|
|
participant MCPServer as MCP Server
|
|
participant TokenStore as Token Storage
|
|
participant IdP as Shared IdP
|
|
participant Nextcloud
|
|
|
|
Note over User,MCPClient: User authenticated from Flow 1
|
|
|
|
MCPClient->>MCPServer: list_notes()<br/>Authorization: Bearer {mcp_token}
|
|
|
|
MCPServer->>MCPServer: Validate token:<br/>aud == "mcp-server" ✓
|
|
|
|
MCPServer->>MCPServer: Extract user_id from token
|
|
|
|
MCPServer->>TokenStore: Check: Has refresh token<br/>for user_id?
|
|
TokenStore-->>MCPServer: NOT FOUND
|
|
|
|
MCPServer-->>MCPClient: Error Response:<br/>"Not provisioned for Nextcloud access.<br/>Please call provision_nextcloud_access tool."
|
|
|
|
Note over User,MCPClient: User explicitly calls provisioning
|
|
|
|
MCPClient->>MCPServer: provision_nextcloud_access()<br/>Authorization: Bearer {mcp_token}
|
|
|
|
MCPServer->>MCPServer: Validate token:<br/>aud == "mcp-server" ✓
|
|
|
|
MCPServer->>MCPServer: Generate state linking to user_id
|
|
|
|
MCPServer-->>MCPClient: Return OAuth URL:<br/>{<br/> "auth_url": "https://keycloak/auth?...",<br/> "message": "Open this URL to authorize"<br/>}
|
|
|
|
Note over MCPClient,IdP: This is a SECOND, SEPARATE OAuth flow!<br/>Uses MCP server's client_id!
|
|
|
|
User->>IdP: Navigate to auth_url:<br/>client_id="mcp-server" ← Server's ID!<br/>redirect_uri=https://mcp-server.com/callback-nextcloud<br/>scope=openid offline_access nextcloud:api<br/>state={user_id_link}
|
|
|
|
Note over IdP: User may already be logged in (SSO)<br/>Just need consent, not re-authentication
|
|
|
|
IdP->>User: Consent: "Allow MCP Server<br/>to access Nextcloud offline<br/>on your behalf?"
|
|
User->>IdP: Approve offline_access
|
|
|
|
IdP->>MCPServer: 302 to /callback-nextcloud<br/>code={authorization_code}<br/>state={user_id_link}
|
|
|
|
MCPServer->>MCPServer: Validate state,<br/>extract user_id
|
|
|
|
MCPServer->>IdP: POST /token<br/>code={authorization_code}<br/>client_id="mcp-server"<br/>client_secret={server_secret}<br/>redirect_uri=https://mcp-server.com/callback-nextcloud
|
|
|
|
IdP->>MCPServer: Tokens:<br/>- access_token (aud: nextcloud)<br/>- refresh_token ← MASTER TOKEN!<br/>- id_token
|
|
|
|
MCPServer->>TokenStore: Store refresh token<br/>(encrypted, linked to user_id)
|
|
|
|
MCPServer-->>User: "Provisioning complete!<br/>You can now access Nextcloud."
|
|
|
|
Note over MCPServer: Server is now STATEFUL<br/>Has refresh token for this user
|
|
```
|
|
|
|
**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<br/>(Stateful)
|
|
participant TokenStore as Token Storage
|
|
participant IdP as Shared IdP
|
|
participant Nextcloud
|
|
|
|
Note over MCPClient: User reconnects (new MCP session)
|
|
|
|
MCPClient->>MCPServer: list_notes()<br/>Authorization: Bearer {new_mcp_token}
|
|
|
|
MCPServer->>MCPServer: Validate token:<br/>aud == "mcp-server" ✓
|
|
|
|
MCPServer->>MCPServer: Extract user_id from token
|
|
|
|
MCPServer->>TokenStore: Check: Has refresh token<br/>for user_id?
|
|
TokenStore-->>MCPServer: FOUND ✓
|
|
|
|
MCPServer->>MCPServer: Check cached Nextcloud token
|
|
|
|
alt Nextcloud Token Expired or Missing
|
|
MCPServer->>IdP: POST /token<br/>grant_type=refresh_token<br/>refresh_token={stored_token}<br/>audience=nextcloud
|
|
|
|
IdP->>MCPServer: access_token (aud: nextcloud)
|
|
|
|
MCPServer->>MCPServer: Cache token (5 min TTL)
|
|
end
|
|
|
|
MCPServer->>Nextcloud: GET /notes<br/>Authorization: Bearer {nextcloud_token}
|
|
|
|
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,<br/>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
|
|
sequenceDiagram
|
|
participant Worker as Background Worker
|
|
participant TokenStore as Token Storage
|
|
participant IdP as Shared IdP
|
|
participant Nextcloud
|
|
|
|
Worker->>TokenStore: Get user's master refresh token
|
|
TokenStore-->>Worker: Encrypted master refresh token
|
|
Worker->>Worker: Decrypt token
|
|
|
|
Worker->>IdP: POST /token<br/>grant_type=refresh_token<br/>audience=nextcloud
|
|
IdP->>Worker: New access token ONLY<br/>(aud: nextcloud)
|
|
Note over IdP,Worker: Access token for Nextcloud<br/>Master refresh token unchanged
|
|
|
|
Worker->>Nextcloud: API call with access token
|
|
Nextcloud->>IdP: Validate token
|
|
IdP-->>Nextcloud: Valid + scopes
|
|
Nextcloud-->>Worker: API response
|
|
|
|
Note over Worker: No MCP client involvement!<br/>No refresh token rotation!
|
|
```
|
|
|
|
**Token Rotation Strategy:**
|
|
- **Access Tokens**: Refreshed frequently (every 5-60 minutes) as needed
|
|
- **Master Refresh Token**: Only rotated periodically (e.g., daily/weekly) or when explicitly refreshing the MCP session
|
|
- **Separation**: Getting Nextcloud access tokens does NOT rotate the master refresh token
|
|
|
|
## Implementation
|
|
|
|
### 1. Token Broker Service
|
|
|
|
```python
|
|
import jwt
|
|
from datetime import datetime, timedelta
|
|
|
|
class TokenBrokerService:
|
|
"""
|
|
Token broker that exchanges master refresh tokens for audience-specific access tokens.
|
|
Works alongside the required_scopes decorator which handles MCP token validation.
|
|
"""
|
|
|
|
def __init__(self,
|
|
token_storage: RefreshTokenStorage,
|
|
idp_client: OAuthClient):
|
|
self.storage = token_storage
|
|
self.idp_client = idp_client
|
|
self.nextcloud_token_cache = {} # Short-lived cache
|
|
|
|
async def get_nextcloud_token(self, user_id: str) -> str | None:
|
|
"""
|
|
Get or refresh token with Nextcloud audience.
|
|
Called AFTER the required_scopes decorator has validated the MCP token.
|
|
"""
|
|
# Check cache first
|
|
cached = self.nextcloud_token_cache.get(user_id)
|
|
if cached and cached['exp'] > datetime.utcnow().timestamp():
|
|
return cached['token']
|
|
|
|
# Get master refresh token (stored during OAuth flow)
|
|
refresh_token = await self.storage.get_refresh_token(user_id)
|
|
if not refresh_token:
|
|
logger.warning(f"No refresh token for user {user_id}")
|
|
return None # User needs to re-authenticate
|
|
|
|
try:
|
|
# Request new ACCESS token with Nextcloud audience
|
|
# This does NOT rotate the master refresh token!
|
|
response = await self.idp_client.refresh_token(
|
|
refresh_token=refresh_token,
|
|
audience='nextcloud' # CRITICAL: Request Nextcloud audience
|
|
)
|
|
|
|
# Verify the new token has correct audience
|
|
payload = jwt.decode(
|
|
response.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 wrong audience: {audiences}")
|
|
|
|
# Cache for short period (5 minutes)
|
|
self.nextcloud_token_cache[user_id] = {
|
|
'token': response.access_token,
|
|
'exp': payload.get('exp', 0)
|
|
}
|
|
|
|
return response.access_token
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to get Nextcloud token: {e}")
|
|
return None
|
|
|
|
async def refresh_master_token(self, user_id: str):
|
|
"""
|
|
Refresh the master refresh token (with rotation).
|
|
This should only be called periodically (e.g., weekly) or when
|
|
explicitly refreshing the MCP session, NOT on every API call.
|
|
"""
|
|
old_refresh = await self.storage.get_refresh_token(user_id)
|
|
if not old_refresh:
|
|
raise ValueError("No refresh token found")
|
|
|
|
# Mark as used (rotation)
|
|
await self.storage.mark_token_used(old_refresh.token_id)
|
|
|
|
try:
|
|
# Get new refresh token from IdP
|
|
response = await self.idp_client.refresh_token(
|
|
refresh_token=old_refresh.token,
|
|
scope='openid profile offline_access nextcloud:*'
|
|
)
|
|
|
|
# Store new refresh token
|
|
await self.storage.store_refresh_token(
|
|
user_id=user_id,
|
|
token_family_id=old_refresh.token_family_id,
|
|
refresh_token=response.refresh_token,
|
|
status='active'
|
|
)
|
|
|
|
return response.refresh_token
|
|
|
|
except RefreshTokenReuseError:
|
|
# Possible token theft - revoke entire family
|
|
await self.storage.revoke_token_family(old_refresh.token_family_id)
|
|
await self.alert_user_possible_breach(user_id)
|
|
raise
|
|
|
|
# Integration with FastMCP framework
|
|
class MCPTokenVerifier(TokenVerifier):
|
|
"""
|
|
Simple verifier that checks audience for MCP tokens.
|
|
Used by FastMCP framework alongside required_scopes decorator.
|
|
"""
|
|
|
|
async def verify_token(self, token: str) -> AccessToken | None:
|
|
"""Verify token has correct audience for MCP server."""
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
options={"verify_signature": False} # IdP handles signature
|
|
)
|
|
|
|
# CRITICAL: Verify audience is MCP server
|
|
audiences = payload.get('aud', [])
|
|
if isinstance(audiences, str):
|
|
audiences = [audiences]
|
|
|
|
if 'mcp-server' not in audiences:
|
|
logger.warning(f"Token rejected: wrong audience {audiences}")
|
|
return None
|
|
|
|
# Check expiry
|
|
if payload.get('exp', 0) < datetime.utcnow().timestamp():
|
|
return None
|
|
|
|
return AccessToken(
|
|
token=token, # Keep original MCP token
|
|
scopes=payload.get('scope', '').split(),
|
|
resource=json.dumps({
|
|
"user_id": payload['sub'],
|
|
"session_id": payload.get('jti')
|
|
})
|
|
)
|
|
|
|
except jwt.InvalidTokenError:
|
|
return None
|
|
```
|
|
|
|
### 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
|
|
from urllib.parse import urlencode
|
|
import secrets
|
|
|
|
@mcp.tool()
|
|
@required_scopes("mcp:provision") # Or allow all authenticated users
|
|
async def provision_nextcloud_access(ctx: Context) -> dict:
|
|
"""
|
|
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
|
|
"""
|
|
# 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']
|
|
|
|
# Check if already provisioned
|
|
token_storage = get_token_storage(ctx)
|
|
existing_token = await token_storage.get_refresh_token(user_id)
|
|
if existing_token:
|
|
return {
|
|
"status": "already_provisioned",
|
|
"message": "MCP server already has Nextcloud access for this user."
|
|
}
|
|
|
|
# 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. 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(
|
|
"<html><body>"
|
|
"<h1>Error</h1>"
|
|
"<p>Invalid or expired authorization request.</p>"
|
|
"</body></html>",
|
|
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(
|
|
"<html><body>"
|
|
"<h1>Success!</h1>"
|
|
"<p>MCP server has been granted access to Nextcloud on your behalf.</p>"
|
|
"<p>You can close this window and return to your MCP client.</p>"
|
|
"</body></html>"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to exchange authorization code: {e}")
|
|
return HTMLResponse(
|
|
"<html><body>"
|
|
"<h1>Error</h1>"
|
|
"<p>Failed to complete authorization. Please try again.</p>"
|
|
f"<p>Error: {str(e)}</p>"
|
|
"</body></html>",
|
|
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
|
|
|
|
def required_scopes(*scopes):
|
|
"""
|
|
Decorator that verifies token audience and scopes.
|
|
The existing required_scopes decorator needs to be updated
|
|
to verify audience: "mcp-server" for all incoming tokens.
|
|
"""
|
|
def decorator(func):
|
|
@wraps(func)
|
|
async def wrapper(ctx: Context, *args, **kwargs):
|
|
# Get token from context (set by FastMCP framework)
|
|
token = ctx.authorization.token if ctx.authorization else None
|
|
|
|
if not token:
|
|
raise Unauthorized("No token provided")
|
|
|
|
# Decode and verify audience
|
|
try:
|
|
payload = jwt.decode(
|
|
token,
|
|
options={"verify_signature": False} # IdP handles signature
|
|
)
|
|
|
|
# CRITICAL: Verify token is for MCP server
|
|
audiences = payload.get('aud', [])
|
|
if isinstance(audiences, str):
|
|
audiences = [audiences]
|
|
|
|
if 'mcp-server' not in audiences:
|
|
raise Unauthorized(f"Invalid audience: {audiences}")
|
|
|
|
# Verify required scopes
|
|
token_scopes = set(payload.get('scope', '').split())
|
|
required = set(scopes)
|
|
|
|
if not required.issubset(token_scopes):
|
|
missing = required - token_scopes
|
|
raise Forbidden(f"Missing scopes: {missing}")
|
|
|
|
# Token is valid for MCP server with required scopes
|
|
return await func(ctx, *args, **kwargs)
|
|
|
|
except jwt.InvalidTokenError as e:
|
|
raise Unauthorized(f"Invalid token: {e}")
|
|
|
|
return wrapper
|
|
return decorator
|
|
|
|
# Example usage in MCP tools
|
|
@mcp.tool()
|
|
@required_scopes("notes:read")
|
|
async def list_notes(ctx: Context) -> dict:
|
|
"""List notes - token audience and scopes are automatically verified."""
|
|
# Token already verified to have audience: "mcp-server"
|
|
# Now get Nextcloud token for backend access
|
|
|
|
token_broker = get_token_broker(ctx)
|
|
nextcloud_token = await token_broker.get_nextcloud_token(ctx.user_id)
|
|
|
|
# Use Nextcloud token for API access
|
|
client = NextcloudClient.from_token(
|
|
base_url=NEXTCLOUD_HOST,
|
|
token=nextcloud_token # Token with aud: "nextcloud"
|
|
)
|
|
|
|
notes = await client.notes.list_notes()
|
|
return {"notes": notes}
|
|
```
|
|
|
|
### 4. Token Storage Schema
|
|
|
|
```sql
|
|
-- User accounts (created from IdP identity)
|
|
CREATE TABLE users (
|
|
id TEXT PRIMARY KEY,
|
|
idp_sub TEXT UNIQUE NOT NULL, -- IdP subject identifier
|
|
username TEXT NOT NULL,
|
|
email TEXT,
|
|
created_at INTEGER NOT NULL,
|
|
last_login INTEGER NOT NULL
|
|
);
|
|
|
|
-- IdP tokens with rotation support
|
|
CREATE TABLE idp_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
token_family_id TEXT NOT NULL, -- Groups all tokens in rotation chain
|
|
encrypted_access_token BLOB NOT NULL,
|
|
encrypted_refresh_token BLOB NOT NULL,
|
|
access_expires_at INTEGER NOT NULL,
|
|
status TEXT NOT NULL CHECK(status IN ('active', 'used', 'revoked')),
|
|
scopes TEXT NOT NULL, -- Includes Nextcloud scopes
|
|
idp_subject TEXT NOT NULL, -- IdP user identifier
|
|
created_at INTEGER NOT NULL,
|
|
used_at INTEGER, -- When token was exchanged
|
|
|
|
-- Only one active token per family
|
|
UNIQUE(token_family_id, status) WHERE status = 'active'
|
|
);
|
|
|
|
-- Index for quick lookups
|
|
CREATE INDEX idx_active_tokens ON idp_tokens(user_id, status)
|
|
WHERE status = 'active';
|
|
CREATE INDEX idx_token_families ON idp_tokens(token_family_id);
|
|
|
|
-- MCP session tokens (separate from IdP tokens)
|
|
CREATE TABLE mcp_sessions (
|
|
session_token TEXT PRIMARY KEY,
|
|
user_id TEXT NOT NULL REFERENCES users(id),
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
last_used INTEGER
|
|
);
|
|
|
|
-- OAuth flow sessions with PKCE support (temporary during auth)
|
|
CREATE TABLE oauth_sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
client_id TEXT,
|
|
client_redirect_uri TEXT NOT NULL, -- Client's localhost redirect URI
|
|
state TEXT,
|
|
code_challenge TEXT, -- PKCE code challenge from client
|
|
code_challenge_method TEXT, -- PKCE method (S256)
|
|
mcp_authorization_code TEXT UNIQUE, -- MCP-generated code (e.g., mcp-code-xyz)
|
|
idp_access_token TEXT, -- Stored after IdP exchange in /oauth/callback
|
|
user_id TEXT, -- Set after IdP authentication
|
|
created_at INTEGER NOT NULL,
|
|
expires_at INTEGER NOT NULL
|
|
);
|
|
|
|
-- Audit log for security
|
|
CREATE TABLE token_audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id TEXT NOT NULL,
|
|
token_family_id TEXT,
|
|
operation TEXT NOT NULL, -- 'authorize', 'refresh', 'revoke', 'reuse_detected'
|
|
timestamp INTEGER NOT NULL,
|
|
ip_address TEXT,
|
|
user_agent TEXT,
|
|
details TEXT
|
|
);
|
|
```
|
|
|
|
### 5. Background Worker (Access Token Only)
|
|
|
|
```python
|
|
class BackgroundSyncWorker:
|
|
"""Background workers use master refresh token to get Nextcloud access tokens."""
|
|
|
|
def __init__(self, token_storage: RefreshTokenStorage):
|
|
self.storage = token_storage
|
|
self.idp_client = OAuthClient.from_discovery(IDP_DISCOVERY_URL)
|
|
self.nextcloud_url = os.getenv("NEXTCLOUD_HOST")
|
|
self.nextcloud_token_cache = {} # Short-lived cache
|
|
|
|
async def sync_user_data(self, user_id: str):
|
|
"""
|
|
Sync data using master refresh token to get Nextcloud access tokens.
|
|
|
|
Key Points:
|
|
- Workers use the master refresh token stored during OAuth flow
|
|
- Workers request access tokens with audience: "nextcloud"
|
|
- NO refresh token rotation during normal operations
|
|
- Master refresh token only rotated periodically (e.g., weekly)
|
|
"""
|
|
# Get master refresh token (stored during initial OAuth)
|
|
master_refresh_token = await self.storage.get_refresh_token(user_id)
|
|
if not master_refresh_token:
|
|
logger.warning(f"No master refresh token for user {user_id}")
|
|
return
|
|
|
|
try:
|
|
# Check cache for valid Nextcloud access token
|
|
cached = self.nextcloud_token_cache.get(user_id)
|
|
if cached and cached['exp'] > datetime.utcnow().timestamp():
|
|
nextcloud_token = cached['token']
|
|
else:
|
|
# Get new ACCESS token with Nextcloud audience
|
|
# This does NOT rotate the refresh token!
|
|
response = await self.idp_client.refresh_token(
|
|
refresh_token=master_refresh_token,
|
|
audience='nextcloud' # Request Nextcloud audience
|
|
)
|
|
|
|
# Verify audience is for Nextcloud (security check)
|
|
payload = jwt.decode(
|
|
response.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 wrong audience: {audiences}")
|
|
|
|
# Cache the access token for 5 minutes
|
|
self.nextcloud_token_cache[user_id] = {
|
|
'token': response.access_token,
|
|
'exp': payload.get('exp', 0)
|
|
}
|
|
nextcloud_token = response.access_token
|
|
|
|
# Create Nextcloud client with access token
|
|
# Token has audience: "nextcloud" and proper scopes
|
|
client = NextcloudClient.from_token(
|
|
base_url=self.nextcloud_url,
|
|
token=nextcloud_token, # Access token with aud: nextcloud
|
|
username=user_id
|
|
)
|
|
|
|
# Perform sync operations with Nextcloud
|
|
await self.sync_notes(user_id, client)
|
|
await self.sync_calendar(user_id, client)
|
|
await self.sync_contacts(user_id, client)
|
|
|
|
logger.info(f"Background sync completed for user {user_id}")
|
|
|
|
except HTTPStatusError as e:
|
|
if e.response.status_code == 401:
|
|
# Access token rejected - try clearing cache
|
|
self.nextcloud_token_cache.pop(user_id, None)
|
|
# If persistent, may need to trigger re-authentication
|
|
await self.log_security_event(
|
|
user_id,
|
|
"access_token_rejected",
|
|
f"Nextcloud rejected access token for user {user_id}"
|
|
)
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Background sync failed for user {user_id}: {e}")
|
|
raise
|
|
|
|
async def log_security_event(self, user_id: str, event: str, details: str):
|
|
"""Log security events for audit."""
|
|
await self.storage.log_audit(
|
|
user_id=user_id,
|
|
operation=event,
|
|
details=details,
|
|
timestamp=datetime.utcnow().isoformat()
|
|
)
|
|
```
|
|
|
|
### 6. Configuration
|
|
|
|
```python
|
|
# Environment variables for federated setup
|
|
IDP_DISCOVERY_URL = os.getenv("IDP_DISCOVERY_URL") # e.g., https://keycloak.example.com/realms/master/.well-known/openid-configuration
|
|
MCP_SERVER_CLIENT_ID = os.getenv("MCP_SERVER_CLIENT_ID") # MCP server's client ID in IdP
|
|
MCP_SERVER_CLIENT_SECRET = os.getenv("MCP_SERVER_CLIENT_SECRET") # Client secret
|
|
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000")
|
|
|
|
# Nextcloud configuration
|
|
NEXTCLOUD_HOST = os.getenv("NEXTCLOUD_HOST") # Nextcloud instance URL
|
|
|
|
# Parse IdP discovery document
|
|
async def setup_idp_client():
|
|
"""Initialize OAuth client from IdP discovery."""
|
|
async with httpx.AsyncClient() as client:
|
|
discovery = await client.get(IDP_DISCOVERY_URL)
|
|
discovery_doc = discovery.json()
|
|
|
|
return OAuthClient(
|
|
authorization_endpoint=discovery_doc["authorization_endpoint"],
|
|
token_endpoint=discovery_doc["token_endpoint"],
|
|
introspection_endpoint=discovery_doc.get("introspection_endpoint"),
|
|
userinfo_endpoint=discovery_doc["userinfo_endpoint"],
|
|
client_id=MCP_SERVER_CLIENT_ID,
|
|
client_secret=MCP_SERVER_CLIENT_SECRET
|
|
)
|
|
```
|
|
|
|
## Advantages
|
|
|
|
1. **Single Sign-On**: Users authenticate once to the shared IdP
|
|
2. **Federated Identity**: Enterprise-ready with support for SAML, LDAP backends
|
|
3. **True Offline Access**: Background workers operate with stored IdP refresh tokens
|
|
4. **OAuth Compliant**: Proper delegation with on-behalf-of pattern
|
|
5. **Security Isolation**: MCP clients never see IdP or Nextcloud credentials
|
|
6. **Flexible Backend**: Can swap Nextcloud for other resources without changing auth
|
|
7. **Standard Pattern**: Industry-standard federated OAuth architecture
|
|
|
|
## Disadvantages
|
|
|
|
1. **IdP Dependency**: Requires a shared identity provider infrastructure
|
|
2. **Complex Token Lifecycle**: Managing tokens from IdP for Nextcloud access
|
|
3. **Token Validation Overhead**: Nextcloud must validate tokens with IdP
|
|
4. **Migration Complexity**: Existing deployments need IdP setup
|
|
|
|
## 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
|
|
The architecture enforces **strict audience isolation** to prevent token misuse:
|
|
|
|
- **Tokens with `audience: "mcp-server"`** can ONLY authenticate to MCP server
|
|
- **Tokens with `audience: "nextcloud"`** can ONLY access Nextcloud APIs
|
|
- **No token has multiple audiences** - this would be a security boundary violation
|
|
- **Compromised MCP tokens cannot access Nextcloud** directly
|
|
|
|
#### Token Broker Security Model
|
|
|
|
The MCP server acts as a **secure token broker**:
|
|
1. Validates incoming tokens have `audience: "mcp-server"`
|
|
2. Uses stored refresh tokens to obtain `audience: "nextcloud"` tokens
|
|
3. Never exposes Nextcloud tokens to MCP clients
|
|
4. Maintains separate token lifecycles for each audience
|
|
|
|
#### Audience Validation Examples
|
|
```python
|
|
# MCP Access Token (from IdP)
|
|
{
|
|
"aud": "mcp-server", # Single audience ONLY
|
|
"sub": "user-123",
|
|
"scope": "mcp:full",
|
|
"exp": 1234567890
|
|
}
|
|
|
|
# Nextcloud Access Token (obtained via refresh)
|
|
{
|
|
"aud": "nextcloud", # Different audience
|
|
"sub": "user-123",
|
|
"scope": "notes:read calendar:write",
|
|
"exp": 1234567890
|
|
}
|
|
|
|
# Master Refresh Token Claims (stored by server)
|
|
{
|
|
"sub": "user-123",
|
|
"scope": "openid offline_access nextcloud:notes:read nextcloud:calendar:write",
|
|
# Can mint tokens with aud: "nextcloud" via refresh grant
|
|
}
|
|
```
|
|
|
|
### 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 by IdP before token issuance
|
|
- **Note**: Flow 2 uses confidential client pattern (server has client_secret)
|
|
|
|
### Native Client Security
|
|
- **Localhost redirect only** (RFC 8252)
|
|
- Restrict to `http://localhost:*` or `http://127.0.0.1:*`
|
|
- Dynamic port allocation per session
|
|
- No custom URL schemes allowed
|
|
- **System browser required** - no embedded browsers
|
|
- **302 redirect flow** - direct redirect, no HTML page
|
|
|
|
### Token Storage Security
|
|
- **Master refresh tokens**: Encrypted at rest (Fernet/AES-256)
|
|
- **Audience-specific caching**: Short-lived cache for Nextcloud tokens
|
|
- **Database isolation**: Refresh tokens never exposed to application layer
|
|
- **Key rotation**: Support for encryption key rotation
|
|
- **Hardware security**: Consider HSM for production
|
|
|
|
### Token Rotation with Audience Preservation
|
|
- **Rotation maintains audience**: New tokens keep same audience
|
|
- **Reuse detection**: Previous use revokes entire token family
|
|
- **Atomic operations**: Database transactions prevent races
|
|
- **Audit trail**: All exchanges logged with audience info
|
|
|
|
### Trust Boundaries
|
|
|
|
```
|
|
┌──────────────┐ aud:"mcp-server" ┌──────────────┐
|
|
│ MCP Client │──────────────────────────►│ MCP Server │
|
|
└──────────────┘ └───────┬──────┘
|
|
│
|
|
Refresh for different
|
|
audience
|
|
│
|
|
┌──────────────┐ aud:"nextcloud" ┌───────▼──────┐
|
|
│ Nextcloud │◄──────────────────────────│ IdP │
|
|
└──────────────┘ └──────────────┘
|
|
```
|
|
|
|
1. **MCP Client → MCP Server**: Only `aud:"mcp-server"` tokens
|
|
2. **MCP Server → IdP**: Refresh with audience parameter
|
|
3. **MCP Server → Nextcloud**: Only `aud:"nextcloud"` tokens
|
|
4. **No direct path**: Client cannot use MCP tokens for Nextcloud
|
|
|
|
### Revocation and Breach Response
|
|
- **Audience-specific revocation**: Can revoke MCP without affecting Nextcloud
|
|
- **Token family tracking**: All tokens from same refresh chain
|
|
- **Immediate propagation**: Revocation flows through trust chain
|
|
- **Breach isolation**: Compromised MCP tokens don't grant Nextcloud access
|
|
|
|
## Migration Strategy
|
|
|
|
### Phase 1: IdP Setup
|
|
1. Deploy shared IdP (Keycloak recommended)
|
|
2. Register MCP server as OAuth client
|
|
3. Configure Nextcloud to accept IdP tokens
|
|
4. Test token validation flow
|
|
|
|
### Phase 2: Parallel Operation
|
|
1. Keep existing pass-through authentication
|
|
2. Add federated auth as optional feature flag
|
|
3. Test with subset of users
|
|
4. Monitor token lifecycle and refresh patterns
|
|
|
|
### Phase 3: Migration
|
|
1. Migrate existing users to IdP accounts
|
|
2. Map existing permissions to IdP scopes
|
|
3. Update clients to use new OAuth flow
|
|
4. Maintain backward compatibility period
|
|
|
|
### Phase 4: Deprecation
|
|
1. Announce end-of-life for pass-through mode
|
|
2. Complete user migration
|
|
3. Remove legacy authentication code
|
|
4. Document new auth flow
|
|
|
|
## Alternatives Considered
|
|
|
|
### 1. Pass-Through Only (Current)
|
|
- **Pros**: Simple, stateless
|
|
- **Cons**: No offline access possible
|
|
- **Rejected**: Doesn't meet requirements
|
|
|
|
### 2. Service Accounts (ADR-002 Tier 1)
|
|
- **Pros**: Simple to implement
|
|
- **Cons**: Violates OAuth principles, creates audit issues
|
|
- **Rejected**: Security and compliance concerns
|
|
|
|
### 3. Token Exchange (ADR-002 Tier 2)
|
|
- **Pros**: Standards-based (RFC 8693)
|
|
- **Cons**: Circular dependency, doesn't solve bootstrap problem
|
|
- **Rejected**: Doesn't enable true offline access
|
|
|
|
### 4. Sign-in with Nextcloud (Previous ADR-004)
|
|
- **Pros**: Direct Nextcloud integration
|
|
- **Cons**: Tight coupling, no enterprise IdP support
|
|
- **Rejected**: Not suitable for federated environments
|
|
|
|
### 5. Double OAuth (Manual)
|
|
- **Pros**: Clear separation of concerns
|
|
- **Cons**: Poor UX with two login prompts
|
|
- **Rejected**: Users shouldn't authenticate twice
|
|
|
|
## Token Exchange Pattern Implementation
|
|
|
|
### How Audience-Specific Token Exchange Works
|
|
|
|
The key to this architecture is the IdP's ability to issue tokens with different audiences from a single refresh token. This is achieved through:
|
|
|
|
#### 1. Keycloak Configuration
|
|
|
|
```javascript
|
|
// Keycloak Client Configuration for MCP Server
|
|
{
|
|
"clientId": "mcp-server",
|
|
"standardFlowEnabled": true,
|
|
"serviceAccountsEnabled": false,
|
|
"publicClient": false,
|
|
"attributes": {
|
|
// Allow refresh tokens to request different audiences
|
|
"oauth2.device.authorization.grant.enabled": "false",
|
|
"oidc.ciba.grant.enabled": "false",
|
|
"oauth2.token.exchange.grant.enabled": "true" // Enable token exchange
|
|
}
|
|
}
|
|
|
|
// Audience Mapper Configuration
|
|
{
|
|
"name": "dynamic-audience-mapper",
|
|
"protocol": "openid-connect",
|
|
"protocolMapper": "oidc-audience-mapper",
|
|
"config": {
|
|
"included.client.audience": "mcp-server", // Default audience
|
|
"access.token.claim": "true",
|
|
"id.token.claim": "false"
|
|
}
|
|
}
|
|
|
|
// Scope-to-Audience Mapping
|
|
{
|
|
"mcp:*": "mcp-server", // MCP scopes → mcp-server audience
|
|
"nextcloud:*": "nextcloud" // Nextcloud scopes → nextcloud audience
|
|
}
|
|
```
|
|
|
|
#### 2. Refresh Token with Audience Parameter
|
|
|
|
When the MCP server needs a token for a specific audience:
|
|
|
|
```http
|
|
POST /realms/nextcloud-mcp/protocol/openid-connect/token
|
|
Content-Type: application/x-www-form-urlencoded
|
|
|
|
grant_type=refresh_token
|
|
&refresh_token=eyJhbGc...
|
|
&client_id=mcp-server
|
|
&client_secret=secret
|
|
&audience=nextcloud # Request specific audience
|
|
```
|
|
|
|
Response:
|
|
```json
|
|
{
|
|
"access_token": "eyJhbGc...", // Token with aud: "nextcloud"
|
|
"expires_in": 300,
|
|
"refresh_token": "eyJhbGc...", // Same or rotated refresh token
|
|
"token_type": "Bearer"
|
|
}
|
|
```
|
|
|
|
#### 3. Alternative: Token Exchange (RFC 8693)
|
|
|
|
For IdPs that support token exchange:
|
|
|
|
```http
|
|
POST /realms/nextcloud-mcp/protocol/openid-connect/token
|
|
Content-Type: application/x-www-form-urlencoded
|
|
|
|
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
|
|
&subject_token=eyJhbGc... # Token with aud: "mcp-server"
|
|
&subject_token_type=urn:ietf:params:oauth:token-type:access_token
|
|
&requested_token_type=urn:ietf:params:oauth:token-type:access_token
|
|
&audience=nextcloud # Request different audience
|
|
```
|
|
|
|
### Why This Pattern Is Secure
|
|
|
|
1. **Audience Validation at Every Layer**:
|
|
- MCP server validates `aud: "mcp-server"` for incoming requests
|
|
- Nextcloud validates `aud: "nextcloud"` for API calls
|
|
- Tokens with wrong audience are rejected
|
|
|
|
2. **Unidirectional Token Flow**:
|
|
- Client → MCP: Only `aud: "mcp-server"`
|
|
- MCP → Nextcloud: Only `aud: "nextcloud"`
|
|
- No reverse flow possible
|
|
|
|
3. **Breach Containment**:
|
|
- Stolen MCP token: Cannot access Nextcloud
|
|
- Stolen Nextcloud token: Cannot authenticate to MCP
|
|
- Stolen refresh token: Requires client credentials to use
|
|
|
|
### Configuration for Popular IdPs
|
|
|
|
#### Keycloak
|
|
- Enable Token Exchange in realm settings
|
|
- Configure audience mappers per client
|
|
- Use protocol mappers for dynamic audiences
|
|
|
|
#### Auth0
|
|
- Use custom rules for audience selection
|
|
- Configure API identifiers as audiences
|
|
- Enable refresh token rotation
|
|
|
|
#### Azure AD
|
|
- Configure app registrations for each audience
|
|
- Use scope-to-resource mapping
|
|
- Enable conditional access policies
|
|
|
|
#### Okta
|
|
- Define custom authorization servers
|
|
- Configure audience claim per API
|
|
- Use inline hooks for dynamic audiences
|
|
|
|
## Token Acquisition Patterns for MCP Tool Calls
|
|
|
|
### Progressive Consent vs Token Exchange
|
|
|
|
**IMPORTANT**: Progressive Consent and Token Exchange are complementary patterns that serve different purposes:
|
|
|
|
- **Progressive Consent** = Authorization architecture (when and why users grant access)
|
|
- Flow 1: MCP client authenticates to MCP server
|
|
- Flow 2: MCP server provisions Nextcloud access
|
|
- Results in stored refresh tokens for background jobs
|
|
|
|
- **Token Exchange** = Token acquisition pattern (how tokens are obtained during tool execution)
|
|
- Pass-through mode: Verify and pass Flow 1 token to Nextcloud
|
|
- Token exchange mode: Exchange Flow 1 token for ephemeral Nextcloud token
|
|
- Results in short-lived, operation-specific tokens
|
|
|
|
**Key Principle**: Refresh tokens from Progressive Consent (Flow 2) should **NEVER** be used for MCP tool calls - they are exclusively for background jobs. This maintains clear separation between user-initiated operations and offline background work.
|
|
|
|
### Two Token Acquisition Modes
|
|
|
|
The MCP server supports two modes for obtaining Nextcloud tokens during tool execution:
|
|
|
|
#### Mode 1: Pass-Through (Default - ENABLE_TOKEN_EXCHANGE=false)
|
|
|
|
**How it works:**
|
|
1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call
|
|
2. MCP server validates token audience and scopes
|
|
3. MCP server passes the same token to Nextcloud
|
|
4. Nextcloud validates token with IdP
|
|
|
|
**Characteristics:**
|
|
- Simple, stateless operation
|
|
- Single token flows through the system
|
|
- Lower latency (no token exchange round-trip)
|
|
- Token lifetime determined by IdP's Flow 1 token settings
|
|
|
|
**Use case**: Simple deployments where Flow 1 tokens are trusted to access Nextcloud directly.
|
|
|
|
#### Mode 2: Token Exchange (Opt-In - ENABLE_TOKEN_EXCHANGE=true)
|
|
|
|
**How it works:**
|
|
1. MCP client sends Flow 1 token (aud: "mcp-server") with tool call
|
|
2. MCP server validates token audience and scopes
|
|
3. MCP server exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
|
4. MCP server uses ephemeral token for Nextcloud API call
|
|
5. Ephemeral token is discarded (not cached)
|
|
|
|
**Characteristics:**
|
|
- Enhanced security through token delegation
|
|
- Ephemeral tokens with minimal lifetime (5 minutes default)
|
|
- Token exchange provides audit trail
|
|
- Fallback to refresh grant if RFC 8693 not supported
|
|
- Tokens never cached or stored
|
|
|
|
**Use case**: High-security environments requiring token delegation, audit trails, and minimal token lifetimes.
|
|
|
|
### Implementation in get_client()
|
|
|
|
The token acquisition mode is handled transparently by `get_client()`:
|
|
|
|
```python
|
|
async def get_client(ctx: Context) -> NextcloudClient:
|
|
"""
|
|
Get the appropriate Nextcloud client based on authentication mode.
|
|
|
|
This function handles three modes:
|
|
1. BasicAuth mode: Returns shared client from lifespan context
|
|
2. OAuth pass-through mode (ENABLE_TOKEN_EXCHANGE=false, default):
|
|
Verifies Flow 1 token and passes it to Nextcloud
|
|
3. OAuth token exchange mode (ENABLE_TOKEN_EXCHANGE=true):
|
|
Exchanges Flow 1 token for ephemeral Nextcloud token via RFC 8693
|
|
"""
|
|
settings = get_settings()
|
|
lifespan_ctx = ctx.request_context.lifespan_context
|
|
|
|
# BasicAuth mode
|
|
if hasattr(lifespan_ctx, "client"):
|
|
return lifespan_ctx.client
|
|
|
|
# OAuth mode
|
|
if hasattr(lifespan_ctx, "nextcloud_host"):
|
|
if settings.enable_token_exchange:
|
|
# Token exchange mode
|
|
return await get_session_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
|
else:
|
|
# Pass-through mode (default)
|
|
return get_client_from_context(ctx, lifespan_ctx.nextcloud_host)
|
|
```
|
|
|
|
### Nextcloud Scope Limitation
|
|
|
|
**CRITICAL**: Nextcloud does not support OAuth scopes natively. The scopes used in this architecture (e.g., "notes:read", "calendar:write") are **soft-scopes** enforced by the MCP server via the `@require_scopes` decorator, **not by the IdP or Nextcloud**.
|
|
|
|
**Implications:**
|
|
1. Token exchange requests don't pass scopes to the IdP (Nextcloud doesn't validate them)
|
|
2. The MCP server's `@require_scopes` decorator handles authorization checks
|
|
3. All Nextcloud tokens have equivalent permissions at the Nextcloud level
|
|
4. Fine-grained access control is enforced by the MCP server, not Nextcloud
|
|
|
|
**Why this matters:**
|
|
- You cannot request a "notes-only" token from the IdP
|
|
- Token exchange provides audit and delegation benefits, not scope restriction
|
|
- Scopes are a convenience for MCP server authorization logic, not a security boundary
|
|
|
|
### Background Job Pattern
|
|
|
|
Background jobs use a **completely different** token acquisition pattern:
|
|
|
|
```python
|
|
class BackgroundSyncWorker:
|
|
async def sync_user_data(self, user_id: str):
|
|
"""Background workers use refresh tokens from Flow 2, never from tool calls."""
|
|
|
|
# Get refresh token stored during Flow 2 (Progressive Consent)
|
|
refresh_token = await self.storage.get_refresh_token(user_id)
|
|
|
|
# Use refresh token to get Nextcloud access token
|
|
response = await self.idp_client.refresh_token(
|
|
refresh_token=refresh_token,
|
|
audience='nextcloud'
|
|
)
|
|
|
|
# Use access token for background operations
|
|
client = NextcloudClient.from_token(
|
|
base_url=self.nextcloud_url,
|
|
token=response.access_token,
|
|
username=user_id
|
|
)
|
|
|
|
await self.sync_notes(user_id, client)
|
|
```
|
|
|
|
**Key differences:**
|
|
- Uses refresh tokens from Flow 2 (Progressive Consent provisioning)
|
|
- Tokens can be cached for efficiency (longer-lived operations)
|
|
- No user interaction possible (offline)
|
|
- Different scopes than tool calls (e.g., "notes:sync" vs "notes:read")
|
|
|
|
### When to Enable Token Exchange
|
|
|
|
**Enable token exchange when:**
|
|
- You need audit trails showing token delegation
|
|
- You want minimal token lifetimes for security
|
|
- Your IdP supports RFC 8693
|
|
- You operate in a high-security environment
|
|
|
|
**Use pass-through mode when:**
|
|
- Simplicity is more important than token delegation
|
|
- Your IdP doesn't support RFC 8693
|
|
- You trust Flow 1 tokens to access Nextcloud directly
|
|
- Lower latency is a priority
|
|
|
|
**Both modes maintain the same security boundary**: Refresh tokens from Flow 2 are never used for tool calls, only for background jobs.
|
|
|
|
## Decision Outcome
|
|
|
|
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 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: Two Completely Separate OAuth Flows
|
|
|
|
The **Progressive Consent architecture** solves the critical challenges of token brokering while maintaining standards compliance:
|
|
|
|
#### 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
|
|
|
|
#### 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**: 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
|
|
- **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
|
|
|
|
- **Access Token Refresh**: Happens frequently (every 5-60 minutes) without rotating the master refresh token
|
|
- **Master Refresh Token**: Only rotated periodically (e.g., weekly) or during explicit session refresh
|
|
- **Audience Separation**: Each token request specifies the target audience (mcp-server or nextcloud)
|
|
|
|
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
|
|
|
|
The ADR-004 Hybrid Flow is currently tested via automated integration tests (using the simplified implementation):
|
|
|
|
### Integration Tests
|
|
|
|
```bash
|
|
# Run all ADR-004 tests
|
|
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py --browser firefox -v
|
|
|
|
# Run specific test
|
|
uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py::test_adr004_hybrid_flow_tool_execution --browser firefox -v
|
|
```
|
|
|
|
**Test Coverage:**
|
|
- `test_adr004_hybrid_flow_connection`: Verifies MCP session establishment with hybrid flow token
|
|
- `test_adr004_hybrid_flow_tool_execution`: Tests complete flow including tool execution
|
|
- `test_adr004_hybrid_flow_multiple_operations`: Validates persistent access without re-authentication
|
|
|
|
**What the tests verify:**
|
|
1. ✅ PKCE code challenge/verifier flow
|
|
2. ✅ MCP server intercepts OAuth callback and stores master refresh token
|
|
3. ✅ Client receives MCP access token (not master token)
|
|
4. ✅ MCP session establishment with hybrid flow token
|
|
5. ✅ Tool execution using stored refresh tokens (on-behalf-of pattern)
|
|
6. ✅ Multiple operations without re-authentication
|
|
|
|
### Test Implementation
|
|
|
|
The tests use Playwright automation to complete the OAuth flow:
|
|
1. Generate PKCE challenge/verifier
|
|
2. Navigate to MCP server `/oauth/authorize` endpoint
|
|
3. MCP server redirects to IdP
|
|
4. Playwright fills login form and consents
|
|
5. IdP redirects to MCP server `/oauth/callback`
|
|
6. MCP server stores master refresh token
|
|
7. MCP server redirects client with MCP authorization code
|
|
8. Client exchanges MCP code for access token using PKCE verifier
|
|
9. Create MCP session and execute tools
|
|
|
|
See `tests/server/oauth/test_adr004_hybrid_flow.py` for complete implementation.
|
|
|
|
## References
|
|
|
|
- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
|
|
- [RFC 8693: OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
|
|
- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
|
|
- [OAuth 2.0 Security Best Practices](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics)
|
|
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
|
|
- [OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252)
|
|
- [OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628) |