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:
Chris Coutinho
2025-11-02 23:58:15 +01:00
parent f2af5a39a8
commit bf8120682e
2 changed files with 491 additions and 347 deletions
+358 -271
View File
@@ -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)
+133 -76
View File
@@ -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 Request2. 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: