docs: Rewrite ADR-004 for Federated Authentication Architecture
Major rewrite of ADR-004 to reflect federated authentication pattern with shared identity provider (IdP) instead of direct Nextcloud authentication. Key changes: - Replaced "Sign-in with Nextcloud" with "Federated Authentication" - Added shared IdP (Keycloak, Okta, Azure AD) as central auth provider - MCP server now acts as OAuth client to shared IdP, not Nextcloud - Single user authentication grants both identity and Nextcloud access - Updated all diagrams to show 4-party architecture - Removed authorize_nextcloud tool - uses standard 401 flow - Added proper token rotation with reuse detection - Clarified Pattern 3 vs Pattern 4 differences in comparison doc - Pattern 3 can use external IdPs via user_oidc (not limited to NC) Architecture benefits: - True single sign-on with enterprise IdP support - OAuth-compliant on-behalf-of pattern - Supports SAML/LDAP backends through IdP - Nextcloud validates IdP tokens, not MCP-specific tokens 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# ADR-004: MCP Server as OAuth Client for Offline Access
|
||||
# ADR-004: Federated Authentication Architecture for Offline Access
|
||||
|
||||
**Status**: Draft
|
||||
**Date**: 2025-11-02
|
||||
@@ -6,54 +6,63 @@
|
||||
|
||||
## 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. The MCP protocol assumes that:
|
||||
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.
|
||||
|
||||
1. The MCP **client** (e.g., Claude Desktop, IDE) manages OAuth flows
|
||||
2. The MCP **server** receives pre-authenticated tokens with each request
|
||||
3. The server never sees or stores refresh tokens
|
||||
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
|
||||
|
||||
This architecture makes offline/background operations impossible because the server cannot obtain tokens outside of active MCP sessions. ADR-002's proposed solutions (service accounts, token exchange) were either OAuth-violating or circular in dependency.
|
||||
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. Background workers to access user data when users are offline
|
||||
2. The MCP server to maintain persistent access to Nextcloud
|
||||
3. Proper OAuth compliance with user consent
|
||||
4. Clean separation of authentication concerns
|
||||
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 and refresh tokens independently of MCP client sessions?**
|
||||
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 **"Sign-in with Nextcloud" architecture** where:
|
||||
We will implement a **Federated Authentication Architecture using a Shared Identity Provider** where:
|
||||
|
||||
1. **Nextcloud as Identity Provider**: Users authenticate using Nextcloud's OAuth/OIDC
|
||||
2. **MCP Server as OAuth Client**: The MCP server acts as a registered OAuth client to Nextcloud
|
||||
3. **Single Authentication Flow**: One OAuth flow bootstraps both user identity and API access
|
||||
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 becomes a full OAuth client application that:
|
||||
- Registers with Nextcloud's OAuth provider
|
||||
- Uses Nextcloud OIDC as the primary authentication mechanism
|
||||
- Stores refresh tokens securely with rotation
|
||||
- Uses stored tokens for both MCP sessions and background operations
|
||||
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
|
||||
|
||||
### OAuth Flow
|
||||
### Federated OAuth Architecture
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌────────────┐
|
||||
│ MCP Client ├───────────────────> │ MCP Server ├────────────────────>│ Nextcloud │
|
||||
│ (Claude) │ (MCP Protocol) │ (OAuth Client) │ (OIDC + APIs) │ APIs │
|
||||
└─────────────┘ └─────────────────┘ └────────────┘
|
||||
│
|
||||
┌──────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (Rotated Tokens)
|
||||
└───────────────┘
|
||||
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ MCP Client │◄──────401──────│ MCP Server │◄────OAuth──────│ Shared IdP │──Validates──►│ Nextcloud │
|
||||
│ (Claude) │ │ (OAuth Client) │ (On-Behalf) │ (Keycloak) │ Tokens │(Resource) │
|
||||
└─────────────┘ └─────────────────┘ └──────────────┘ └────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (IdP Tokens) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
**Key Components:**
|
||||
- **MCP Client**: Initiates connection, receives 401, opens OAuth flow
|
||||
- **MCP Server**: OAuth client to IdP, stores tokens, generates session tokens
|
||||
- **Shared IdP**: Central authentication, issues tokens with Nextcloud scopes
|
||||
- **Nextcloud**: Resource server, validates IdP tokens for API access
|
||||
|
||||
### Authentication Flows
|
||||
|
||||
#### Initial Setup (One-Time)
|
||||
@@ -64,33 +73,50 @@ sequenceDiagram
|
||||
participant Browser
|
||||
participant MCPClient as MCP Client
|
||||
participant MCPServer as MCP Server
|
||||
participant IdP as Shared IdP (Keycloak)
|
||||
participant Nextcloud
|
||||
|
||||
User->>MCPClient: Try to use MCP tool (e.g., list_notes)
|
||||
MCPClient->>MCPServer: MCP Request
|
||||
MCPServer->>MCPServer: Check token storage
|
||||
MCPServer-->>MCPClient: Auth Required (special response)
|
||||
User->>MCPClient: Connect to MCP
|
||||
MCPClient->>MCPServer: Initial request
|
||||
MCPServer-->>MCPClient: 401 Unauthorized
|
||||
|
||||
MCPClient->>MCPServer: Call authorize_nextcloud tool
|
||||
MCPServer-->>MCPClient: Return auth_url
|
||||
MCPClient-->>User: Display auth URL
|
||||
Note over MCPClient: WWW-Authenticate header<br/>points to IdP OAuth
|
||||
|
||||
User->>Browser: Click link to authenticate
|
||||
Browser->>Nextcloud: OAuth Authorization Request
|
||||
Nextcloud->>User: Login & Consent
|
||||
User->>Nextcloud: Approve
|
||||
Nextcloud->>Browser: Redirect to callback with code
|
||||
Browser->>MCPServer: /oauth/callback with code
|
||||
MCPClient->>Browser: Open IdP OAuth URL
|
||||
Browser->>MCPServer: GET /oauth/authorize
|
||||
MCPServer->>Browser: Redirect to IdP
|
||||
|
||||
MCPServer->>Nextcloud: Exchange code for tokens
|
||||
Nextcloud->>MCPServer: Access + Refresh Tokens
|
||||
MCPServer->>MCPServer: Create user account
|
||||
MCPServer->>MCPServer: Store encrypted tokens
|
||||
MCPServer-->>Browser: Success page
|
||||
Browser->>IdP: Authorization Request
|
||||
Note over IdP: Scopes include:<br/>- openid profile email<br/>- offline_access<br/>- nextcloud:notes:*
|
||||
|
||||
User->>MCPClient: Retry MCP tool
|
||||
MCPClient->>MCPServer: MCP Request (now authenticated)
|
||||
MCPServer-->>MCPClient: Tool response
|
||||
IdP->>User: Login page
|
||||
User->>IdP: Authenticate once
|
||||
|
||||
IdP->>User: Consent screen
|
||||
Note over IdP: "Allow MCP Server to:<br/>- Verify your identity<br/>- Access data offline<br/>- Read/write Nextcloud"
|
||||
|
||||
User->>IdP: Grant consent
|
||||
IdP->>Browser: Redirect with code
|
||||
Browser->>MCPServer: /oauth/callback?code=...
|
||||
|
||||
MCPServer->>IdP: Exchange code for tokens
|
||||
IdP->>MCPServer: id_token, access_token, refresh_token
|
||||
|
||||
Note over MCPServer: Two token sets created:<br/>1. Store IdP refresh token<br/>2. Issue MCP session token
|
||||
|
||||
MCPServer->>MCPServer: Store IdP tokens (encrypted)
|
||||
MCPServer->>MCPServer: Generate MCP session token
|
||||
MCPServer-->>Browser: Success + session info
|
||||
|
||||
Browser-->>MCPClient: Authentication complete
|
||||
MCPClient->>MCPServer: Retry with session token
|
||||
|
||||
MCPServer->>IdP: Use stored access token
|
||||
MCPServer->>Nextcloud: API call with IdP token
|
||||
Nextcloud->>IdP: Validate token (introspection)
|
||||
IdP-->>Nextcloud: Token valid + scopes
|
||||
Nextcloud-->>MCPServer: API response
|
||||
MCPServer-->>MCPClient: Success
|
||||
```
|
||||
|
||||
#### Subsequent MCP Sessions
|
||||
@@ -100,21 +126,25 @@ sequenceDiagram
|
||||
participant MCPClient as MCP Client
|
||||
participant MCPServer as MCP Server
|
||||
participant TokenStore as Token Storage
|
||||
participant IdP as Shared IdP
|
||||
participant Nextcloud
|
||||
|
||||
MCPClient->>MCPServer: MCP Request
|
||||
MCPServer->>TokenStore: Get user's active token
|
||||
TokenStore-->>MCPServer: Encrypted token (status='active')
|
||||
MCPClient->>MCPServer: Request with MCP session token
|
||||
MCPServer->>MCPServer: Validate MCP session
|
||||
MCPServer->>TokenStore: Get user's IdP tokens
|
||||
TokenStore-->>MCPServer: Encrypted tokens (status='active')
|
||||
MCPServer->>MCPServer: Check expiry
|
||||
|
||||
alt Token Expired
|
||||
MCPServer->>TokenStore: Mark token as 'used'
|
||||
MCPServer->>Nextcloud: Refresh with rotation
|
||||
Nextcloud->>MCPServer: New access + refresh tokens
|
||||
MCPServer->>IdP: Refresh token request
|
||||
IdP->>MCPServer: New access + refresh tokens
|
||||
MCPServer->>TokenStore: Store new tokens (status='active')
|
||||
end
|
||||
|
||||
MCPServer->>Nextcloud: API call with access token
|
||||
MCPServer->>Nextcloud: API call with IdP access token
|
||||
Nextcloud->>IdP: Validate token
|
||||
IdP-->>Nextcloud: Valid + scopes
|
||||
Nextcloud-->>MCPServer: API response
|
||||
MCPServer-->>MCPClient: MCP response
|
||||
```
|
||||
@@ -125,83 +155,76 @@ sequenceDiagram
|
||||
sequenceDiagram
|
||||
participant Worker as Background Worker
|
||||
participant TokenStore as Token Storage
|
||||
participant IdP as Shared IdP
|
||||
participant Nextcloud
|
||||
|
||||
Worker->>TokenStore: Get user's active refresh token
|
||||
TokenStore-->>Worker: Encrypted refresh token
|
||||
TokenStore-->>Worker: Encrypted IdP refresh token
|
||||
Worker->>TokenStore: Mark token as 'used'
|
||||
Worker->>Worker: Decrypt token
|
||||
Worker->>Nextcloud: Exchange for new tokens
|
||||
Nextcloud->>Worker: New access + refresh tokens
|
||||
|
||||
Worker->>IdP: Exchange refresh token
|
||||
IdP->>Worker: New access + refresh tokens
|
||||
Worker->>TokenStore: Store new tokens (status='active')
|
||||
Worker->>Nextcloud: API operations with access token
|
||||
|
||||
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!
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. Sign-in with Nextcloud Token Verifier
|
||||
### 1. Federated Token Verifier
|
||||
|
||||
```python
|
||||
class NextcloudIdentityTokenVerifier(TokenVerifier):
|
||||
"""Uses Nextcloud as the sole identity provider."""
|
||||
class FederatedTokenVerifier(TokenVerifier):
|
||||
"""Verifies MCP session tokens and manages IdP tokens."""
|
||||
|
||||
def __init__(self, token_storage: RefreshTokenStorage):
|
||||
def __init__(self, token_storage: RefreshTokenStorage, idp_client: OAuthClient):
|
||||
self.storage = token_storage
|
||||
self.idp_client = idp_client
|
||||
|
||||
async def verify_token(self, token: str) -> AccessToken | None:
|
||||
# Token represents a Nextcloud session ID after OAuth
|
||||
session = await self.storage.get_session(token)
|
||||
# Verify MCP session token
|
||||
session = await self.verify_mcp_session(token)
|
||||
if not session:
|
||||
# User needs to complete Sign-in with Nextcloud
|
||||
return AccessToken(
|
||||
token=token,
|
||||
scopes=["nextcloud:auth:required"],
|
||||
resource=json.dumps({
|
||||
"needs_auth": True,
|
||||
"auth_type": "sign_in_with_nextcloud"
|
||||
})
|
||||
)
|
||||
return None # Will trigger 401 response
|
||||
|
||||
# Get active token for this user
|
||||
nc_tokens = await self.storage.get_active_tokens(session.user_id)
|
||||
# Get stored IdP tokens for this user
|
||||
idp_tokens = await self.storage.get_active_tokens(session.user_id)
|
||||
|
||||
if not nc_tokens:
|
||||
# Session exists but tokens revoked/expired
|
||||
return AccessToken(
|
||||
token=token,
|
||||
scopes=["nextcloud:auth:required"],
|
||||
resource=json.dumps({
|
||||
"user_id": session.user_id,
|
||||
"needs_reauth": True
|
||||
})
|
||||
)
|
||||
if not idp_tokens:
|
||||
# User needs to complete OAuth flow with IdP
|
||||
return None # Triggers 401 with WWW-Authenticate header
|
||||
|
||||
# Refresh if expired (with rotation)
|
||||
if nc_tokens.is_expired():
|
||||
nc_tokens = await self.rotate_refresh_token(
|
||||
if idp_tokens.is_expired():
|
||||
idp_tokens = await self.rotate_refresh_token(
|
||||
session.user_id,
|
||||
nc_tokens
|
||||
idp_tokens
|
||||
)
|
||||
|
||||
# Return Nextcloud access token for API use
|
||||
# Return IdP access token for Nextcloud API use
|
||||
return AccessToken(
|
||||
token=nc_tokens.access_token,
|
||||
scopes=nc_tokens.scopes,
|
||||
token=idp_tokens.access_token,
|
||||
scopes=idp_tokens.scopes,
|
||||
resource=json.dumps({
|
||||
"user_id": session.user_id,
|
||||
"nc_user": nc_tokens.username
|
||||
"idp_sub": idp_tokens.subject
|
||||
})
|
||||
)
|
||||
|
||||
async def rotate_refresh_token(self, user_id: str, old_tokens: TokenSet):
|
||||
"""Implement proper token rotation with reuse detection."""
|
||||
"""Rotate IdP refresh tokens with reuse detection."""
|
||||
# Mark old token as 'used'
|
||||
await self.storage.mark_token_used(old_tokens.token_id)
|
||||
|
||||
try:
|
||||
# Exchange for new tokens
|
||||
new_tokens = await self.oauth_client.refresh(old_tokens.refresh_token)
|
||||
# Exchange with IdP for new tokens
|
||||
new_tokens = await self.idp_client.refresh(old_tokens.refresh_token)
|
||||
|
||||
# Store new tokens in same family
|
||||
await self.storage.store_tokens(
|
||||
@@ -221,55 +244,57 @@ class NextcloudIdentityTokenVerifier(TokenVerifier):
|
||||
raise
|
||||
```
|
||||
|
||||
### 2. OAuth Flow Initiation
|
||||
### 2. OAuth Endpoints (MCP Server as OAuth Client)
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def authorize_nextcloud(ctx: Context) -> dict:
|
||||
"""Initiate Sign-in with Nextcloud OAuth flow."""
|
||||
access_token = ctx.request_context.request.user.access_token
|
||||
auth_state = json.loads(access_token.resource)
|
||||
@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
|
||||
):
|
||||
"""MCP Server OAuth endpoint - redirects to Shared IdP."""
|
||||
# Store MCP client details for callback
|
||||
session_id = str(uuid4())
|
||||
await store_oauth_session(
|
||||
session_id=session_id,
|
||||
client_id=client_id,
|
||||
redirect_uri=redirect_uri,
|
||||
state=state
|
||||
)
|
||||
|
||||
if not auth_state.get("needs_auth"):
|
||||
return {"status": "already_authorized"}
|
||||
|
||||
# Generate OAuth URL with PKCE
|
||||
state = generate_secure_state()
|
||||
code_verifier = generate_pkce_verifier()
|
||||
code_challenge = generate_pkce_challenge(code_verifier)
|
||||
|
||||
# Store PKCE verifier for callback
|
||||
await store_oauth_state(state, code_verifier)
|
||||
|
||||
auth_url = (
|
||||
f"{NEXTCLOUD_URL}/apps/oidc/authorize?"
|
||||
# Build IdP authorization URL with all needed scopes
|
||||
idp_state = f"{session_id}:{generate_secure_state()}"
|
||||
idp_auth_url = (
|
||||
f"{IDP_AUTHORIZATION_ENDPOINT}?"
|
||||
f"client_id={MCP_SERVER_CLIENT_ID}&"
|
||||
f"redirect_uri={MCP_SERVER_URL}/oauth/callback&"
|
||||
f"response_type=code&"
|
||||
f"scope=openid profile email offline_access notes:read notes:write&"
|
||||
f"state={state}&"
|
||||
f"code_challenge={code_challenge}&"
|
||||
f"code_challenge_method=S256"
|
||||
f"scope=openid profile email offline_access " # Identity + offline
|
||||
f"nextcloud:notes:read nextcloud:notes:write " # Nextcloud scopes
|
||||
f"nextcloud:calendar:read nextcloud:calendar:write&"
|
||||
f"state={idp_state}&"
|
||||
f"prompt=consent" # Ensure refresh token is issued
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "authorization_required",
|
||||
"auth_url": auth_url,
|
||||
"message": "Please visit the URL to sign in with Nextcloud"
|
||||
}
|
||||
return RedirectResponse(idp_auth_url)
|
||||
|
||||
@app.get("/oauth/callback")
|
||||
async def oauth_callback(code: str, state: str):
|
||||
"""Handle OAuth callback and create user account."""
|
||||
# Verify state and retrieve PKCE verifier
|
||||
code_verifier = await get_oauth_state(state)
|
||||
if not code_verifier:
|
||||
return {"error": "Invalid state"}
|
||||
"""Handle callback from Shared IdP."""
|
||||
# Extract session ID from state
|
||||
session_id, _ = state.split(":", 1)
|
||||
oauth_session = await get_oauth_session(session_id)
|
||||
|
||||
# Exchange code for tokens
|
||||
tokens = await oauth_client.exchange_code(
|
||||
if not oauth_session:
|
||||
return {"error": "Invalid session"}
|
||||
|
||||
# Exchange code with IdP for tokens
|
||||
tokens = await idp_client.exchange_code(
|
||||
code=code,
|
||||
code_verifier=code_verifier
|
||||
redirect_uri=f"{MCP_SERVER_URL}/oauth/callback"
|
||||
)
|
||||
|
||||
# Decode ID token to get user info
|
||||
@@ -277,54 +302,114 @@ async def oauth_callback(code: str, state: str):
|
||||
|
||||
# Create or update user account
|
||||
user = await create_or_update_user(
|
||||
nc_username=userinfo.preferred_username,
|
||||
nc_sub=userinfo.sub,
|
||||
idp_sub=userinfo.sub,
|
||||
username=userinfo.preferred_username,
|
||||
email=userinfo.email
|
||||
)
|
||||
|
||||
# Generate new token family for this authentication
|
||||
# Generate new token family for rotation
|
||||
token_family_id = str(uuid4())
|
||||
|
||||
# Store tokens with rotation support
|
||||
# Store IdP tokens (these have Nextcloud scopes)
|
||||
await token_storage.store_tokens(
|
||||
user_id=user.id,
|
||||
token_family_id=token_family_id,
|
||||
access_token=tokens.access_token,
|
||||
refresh_token=tokens.refresh_token,
|
||||
status='active',
|
||||
nc_username=userinfo.preferred_username
|
||||
scopes=tokens.scope,
|
||||
idp_subject=userinfo.sub
|
||||
)
|
||||
|
||||
# Create session for MCP
|
||||
session_token = generate_session_token()
|
||||
await token_storage.create_session(session_token, user.id)
|
||||
# Generate MCP session token for the client
|
||||
mcp_session_token = generate_mcp_session_token(user.id)
|
||||
|
||||
return HTMLResponse("""
|
||||
# Store MCP session
|
||||
await store_mcp_session(mcp_session_token, user.id)
|
||||
|
||||
# Return success page with session info
|
||||
# (Implementation depends on client type - could be redirect, postMessage, etc.)
|
||||
return HTMLResponse(f"""
|
||||
<html>
|
||||
<body>
|
||||
<h1>Authorization Successful!</h1>
|
||||
<p>Session token: {mcp_session_token}</p>
|
||||
<p>You can now close this window and return to your MCP client.</p>
|
||||
<script>window.close();</script>
|
||||
<script>
|
||||
// Post message to opener if available
|
||||
if (window.opener) {{
|
||||
window.opener.postMessage({{
|
||||
type: 'auth_complete',
|
||||
session_token: '{mcp_session_token}'
|
||||
}}, '*');
|
||||
}}
|
||||
window.close();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
@app.post("/oauth/token")
|
||||
async def oauth_token(
|
||||
grant_type: str = Form(...),
|
||||
code: str = Form(None),
|
||||
refresh_token: str = Form(None)
|
||||
):
|
||||
"""Token endpoint for MCP clients."""
|
||||
if grant_type == "authorization_code":
|
||||
# Exchange authorization code for MCP tokens
|
||||
# (This would be used if implementing full OAuth server)
|
||||
pass
|
||||
elif grant_type == "refresh_token":
|
||||
# Refresh MCP session token
|
||||
# (Separate from IdP token refresh)
|
||||
pass
|
||||
|
||||
# For now, session tokens are issued directly in callback
|
||||
return {"error": "Not implemented"}
|
||||
```
|
||||
|
||||
### 3. Token Storage Schema with Rotation
|
||||
### 3. 401 Response with WWW-Authenticate
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def list_notes(ctx: Context) -> dict:
|
||||
"""List notes - automatically triggers OAuth if needed."""
|
||||
try:
|
||||
# FastMCP automatically calls token verifier
|
||||
# If it returns None, a 401 is sent
|
||||
client = get_client_from_context(ctx)
|
||||
notes = await client.notes.list_notes()
|
||||
return {"notes": notes}
|
||||
except Unauthorized:
|
||||
# Return 401 with WWW-Authenticate header
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
headers={
|
||||
"WWW-Authenticate": (
|
||||
f'Bearer realm="{MCP_SERVER_URL}/oauth/authorize", '
|
||||
f'error="invalid_token", '
|
||||
f'error_description="Authentication required"'
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### 4. Token Storage Schema
|
||||
|
||||
```sql
|
||||
-- User accounts (created from Nextcloud OIDC)
|
||||
-- User accounts (created from IdP identity)
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
nc_sub TEXT UNIQUE NOT NULL, -- Nextcloud OIDC subject
|
||||
nc_username TEXT NOT NULL,
|
||||
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
|
||||
);
|
||||
|
||||
-- Token storage with rotation support
|
||||
CREATE TABLE user_nextcloud_tokens (
|
||||
-- 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
|
||||
@@ -332,24 +417,36 @@ CREATE TABLE user_nextcloud_tokens (
|
||||
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,
|
||||
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
|
||||
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 user_nextcloud_tokens(user_id, status)
|
||||
CREATE INDEX idx_active_tokens ON idp_tokens(user_id, status)
|
||||
WHERE status = 'active';
|
||||
CREATE INDEX idx_token_families ON user_nextcloud_tokens(token_family_id);
|
||||
CREATE INDEX idx_token_families ON idp_tokens(token_family_id);
|
||||
|
||||
-- MCP session mapping
|
||||
-- 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 (temporary during auth)
|
||||
CREATE TABLE oauth_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
client_id TEXT,
|
||||
redirect_uri TEXT,
|
||||
state TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
@@ -366,31 +463,31 @@ CREATE TABLE token_audit_log (
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Background Worker with Token Rotation
|
||||
### 5. Background Worker with IdP Token Refresh
|
||||
|
||||
```python
|
||||
class BackgroundSyncWorker:
|
||||
"""Sync user data with proper token rotation."""
|
||||
"""Sync user data using IdP 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")
|
||||
|
||||
async def sync_user_data(self, user_id: str):
|
||||
"""Sync data using rotated tokens."""
|
||||
# Get active refresh token
|
||||
"""Sync data using IdP tokens with Nextcloud scopes."""
|
||||
# Get active refresh token from IdP
|
||||
tokens = await self.storage.get_active_tokens(user_id)
|
||||
if not tokens:
|
||||
logger.warning(f"No active tokens for user {user_id}")
|
||||
logger.warning(f"No active IdP tokens for user {user_id}")
|
||||
return
|
||||
|
||||
# Mark token as used immediately
|
||||
# Mark token as used immediately (rotation)
|
||||
await self.storage.mark_token_used(tokens.id)
|
||||
|
||||
try:
|
||||
# Exchange for new tokens (rotation)
|
||||
oauth_client = NextcloudOAuthClient.from_discovery(self.nextcloud_url)
|
||||
new_tokens = await oauth_client.refresh(tokens.refresh_token)
|
||||
# Exchange with IdP for new tokens
|
||||
new_tokens = await self.idp_client.refresh(tokens.refresh_token)
|
||||
|
||||
# Store new tokens in same family
|
||||
await self.storage.store_tokens(
|
||||
@@ -401,20 +498,22 @@ class BackgroundSyncWorker:
|
||||
status='active'
|
||||
)
|
||||
|
||||
# Create Nextcloud client with new access token
|
||||
# Create Nextcloud client with IdP access token
|
||||
# Nextcloud will validate this token with the IdP
|
||||
client = NextcloudClient.from_token(
|
||||
base_url=self.nextcloud_url,
|
||||
token=new_tokens.access_token,
|
||||
username=tokens.nc_username
|
||||
username=tokens.username
|
||||
)
|
||||
|
||||
# Perform sync operations
|
||||
await self.sync_notes(user_id, client)
|
||||
await self.sync_calendar(user_id, client)
|
||||
await self.sync_contacts(user_id, client)
|
||||
|
||||
except HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
# Token revoked or reuse detected
|
||||
# Token rejected by IdP or Nextcloud
|
||||
await self.storage.revoke_token_family(tokens.token_family_id)
|
||||
await self.log_security_event(user_id, "token_revoked", tokens.token_family_id)
|
||||
raise
|
||||
@@ -422,94 +521,61 @@ class BackgroundSyncWorker:
|
||||
# Revert token status on failure
|
||||
await self.storage.revert_token_status(tokens.id)
|
||||
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
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Reuse Detection
|
||||
### 6. Configuration
|
||||
|
||||
```python
|
||||
class RefreshTokenStorage:
|
||||
"""Storage with reuse detection."""
|
||||
# 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")
|
||||
|
||||
async def get_active_tokens(self, user_id: str) -> TokenSet | None:
|
||||
"""Get active tokens, detecting reuse attempts."""
|
||||
async with self.db.execute(
|
||||
"""
|
||||
SELECT id, token_family_id, encrypted_access_token,
|
||||
encrypted_refresh_token, status, access_expires_at
|
||||
FROM user_nextcloud_tokens
|
||||
WHERE user_id = ? AND status = 'active'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(user_id,)
|
||||
) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
# Nextcloud configuration
|
||||
NEXTCLOUD_HOST = os.getenv("NEXTCLOUD_HOST") # Nextcloud instance URL
|
||||
|
||||
return self._decrypt_tokens(row)
|
||||
# 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()
|
||||
|
||||
async def mark_token_used(self, token_id: int):
|
||||
"""Mark token as used - critical for reuse detection."""
|
||||
result = await self.db.execute(
|
||||
"""
|
||||
UPDATE user_nextcloud_tokens
|
||||
SET status = 'used', used_at = ?
|
||||
WHERE id = ? AND status = 'active'
|
||||
""",
|
||||
(int(time.time()), token_id)
|
||||
)
|
||||
|
||||
if result.rowcount == 0:
|
||||
# Token was already used - possible attack!
|
||||
await self.handle_token_reuse(token_id)
|
||||
|
||||
async def handle_token_reuse(self, token_id: int):
|
||||
"""Detect and handle refresh token reuse."""
|
||||
# Get token family
|
||||
cursor = await self.db.execute(
|
||||
"SELECT token_family_id, user_id FROM user_nextcloud_tokens WHERE id = ?",
|
||||
(token_id,)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row:
|
||||
# Revoke entire token family
|
||||
await self.revoke_token_family(row['token_family_id'])
|
||||
|
||||
# Log security event
|
||||
await self.log_security_event(
|
||||
row['user_id'],
|
||||
'reuse_detected',
|
||||
f"Token {token_id} reused, family {row['token_family_id']} revoked"
|
||||
)
|
||||
|
||||
async def revoke_token_family(self, token_family_id: str):
|
||||
"""Revoke all tokens in a family."""
|
||||
await self.db.execute(
|
||||
"""
|
||||
UPDATE user_nextcloud_tokens
|
||||
SET status = 'revoked'
|
||||
WHERE token_family_id = ? AND status IN ('active', 'used')
|
||||
""",
|
||||
(token_family_id,)
|
||||
)
|
||||
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. **True Offline Access**: Background workers can operate without active MCP sessions
|
||||
2. **OAuth Compliant**: Proper user consent and token lifecycle with rotation
|
||||
3. **Single Sign-On**: Users authenticate once with their Nextcloud credentials
|
||||
4. **Security**: Full token rotation with reuse detection
|
||||
5. **Simplicity**: No separate app authentication layer to maintain
|
||||
6. **User Control**: Users can revoke access at any time through Nextcloud
|
||||
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. **Nextcloud Dependency**: The MCP server requires Nextcloud OIDC for all authentication
|
||||
2. **Token Management**: Complex token rotation logic
|
||||
3. **Migration**: Existing deployments need architectural changes
|
||||
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
|
||||
|
||||
@@ -524,32 +590,43 @@ class RefreshTokenStorage:
|
||||
- **Atomic operations**: Token status updates must be atomic to prevent race conditions
|
||||
- **Audit logging**: All token operations are logged for security analysis
|
||||
|
||||
### Revocation
|
||||
- Implement webhook listener for Nextcloud revocation events
|
||||
- Immediate family revocation on reuse detection
|
||||
- Clear session mappings on logout
|
||||
### Trust Relationships
|
||||
- **IdP Trust**: Both MCP server and Nextcloud must trust the IdP
|
||||
- **Audience Validation**: Tokens must include proper audience claims
|
||||
- **Scope Verification**: Each service validates only its required scopes
|
||||
- **Certificate Pinning**: Consider pinning IdP certificates in production
|
||||
|
||||
### Scope Management
|
||||
- Request minimal scopes needed for operations
|
||||
- Allow users to customize scope grants
|
||||
- Implement per-tool scope checking
|
||||
### Revocation
|
||||
- Implement webhook listener for IdP revocation events
|
||||
- Immediate family revocation on reuse detection
|
||||
- Clear MCP sessions on logout
|
||||
- Propagate revocation to Nextcloud if needed
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Parallel Operation
|
||||
### 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 Sign-in with Nextcloud as optional feature
|
||||
2. Add federated auth as optional feature flag
|
||||
3. Test with subset of users
|
||||
4. Monitor token lifecycle and refresh patterns
|
||||
|
||||
### Phase 2: Gradual Migration
|
||||
1. New users default to Sign-in with Nextcloud
|
||||
2. Prompt existing users to migrate
|
||||
3. Maintain backward compatibility
|
||||
### 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 3: Deprecation
|
||||
### Phase 4: Deprecation
|
||||
1. Announce end-of-life for pass-through mode
|
||||
2. Provide migration tools
|
||||
3. Remove legacy code
|
||||
2. Complete user migration
|
||||
3. Remove legacy authentication code
|
||||
4. Document new auth flow
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
@@ -568,23 +645,33 @@ class RefreshTokenStorage:
|
||||
- **Cons**: Circular dependency, doesn't solve bootstrap problem
|
||||
- **Rejected**: Doesn't enable true offline access
|
||||
|
||||
### 4. Double OAuth (Initial ADR-004 Draft)
|
||||
- **Pros**: Separation of concerns
|
||||
- **Cons**: Users must authenticate twice, complex to maintain two auth systems
|
||||
- **Rejected**: Poor user experience, unnecessary complexity
|
||||
### 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
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
The Federated Authentication Architecture provides a clean, enterprise-ready solution for offline access while maintaining OAuth compliance. By using a shared identity provider, we achieve:
|
||||
|
||||
1. **Single user authentication** to a trusted IdP
|
||||
2. **Delegated access** to Nextcloud resources via scoped tokens
|
||||
3. **Offline capabilities** through secure refresh token storage and rotation
|
||||
4. **Enterprise integration** with existing identity infrastructure
|
||||
|
||||
This architecture follows industry best practices for federated systems and positions the MCP server as a standard OAuth client in an enterprise identity ecosystem.
|
||||
|
||||
## References
|
||||
|
||||
- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
|
||||
- [RFC 6749 Section 1.5: Refresh Tokens](https://datatracker.ietf.org/doc/html/rfc6749#section-1.5)
|
||||
- [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)
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
This architecture provides a clean, OAuth-compliant solution for offline access while maintaining security boundaries. The MCP server uses "Sign-in with Nextcloud" as its primary authentication mechanism, creating a seamless user experience while enabling full offline capabilities.
|
||||
|
||||
The implementation of proper token rotation with reuse detection ensures security against token theft, while the simplified authentication flow improves user experience compared to a double OAuth approach.
|
||||
|
||||
The additional complexity of token rotation is justified by the security benefits and follows industry best practices for OAuth implementations requiring offline access.
|
||||
- [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)
|
||||
@@ -102,101 +102,141 @@ A: MCP server never sees refresh tokens (by design)
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: MCP Server as OAuth Client (ADR-004 - Solution)
|
||||
## Pattern 3: Sign-in with Nextcloud (Previous ADR-004 Draft)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
Layer 1: MCP Authentication Layer 2: Nextcloud Authorization
|
||||
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
|
||||
│ MCP Client │ │ MCP Server │ │ Nextcloud │
|
||||
│ (Claude) │ │ (OAuth Client) │ │OAuth Provider
|
||||
└──────┬──────┘ └────────┬────────┘ └──────┬──────┘
|
||||
│ │ │
|
||||
│ 1. MCP Request │ 2. Check stored tokens │
|
||||
├─────────────────────────────────────►│ │
|
||||
│ │ │
|
||||
│ 3. "Need Nextcloud Auth" │ │
|
||||
│◄─────────────────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 4. User initiates OAuth │ 5. OAuth Authorization │
|
||||
├─────────────────────────────────────►├───────────────────────────────►│
|
||||
│ │ │
|
||||
│ │ 6. Access + Refresh Tokens │
|
||||
│ │◄───────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ 7. Store encrypted tokens │
|
||||
│ ├────────┐ │
|
||||
│ │ ▼ │
|
||||
│ │ ┌─────────────┐ │
|
||||
│ │ │Token Storage│ │
|
||||
│ │ └─────────────┘ │
|
||||
│ 8. "Auth Complete" │ │
|
||||
│◄─────────────────────────────────────┤ │
|
||||
│ │ │
|
||||
│ 9. Subsequent requests │ 10. Use stored tokens │
|
||||
├─────────────────────────────────────►├───────────────────────────────►│
|
||||
│ │ Nextcloud APIs
|
||||
│ │ │
|
||||
│ Background │ 11. Refresh when expired │
|
||||
│ Worker──►├───────────────────────────────►│
|
||||
│ (No client needed!) │
|
||||
┌─────────────┐ ┌─────────────────┐ ┌────────────┐
|
||||
│ MCP Client ├───────────────────> │ MCP Server ├────────────────────>│ Nextcloud │
|
||||
│ (Claude) │ (MCP Protocol) │ (OAuth Client) │ (OIDC + APIs) │ (IdP) │
|
||||
└─────────────┘ └─────────────────┘ └────────────┘
|
||||
│
|
||||
┌──────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (NC Tokens) │
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | MCP Server owns Nextcloud tokens |
|
||||
| **Token Storage** | ✅ Encrypted refresh tokens |
|
||||
| **Token Flow** | MCP Server uses Nextcloud as identity provider |
|
||||
| **Token Storage** | ✅ Encrypted Nextcloud refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored refresh tokens |
|
||||
| **User Consent** | Two OAuth flows (app + Nextcloud) |
|
||||
| **Complexity** | Medium-High |
|
||||
| **Security** | High (proper OAuth compliance) |
|
||||
| **User Consent** | Single OAuth flow (Nextcloud only) |
|
||||
| **Complexity** | Medium |
|
||||
| **Security** | High (with token rotation) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- User connects to MCP server (Layer 1 auth)
|
||||
- MCP server checks for stored Nextcloud tokens
|
||||
- If missing, triggers OAuth flow with Nextcloud
|
||||
- User authorizes MCP server to access Nextcloud
|
||||
- MCP server stores refresh token (encrypted)
|
||||
- User tries to use MCP tool
|
||||
- MCP server returns auth required
|
||||
- User authenticates with Nextcloud's OIDC endpoint
|
||||
- Nextcloud may use user_oidc to delegate to external IdP (Keycloak, etc.)
|
||||
- MCP server stores Nextcloud-issued refresh token (encrypted)
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server uses stored access token
|
||||
- MCP server uses stored Nextcloud tokens
|
||||
- Refreshes automatically when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored refresh token
|
||||
- Gets new access token from Nextcloud
|
||||
- Refreshes with Nextcloud directly
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ Single sign-on with Nextcloud
|
||||
- ✅ True offline access capability
|
||||
- ✅ OAuth-compliant with proper consent
|
||||
- ✅ Background workers can operate independently
|
||||
- ✅ Tokens persist across MCP sessions
|
||||
- ✅ Users can revoke access anytime
|
||||
- ✅ Supports external IdPs via user_oidc
|
||||
- ✅ Simpler integration - only one OAuth endpoint
|
||||
|
||||
### Trade-offs
|
||||
- Users must authorize twice (MCP + Nextcloud)
|
||||
- More complex token management
|
||||
- Requires secure token storage
|
||||
- Authentication flows through Nextcloud
|
||||
- Nextcloud manages IdP relationships (via user_oidc)
|
||||
- MCP server only knows about Nextcloud, not the underlying IdP
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Federated Authentication Architecture (ADR-004 - Solution)
|
||||
|
||||
### Architecture
|
||||
```
|
||||
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||
│ MCP Client │◄──────401──────│ MCP Server │◄────OAuth──────│ Shared IdP │──Validates──►│ Nextcloud │
|
||||
│ (Claude) │ │ (OAuth Client) │ (On-Behalf) │ (Keycloak) │ Tokens │(Resource) │
|
||||
└─────────────┘ └─────────────────┘ └──────────────┘ └────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Token Storage │
|
||||
│ (IdP Tokens) │
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
### Characteristics
|
||||
| Aspect | Description |
|
||||
|--------|-------------|
|
||||
| **Token Flow** | Shared IdP issues tokens for Nextcloud access |
|
||||
| **Token Storage** | ✅ Encrypted IdP refresh tokens |
|
||||
| **Offline Access** | ✅ Full support |
|
||||
| **Background Workers** | ✅ Use stored IdP refresh tokens |
|
||||
| **User Consent** | Single OAuth flow (IdP manages consent) |
|
||||
| **Complexity** | Medium-High |
|
||||
| **Security** | Highest (enterprise-grade IdP) |
|
||||
|
||||
### How It Works
|
||||
1. **Initial Setup**:
|
||||
- MCP client connects, receives 401
|
||||
- Browser opens MCP server OAuth URL
|
||||
- MCP server redirects to shared IdP
|
||||
- User authenticates once to IdP
|
||||
- IdP shows consent for both identity and Nextcloud access
|
||||
- MCP server stores IdP refresh token (encrypted)
|
||||
- MCP server issues session token to client
|
||||
|
||||
2. **Subsequent Requests**:
|
||||
- MCP server validates session token
|
||||
- Uses stored IdP token for Nextcloud
|
||||
- Refreshes with IdP when expired
|
||||
- No client involvement needed
|
||||
|
||||
3. **Background Operations**:
|
||||
- Worker retrieves stored IdP refresh token
|
||||
- Gets new access token from IdP
|
||||
- Uses token to access Nextcloud
|
||||
- Performs operations independently
|
||||
|
||||
### Advantages
|
||||
- ✅ True single sign-on (SSO)
|
||||
- ✅ Enterprise-ready with SAML/LDAP support
|
||||
- ✅ OAuth-compliant with proper delegation
|
||||
- ✅ Direct IdP relationship - no intermediary
|
||||
- ✅ Flexible - can swap resource servers
|
||||
- ✅ Industry-standard federated pattern
|
||||
|
||||
### Trade-offs
|
||||
- Requires shared IdP infrastructure
|
||||
- More complex initial setup
|
||||
- Token validation overhead
|
||||
|
||||
---
|
||||
|
||||
## Comparison Matrix
|
||||
|
||||
| Feature | Pass-Through | Token Exchange | MCP as OAuth Client |
|
||||
|---------|--------------|----------------|-------------------|
|
||||
| **Offline Access** | ❌ No | ❌ No | ✅ Yes |
|
||||
| **Background Workers** | ❌ No | ❌ No* | ✅ Yes |
|
||||
| **Token Storage** | None | None | Refresh tokens |
|
||||
| **OAuth Compliance** | ✅ Full | ⚠️ Violates | ✅ Full |
|
||||
| **User Consent** | Once | Implicit | Twice |
|
||||
| **Implementation Complexity** | Low | High | Medium |
|
||||
| **Security** | High | Medium | High |
|
||||
| **Suitable For** | Interactive only | N/A (flawed) | Full platform |
|
||||
| Feature | Pass-Through | Token Exchange | Sign-in with NC | Federated Auth |
|
||||
|---------|--------------|----------------|-----------------|----------------|
|
||||
| **Offline Access** | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||
| **Background Workers** | ❌ No | ❌ No* | ✅ Yes | ✅ Yes |
|
||||
| **Token Storage** | None | None | NC refresh tokens | IdP refresh tokens |
|
||||
| **OAuth Compliance** | ✅ Full | ⚠️ Violates | ✅ Full | ✅ Full |
|
||||
| **User Consent** | Once | Implicit | Once (NC) | Once (IdP) |
|
||||
| **Implementation Complexity** | Low | High | Medium | Medium-High |
|
||||
| **Security** | High | Medium | High | Highest |
|
||||
| **Enterprise Ready** | ❌ No | ❌ No | ⚠️ Indirect | ✅ Yes |
|
||||
| **Identity Provider** | Client-managed | N/A | Nextcloud (+user_oidc) | Shared IdP |
|
||||
| **Suitable For** | Interactive only | N/A (flawed) | Small teams | Enterprise |
|
||||
|
||||
\* *Requires service accounts that violate OAuth principles*
|
||||
|
||||
@@ -214,24 +254,34 @@ A: MCP server never sees refresh tokens (by design)
|
||||
- **Result**: Circular dependencies, OAuth violations
|
||||
- **Learning**: MCP protocol constraints are fundamental
|
||||
|
||||
### Stage 3: Application Pattern ✅
|
||||
### Stage 3: Sign-in with Nextcloud ⚠️
|
||||
- **Goal**: True offline access with OAuth compliance
|
||||
- **Result**: MCP server as independent OAuth client
|
||||
- **Trade-off**: Additional complexity justified by requirements
|
||||
- **Result**: MCP server uses Nextcloud as identity provider
|
||||
- **Limitation**: Tight coupling to Nextcloud, no enterprise IdP
|
||||
|
||||
### Stage 4: Federated Pattern ✅
|
||||
- **Goal**: Enterprise-ready offline access
|
||||
- **Result**: Shared IdP for both MCP server and Nextcloud
|
||||
- **Trade-off**: Additional infrastructure justified by enterprise needs
|
||||
|
||||
---
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. **The MCP Protocol Boundary**: The MCP protocol creates a fundamental boundary between client and server token management. Attempting to breach this boundary (ADR-002) leads to architectural contradictions.
|
||||
1. **Pattern 3 vs Pattern 4**: Both support external IdPs, but differ in integration approach:
|
||||
- Pattern 3: MCP → Nextcloud OIDC → (user_oidc) → External IdP
|
||||
- Pattern 4: MCP → External IdP directly (Nextcloud also uses same IdP)
|
||||
- Choose Pattern 3 for Nextcloud-centric deployments, Pattern 4 for IdP-centric enterprises
|
||||
|
||||
2. **Service Accounts Don't Solve User Problems**: Using service accounts for user operations violates OAuth's core principle of acting on behalf of users, not as a service identity.
|
||||
2. **The MCP Protocol Boundary**: The MCP protocol creates a fundamental boundary between client and server token management. Attempting to breach this boundary (ADR-002) leads to architectural contradictions.
|
||||
|
||||
3. **Double OAuth is Industry Standard**: Major platforms (Zapier, IFTTT, Microsoft Power Automate) use this pattern - the integration platform is an OAuth client that maintains its own relationships with upstream services.
|
||||
3. **Service Accounts Don't Solve User Problems**: Using service accounts for user operations violates OAuth's core principle of acting on behalf of users, not as a service identity.
|
||||
|
||||
4. **Refresh Tokens Are The Solution**: The OAuth spec designed refresh tokens specifically for offline access. Rejecting them (as ADR-002 did) means rejecting the standard solution.
|
||||
4. **Double OAuth is Industry Standard**: Major platforms (Zapier, IFTTT, Microsoft Power Automate) use this pattern - the integration platform is an OAuth client that maintains its own relationships with upstream services.
|
||||
|
||||
5. **Complexity is Justified**: The additional complexity of managing two OAuth flows is acceptable when offline access is a requirement. The alternative is no offline access at all.
|
||||
5. **Refresh Tokens Are The Solution**: The OAuth spec designed refresh tokens specifically for offline access. Rejecting them (as ADR-002 did) means rejecting the standard solution.
|
||||
|
||||
6. **Complexity is Justified**: The additional complexity of managing OAuth flows is acceptable when offline access is a requirement. The alternative is no offline access at all.
|
||||
|
||||
---
|
||||
|
||||
@@ -243,12 +293,19 @@ Use **Pattern 1 (Pass-Through)** if:
|
||||
- Only interactive operations required
|
||||
- Simplicity is priority
|
||||
|
||||
### For Platform Deployments
|
||||
Use **Pattern 3 (MCP as OAuth Client)** if:
|
||||
### For Teams Using Nextcloud
|
||||
Use **Pattern 3 (Sign-in with Nextcloud)** if:
|
||||
- Background sync/indexing required
|
||||
- Multiple users need service
|
||||
- Building integration platform
|
||||
- Offline operations critical
|
||||
- Nextcloud manages your authentication
|
||||
- Can use external IdPs via user_oidc
|
||||
- Prefer single integration point through Nextcloud
|
||||
|
||||
### For Enterprise Deployments
|
||||
Use **Pattern 4 (Federated Authentication)** if:
|
||||
- Enterprise IdP already exists (Keycloak, Okta, Azure AD)
|
||||
- Multiple resource servers beyond Nextcloud
|
||||
- Compliance requirements for centralized auth
|
||||
- Building platform for multiple organizations
|
||||
|
||||
### Never Use Pattern 2
|
||||
Token Exchange with service accounts should not be used as it:
|
||||
|
||||
Reference in New Issue
Block a user