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
|
**Status**: Draft
|
||||||
**Date**: 2025-11-02
|
**Date**: 2025-11-02
|
||||||
@@ -6,54 +6,63 @@
|
|||||||
|
|
||||||
## Context
|
## 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
|
The real challenge is that:
|
||||||
2. The MCP **server** receives pre-authenticated tokens with each request
|
1. The MCP server needs to access Nextcloud APIs on behalf of users
|
||||||
3. The server never sees or stores refresh tokens
|
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
|
## Problem Statement
|
||||||
|
|
||||||
We need a way for:
|
We need a way for:
|
||||||
1. Background workers to access user data when users are offline
|
1. Users to authenticate once to a central identity provider
|
||||||
2. The MCP server to maintain persistent access to Nextcloud
|
2. The MCP server to obtain delegated access to Nextcloud resources
|
||||||
3. Proper OAuth compliance with user consent
|
3. Background workers to access user data using stored refresh tokens
|
||||||
4. Clean separation of authentication concerns
|
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
|
## 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
|
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 acts as a registered OAuth client to Nextcloud
|
2. **MCP Server as OAuth Client**: The MCP server registers with the shared IdP to request tokens
|
||||||
3. **Single Authentication Flow**: One OAuth flow bootstraps both user identity and API access
|
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:
|
The MCP server will:
|
||||||
- Registers with Nextcloud's OAuth provider
|
- Act as an OAuth client to the shared IdP
|
||||||
- Uses Nextcloud OIDC as the primary authentication mechanism
|
- Request tokens on behalf of users, scoped for Nextcloud API access
|
||||||
- Stores refresh tokens securely with rotation
|
- Store refresh tokens securely with rotation
|
||||||
- Uses stored tokens for both MCP sessions and background operations
|
- Use stored tokens for both MCP sessions and background operations
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### OAuth Flow
|
### Federated OAuth Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────┐ ┌─────────────────┐ ┌────────────┐
|
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐
|
||||||
│ MCP Client ├───────────────────> │ MCP Server ├────────────────────>│ Nextcloud │
|
│ MCP Client │◄──────401──────│ MCP Server │◄────OAuth──────│ Shared IdP │──Validates──►│ Nextcloud │
|
||||||
│ (Claude) │ (MCP Protocol) │ (OAuth Client) │ (OIDC + APIs) │ APIs │
|
│ (Claude) │ │ (OAuth Client) │ (On-Behalf) │ (Keycloak) │ Tokens │(Resource) │
|
||||||
└─────────────┘ └─────────────────┘ └────────────┘
|
└─────────────┘ └─────────────────┘ └──────────────┘ └────────────┘
|
||||||
│
|
│
|
||||||
┌──────▼────────┐
|
┌───────▼────────┐
|
||||||
│ Token Storage │
|
│ Token Storage │
|
||||||
│ (Rotated Tokens)
|
│ (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
|
### Authentication Flows
|
||||||
|
|
||||||
#### Initial Setup (One-Time)
|
#### Initial Setup (One-Time)
|
||||||
@@ -64,33 +73,50 @@ sequenceDiagram
|
|||||||
participant Browser
|
participant Browser
|
||||||
participant MCPClient as MCP Client
|
participant MCPClient as MCP Client
|
||||||
participant MCPServer as MCP Server
|
participant MCPServer as MCP Server
|
||||||
|
participant IdP as Shared IdP (Keycloak)
|
||||||
participant Nextcloud
|
participant Nextcloud
|
||||||
|
|
||||||
User->>MCPClient: Try to use MCP tool (e.g., list_notes)
|
User->>MCPClient: Connect to MCP
|
||||||
MCPClient->>MCPServer: MCP Request
|
MCPClient->>MCPServer: Initial request
|
||||||
MCPServer->>MCPServer: Check token storage
|
MCPServer-->>MCPClient: 401 Unauthorized
|
||||||
MCPServer-->>MCPClient: Auth Required (special response)
|
|
||||||
|
|
||||||
MCPClient->>MCPServer: Call authorize_nextcloud tool
|
Note over MCPClient: WWW-Authenticate header<br/>points to IdP OAuth
|
||||||
MCPServer-->>MCPClient: Return auth_url
|
|
||||||
MCPClient-->>User: Display auth URL
|
|
||||||
|
|
||||||
User->>Browser: Click link to authenticate
|
MCPClient->>Browser: Open IdP OAuth URL
|
||||||
Browser->>Nextcloud: OAuth Authorization Request
|
Browser->>MCPServer: GET /oauth/authorize
|
||||||
Nextcloud->>User: Login & Consent
|
MCPServer->>Browser: Redirect to IdP
|
||||||
User->>Nextcloud: Approve
|
|
||||||
Nextcloud->>Browser: Redirect to callback with code
|
|
||||||
Browser->>MCPServer: /oauth/callback with code
|
|
||||||
|
|
||||||
MCPServer->>Nextcloud: Exchange code for tokens
|
Browser->>IdP: Authorization Request
|
||||||
Nextcloud->>MCPServer: Access + Refresh Tokens
|
Note over IdP: Scopes include:<br/>- openid profile email<br/>- offline_access<br/>- nextcloud:notes:*
|
||||||
MCPServer->>MCPServer: Create user account
|
|
||||||
MCPServer->>MCPServer: Store encrypted tokens
|
|
||||||
MCPServer-->>Browser: Success page
|
|
||||||
|
|
||||||
User->>MCPClient: Retry MCP tool
|
IdP->>User: Login page
|
||||||
MCPClient->>MCPServer: MCP Request (now authenticated)
|
User->>IdP: Authenticate once
|
||||||
MCPServer-->>MCPClient: Tool response
|
|
||||||
|
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
|
#### Subsequent MCP Sessions
|
||||||
@@ -100,21 +126,25 @@ sequenceDiagram
|
|||||||
participant MCPClient as MCP Client
|
participant MCPClient as MCP Client
|
||||||
participant MCPServer as MCP Server
|
participant MCPServer as MCP Server
|
||||||
participant TokenStore as Token Storage
|
participant TokenStore as Token Storage
|
||||||
|
participant IdP as Shared IdP
|
||||||
participant Nextcloud
|
participant Nextcloud
|
||||||
|
|
||||||
MCPClient->>MCPServer: MCP Request
|
MCPClient->>MCPServer: Request with MCP session token
|
||||||
MCPServer->>TokenStore: Get user's active token
|
MCPServer->>MCPServer: Validate MCP session
|
||||||
TokenStore-->>MCPServer: Encrypted token (status='active')
|
MCPServer->>TokenStore: Get user's IdP tokens
|
||||||
|
TokenStore-->>MCPServer: Encrypted tokens (status='active')
|
||||||
MCPServer->>MCPServer: Check expiry
|
MCPServer->>MCPServer: Check expiry
|
||||||
|
|
||||||
alt Token Expired
|
alt Token Expired
|
||||||
MCPServer->>TokenStore: Mark token as 'used'
|
MCPServer->>TokenStore: Mark token as 'used'
|
||||||
MCPServer->>Nextcloud: Refresh with rotation
|
MCPServer->>IdP: Refresh token request
|
||||||
Nextcloud->>MCPServer: New access + refresh tokens
|
IdP->>MCPServer: New access + refresh tokens
|
||||||
MCPServer->>TokenStore: Store new tokens (status='active')
|
MCPServer->>TokenStore: Store new tokens (status='active')
|
||||||
end
|
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
|
Nextcloud-->>MCPServer: API response
|
||||||
MCPServer-->>MCPClient: MCP response
|
MCPServer-->>MCPClient: MCP response
|
||||||
```
|
```
|
||||||
@@ -125,83 +155,76 @@ sequenceDiagram
|
|||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
participant Worker as Background Worker
|
participant Worker as Background Worker
|
||||||
participant TokenStore as Token Storage
|
participant TokenStore as Token Storage
|
||||||
|
participant IdP as Shared IdP
|
||||||
participant Nextcloud
|
participant Nextcloud
|
||||||
|
|
||||||
Worker->>TokenStore: Get user's active refresh token
|
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->>TokenStore: Mark token as 'used'
|
||||||
Worker->>Worker: Decrypt token
|
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->>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!
|
Note over Worker: No MCP client involvement!
|
||||||
```
|
```
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
|
|
||||||
### 1. Sign-in with Nextcloud Token Verifier
|
### 1. Federated Token Verifier
|
||||||
|
|
||||||
```python
|
```python
|
||||||
class NextcloudIdentityTokenVerifier(TokenVerifier):
|
class FederatedTokenVerifier(TokenVerifier):
|
||||||
"""Uses Nextcloud as the sole identity provider."""
|
"""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.storage = token_storage
|
||||||
|
self.idp_client = idp_client
|
||||||
|
|
||||||
async def verify_token(self, token: str) -> AccessToken | None:
|
async def verify_token(self, token: str) -> AccessToken | None:
|
||||||
# Token represents a Nextcloud session ID after OAuth
|
# Verify MCP session token
|
||||||
session = await self.storage.get_session(token)
|
session = await self.verify_mcp_session(token)
|
||||||
if not session:
|
if not session:
|
||||||
# User needs to complete Sign-in with Nextcloud
|
return None # Will trigger 401 response
|
||||||
return AccessToken(
|
|
||||||
token=token,
|
|
||||||
scopes=["nextcloud:auth:required"],
|
|
||||||
resource=json.dumps({
|
|
||||||
"needs_auth": True,
|
|
||||||
"auth_type": "sign_in_with_nextcloud"
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get active token for this user
|
# Get stored IdP tokens for this user
|
||||||
nc_tokens = await self.storage.get_active_tokens(session.user_id)
|
idp_tokens = await self.storage.get_active_tokens(session.user_id)
|
||||||
|
|
||||||
if not nc_tokens:
|
if not idp_tokens:
|
||||||
# Session exists but tokens revoked/expired
|
# User needs to complete OAuth flow with IdP
|
||||||
return AccessToken(
|
return None # Triggers 401 with WWW-Authenticate header
|
||||||
token=token,
|
|
||||||
scopes=["nextcloud:auth:required"],
|
|
||||||
resource=json.dumps({
|
|
||||||
"user_id": session.user_id,
|
|
||||||
"needs_reauth": True
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
# Refresh if expired (with rotation)
|
# Refresh if expired (with rotation)
|
||||||
if nc_tokens.is_expired():
|
if idp_tokens.is_expired():
|
||||||
nc_tokens = await self.rotate_refresh_token(
|
idp_tokens = await self.rotate_refresh_token(
|
||||||
session.user_id,
|
session.user_id,
|
||||||
nc_tokens
|
idp_tokens
|
||||||
)
|
)
|
||||||
|
|
||||||
# Return Nextcloud access token for API use
|
# Return IdP access token for Nextcloud API use
|
||||||
return AccessToken(
|
return AccessToken(
|
||||||
token=nc_tokens.access_token,
|
token=idp_tokens.access_token,
|
||||||
scopes=nc_tokens.scopes,
|
scopes=idp_tokens.scopes,
|
||||||
resource=json.dumps({
|
resource=json.dumps({
|
||||||
"user_id": session.user_id,
|
"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):
|
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'
|
# Mark old token as 'used'
|
||||||
await self.storage.mark_token_used(old_tokens.token_id)
|
await self.storage.mark_token_used(old_tokens.token_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Exchange for new tokens
|
# Exchange with IdP for new tokens
|
||||||
new_tokens = await self.oauth_client.refresh(old_tokens.refresh_token)
|
new_tokens = await self.idp_client.refresh(old_tokens.refresh_token)
|
||||||
|
|
||||||
# Store new tokens in same family
|
# Store new tokens in same family
|
||||||
await self.storage.store_tokens(
|
await self.storage.store_tokens(
|
||||||
@@ -221,55 +244,57 @@ class NextcloudIdentityTokenVerifier(TokenVerifier):
|
|||||||
raise
|
raise
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. OAuth Flow Initiation
|
### 2. OAuth Endpoints (MCP Server as OAuth Client)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@mcp.tool()
|
@app.get("/oauth/authorize")
|
||||||
async def authorize_nextcloud(ctx: Context) -> dict:
|
async def oauth_authorize(
|
||||||
"""Initiate Sign-in with Nextcloud OAuth flow."""
|
response_type: str = "code",
|
||||||
access_token = ctx.request_context.request.user.access_token
|
client_id: str = None,
|
||||||
auth_state = json.loads(access_token.resource)
|
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"):
|
# Build IdP authorization URL with all needed scopes
|
||||||
return {"status": "already_authorized"}
|
idp_state = f"{session_id}:{generate_secure_state()}"
|
||||||
|
idp_auth_url = (
|
||||||
# Generate OAuth URL with PKCE
|
f"{IDP_AUTHORIZATION_ENDPOINT}?"
|
||||||
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?"
|
|
||||||
f"client_id={MCP_SERVER_CLIENT_ID}&"
|
f"client_id={MCP_SERVER_CLIENT_ID}&"
|
||||||
f"redirect_uri={MCP_SERVER_URL}/oauth/callback&"
|
f"redirect_uri={MCP_SERVER_URL}/oauth/callback&"
|
||||||
f"response_type=code&"
|
f"response_type=code&"
|
||||||
f"scope=openid profile email offline_access notes:read notes:write&"
|
f"scope=openid profile email offline_access " # Identity + offline
|
||||||
f"state={state}&"
|
f"nextcloud:notes:read nextcloud:notes:write " # Nextcloud scopes
|
||||||
f"code_challenge={code_challenge}&"
|
f"nextcloud:calendar:read nextcloud:calendar:write&"
|
||||||
f"code_challenge_method=S256"
|
f"state={idp_state}&"
|
||||||
|
f"prompt=consent" # Ensure refresh token is issued
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return RedirectResponse(idp_auth_url)
|
||||||
"status": "authorization_required",
|
|
||||||
"auth_url": auth_url,
|
|
||||||
"message": "Please visit the URL to sign in with Nextcloud"
|
|
||||||
}
|
|
||||||
|
|
||||||
@app.get("/oauth/callback")
|
@app.get("/oauth/callback")
|
||||||
async def oauth_callback(code: str, state: str):
|
async def oauth_callback(code: str, state: str):
|
||||||
"""Handle OAuth callback and create user account."""
|
"""Handle callback from Shared IdP."""
|
||||||
# Verify state and retrieve PKCE verifier
|
# Extract session ID from state
|
||||||
code_verifier = await get_oauth_state(state)
|
session_id, _ = state.split(":", 1)
|
||||||
if not code_verifier:
|
oauth_session = await get_oauth_session(session_id)
|
||||||
return {"error": "Invalid state"}
|
|
||||||
|
|
||||||
# Exchange code for tokens
|
if not oauth_session:
|
||||||
tokens = await oauth_client.exchange_code(
|
return {"error": "Invalid session"}
|
||||||
|
|
||||||
|
# Exchange code with IdP for tokens
|
||||||
|
tokens = await idp_client.exchange_code(
|
||||||
code=code,
|
code=code,
|
||||||
code_verifier=code_verifier
|
redirect_uri=f"{MCP_SERVER_URL}/oauth/callback"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Decode ID token to get user info
|
# Decode ID token to get user info
|
||||||
@@ -277,54 +302,114 @@ async def oauth_callback(code: str, state: str):
|
|||||||
|
|
||||||
# Create or update user account
|
# Create or update user account
|
||||||
user = await create_or_update_user(
|
user = await create_or_update_user(
|
||||||
nc_username=userinfo.preferred_username,
|
idp_sub=userinfo.sub,
|
||||||
nc_sub=userinfo.sub,
|
username=userinfo.preferred_username,
|
||||||
email=userinfo.email
|
email=userinfo.email
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate new token family for this authentication
|
# Generate new token family for rotation
|
||||||
token_family_id = str(uuid4())
|
token_family_id = str(uuid4())
|
||||||
|
|
||||||
# Store tokens with rotation support
|
# Store IdP tokens (these have Nextcloud scopes)
|
||||||
await token_storage.store_tokens(
|
await token_storage.store_tokens(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
token_family_id=token_family_id,
|
token_family_id=token_family_id,
|
||||||
access_token=tokens.access_token,
|
access_token=tokens.access_token,
|
||||||
refresh_token=tokens.refresh_token,
|
refresh_token=tokens.refresh_token,
|
||||||
status='active',
|
status='active',
|
||||||
nc_username=userinfo.preferred_username
|
scopes=tokens.scope,
|
||||||
|
idp_subject=userinfo.sub
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create session for MCP
|
# Generate MCP session token for the client
|
||||||
session_token = generate_session_token()
|
mcp_session_token = generate_mcp_session_token(user.id)
|
||||||
await token_storage.create_session(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>
|
<html>
|
||||||
<body>
|
<body>
|
||||||
<h1>Authorization Successful!</h1>
|
<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>
|
<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>
|
</body>
|
||||||
</html>
|
</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
|
```sql
|
||||||
-- User accounts (created from Nextcloud OIDC)
|
-- User accounts (created from IdP identity)
|
||||||
CREATE TABLE users (
|
CREATE TABLE users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
nc_sub TEXT UNIQUE NOT NULL, -- Nextcloud OIDC subject
|
idp_sub TEXT UNIQUE NOT NULL, -- IdP subject identifier
|
||||||
nc_username TEXT NOT NULL,
|
username TEXT NOT NULL,
|
||||||
email TEXT,
|
email TEXT,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_login INTEGER NOT NULL
|
last_login INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Token storage with rotation support
|
-- IdP tokens with rotation support
|
||||||
CREATE TABLE user_nextcloud_tokens (
|
CREATE TABLE idp_tokens (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
token_family_id TEXT NOT NULL, -- Groups all tokens in rotation chain
|
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,
|
encrypted_refresh_token BLOB NOT NULL,
|
||||||
access_expires_at INTEGER NOT NULL,
|
access_expires_at INTEGER NOT NULL,
|
||||||
status TEXT NOT NULL CHECK(status IN ('active', 'used', 'revoked')),
|
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,
|
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
|
-- Only one active token per family
|
||||||
UNIQUE(token_family_id, status) WHERE status = 'active'
|
UNIQUE(token_family_id, status) WHERE status = 'active'
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Index for quick lookups
|
-- 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';
|
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 (
|
CREATE TABLE mcp_sessions (
|
||||||
session_token TEXT PRIMARY KEY,
|
session_token TEXT PRIMARY KEY,
|
||||||
user_id TEXT NOT NULL REFERENCES users(id),
|
user_id TEXT NOT NULL REFERENCES users(id),
|
||||||
created_at INTEGER NOT NULL,
|
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
|
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
|
```python
|
||||||
class BackgroundSyncWorker:
|
class BackgroundSyncWorker:
|
||||||
"""Sync user data with proper token rotation."""
|
"""Sync user data using IdP tokens."""
|
||||||
|
|
||||||
def __init__(self, token_storage: RefreshTokenStorage):
|
def __init__(self, token_storage: RefreshTokenStorage):
|
||||||
self.storage = token_storage
|
self.storage = token_storage
|
||||||
|
self.idp_client = OAuthClient.from_discovery(IDP_DISCOVERY_URL)
|
||||||
self.nextcloud_url = os.getenv("NEXTCLOUD_HOST")
|
self.nextcloud_url = os.getenv("NEXTCLOUD_HOST")
|
||||||
|
|
||||||
async def sync_user_data(self, user_id: str):
|
async def sync_user_data(self, user_id: str):
|
||||||
"""Sync data using rotated tokens."""
|
"""Sync data using IdP tokens with Nextcloud scopes."""
|
||||||
# Get active refresh token
|
# Get active refresh token from IdP
|
||||||
tokens = await self.storage.get_active_tokens(user_id)
|
tokens = await self.storage.get_active_tokens(user_id)
|
||||||
if not tokens:
|
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
|
return
|
||||||
|
|
||||||
# Mark token as used immediately
|
# Mark token as used immediately (rotation)
|
||||||
await self.storage.mark_token_used(tokens.id)
|
await self.storage.mark_token_used(tokens.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Exchange for new tokens (rotation)
|
# Exchange with IdP for new tokens
|
||||||
oauth_client = NextcloudOAuthClient.from_discovery(self.nextcloud_url)
|
new_tokens = await self.idp_client.refresh(tokens.refresh_token)
|
||||||
new_tokens = await oauth_client.refresh(tokens.refresh_token)
|
|
||||||
|
|
||||||
# Store new tokens in same family
|
# Store new tokens in same family
|
||||||
await self.storage.store_tokens(
|
await self.storage.store_tokens(
|
||||||
@@ -401,20 +498,22 @@ class BackgroundSyncWorker:
|
|||||||
status='active'
|
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(
|
client = NextcloudClient.from_token(
|
||||||
base_url=self.nextcloud_url,
|
base_url=self.nextcloud_url,
|
||||||
token=new_tokens.access_token,
|
token=new_tokens.access_token,
|
||||||
username=tokens.nc_username
|
username=tokens.username
|
||||||
)
|
)
|
||||||
|
|
||||||
# Perform sync operations
|
# Perform sync operations
|
||||||
await self.sync_notes(user_id, client)
|
await self.sync_notes(user_id, client)
|
||||||
await self.sync_calendar(user_id, client)
|
await self.sync_calendar(user_id, client)
|
||||||
|
await self.sync_contacts(user_id, client)
|
||||||
|
|
||||||
except HTTPStatusError as e:
|
except HTTPStatusError as e:
|
||||||
if e.response.status_code == 401:
|
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.storage.revoke_token_family(tokens.token_family_id)
|
||||||
await self.log_security_event(user_id, "token_revoked", tokens.token_family_id)
|
await self.log_security_event(user_id, "token_revoked", tokens.token_family_id)
|
||||||
raise
|
raise
|
||||||
@@ -422,94 +521,61 @@ class BackgroundSyncWorker:
|
|||||||
# Revert token status on failure
|
# Revert token status on failure
|
||||||
await self.storage.revert_token_status(tokens.id)
|
await self.storage.revert_token_status(tokens.id)
|
||||||
raise
|
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
|
```python
|
||||||
class RefreshTokenStorage:
|
# Environment variables for federated setup
|
||||||
"""Storage with reuse detection."""
|
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:
|
# Nextcloud configuration
|
||||||
"""Get active tokens, detecting reuse attempts."""
|
NEXTCLOUD_HOST = os.getenv("NEXTCLOUD_HOST") # Nextcloud instance URL
|
||||||
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
|
|
||||||
|
|
||||||
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):
|
return OAuthClient(
|
||||||
"""Mark token as used - critical for reuse detection."""
|
authorization_endpoint=discovery_doc["authorization_endpoint"],
|
||||||
result = await self.db.execute(
|
token_endpoint=discovery_doc["token_endpoint"],
|
||||||
"""
|
introspection_endpoint=discovery_doc.get("introspection_endpoint"),
|
||||||
UPDATE user_nextcloud_tokens
|
userinfo_endpoint=discovery_doc["userinfo_endpoint"],
|
||||||
SET status = 'used', used_at = ?
|
client_id=MCP_SERVER_CLIENT_ID,
|
||||||
WHERE id = ? AND status = 'active'
|
client_secret=MCP_SERVER_CLIENT_SECRET
|
||||||
""",
|
)
|
||||||
(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,)
|
|
||||||
)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Advantages
|
## Advantages
|
||||||
|
|
||||||
1. **True Offline Access**: Background workers can operate without active MCP sessions
|
1. **Single Sign-On**: Users authenticate once to the shared IdP
|
||||||
2. **OAuth Compliant**: Proper user consent and token lifecycle with rotation
|
2. **Federated Identity**: Enterprise-ready with support for SAML, LDAP backends
|
||||||
3. **Single Sign-On**: Users authenticate once with their Nextcloud credentials
|
3. **True Offline Access**: Background workers operate with stored IdP refresh tokens
|
||||||
4. **Security**: Full token rotation with reuse detection
|
4. **OAuth Compliant**: Proper delegation with on-behalf-of pattern
|
||||||
5. **Simplicity**: No separate app authentication layer to maintain
|
5. **Security Isolation**: MCP clients never see IdP or Nextcloud credentials
|
||||||
6. **User Control**: Users can revoke access at any time through Nextcloud
|
6. **Flexible Backend**: Can swap Nextcloud for other resources without changing auth
|
||||||
|
7. **Standard Pattern**: Industry-standard federated OAuth architecture
|
||||||
|
|
||||||
## Disadvantages
|
## Disadvantages
|
||||||
|
|
||||||
1. **Nextcloud Dependency**: The MCP server requires Nextcloud OIDC for all authentication
|
1. **IdP Dependency**: Requires a shared identity provider infrastructure
|
||||||
2. **Token Management**: Complex token rotation logic
|
2. **Complex Token Lifecycle**: Managing tokens from IdP for Nextcloud access
|
||||||
3. **Migration**: Existing deployments need architectural changes
|
3. **Token Validation Overhead**: Nextcloud must validate tokens with IdP
|
||||||
|
4. **Migration Complexity**: Existing deployments need IdP setup
|
||||||
|
|
||||||
## Security Considerations
|
## Security Considerations
|
||||||
|
|
||||||
@@ -524,32 +590,43 @@ class RefreshTokenStorage:
|
|||||||
- **Atomic operations**: Token status updates must be atomic to prevent race conditions
|
- **Atomic operations**: Token status updates must be atomic to prevent race conditions
|
||||||
- **Audit logging**: All token operations are logged for security analysis
|
- **Audit logging**: All token operations are logged for security analysis
|
||||||
|
|
||||||
### Revocation
|
### Trust Relationships
|
||||||
- Implement webhook listener for Nextcloud revocation events
|
- **IdP Trust**: Both MCP server and Nextcloud must trust the IdP
|
||||||
- Immediate family revocation on reuse detection
|
- **Audience Validation**: Tokens must include proper audience claims
|
||||||
- Clear session mappings on logout
|
- **Scope Verification**: Each service validates only its required scopes
|
||||||
|
- **Certificate Pinning**: Consider pinning IdP certificates in production
|
||||||
|
|
||||||
### Scope Management
|
### Revocation
|
||||||
- Request minimal scopes needed for operations
|
- Implement webhook listener for IdP revocation events
|
||||||
- Allow users to customize scope grants
|
- Immediate family revocation on reuse detection
|
||||||
- Implement per-tool scope checking
|
- Clear MCP sessions on logout
|
||||||
|
- Propagate revocation to Nextcloud if needed
|
||||||
|
|
||||||
## Migration Strategy
|
## 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
|
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
|
3. Test with subset of users
|
||||||
|
4. Monitor token lifecycle and refresh patterns
|
||||||
|
|
||||||
### Phase 2: Gradual Migration
|
### Phase 3: Migration
|
||||||
1. New users default to Sign-in with Nextcloud
|
1. Migrate existing users to IdP accounts
|
||||||
2. Prompt existing users to migrate
|
2. Map existing permissions to IdP scopes
|
||||||
3. Maintain backward compatibility
|
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
|
1. Announce end-of-life for pass-through mode
|
||||||
2. Provide migration tools
|
2. Complete user migration
|
||||||
3. Remove legacy code
|
3. Remove legacy authentication code
|
||||||
|
4. Document new auth flow
|
||||||
|
|
||||||
## Alternatives Considered
|
## Alternatives Considered
|
||||||
|
|
||||||
@@ -568,23 +645,33 @@ class RefreshTokenStorage:
|
|||||||
- **Cons**: Circular dependency, doesn't solve bootstrap problem
|
- **Cons**: Circular dependency, doesn't solve bootstrap problem
|
||||||
- **Rejected**: Doesn't enable true offline access
|
- **Rejected**: Doesn't enable true offline access
|
||||||
|
|
||||||
### 4. Double OAuth (Initial ADR-004 Draft)
|
### 4. Sign-in with Nextcloud (Previous ADR-004)
|
||||||
- **Pros**: Separation of concerns
|
- **Pros**: Direct Nextcloud integration
|
||||||
- **Cons**: Users must authenticate twice, complex to maintain two auth systems
|
- **Cons**: Tight coupling, no enterprise IdP support
|
||||||
- **Rejected**: Poor user experience, unnecessary complexity
|
- **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
|
## References
|
||||||
|
|
||||||
- [RFC 6749: OAuth 2.0](https://datatracker.ietf.org/doc/html/rfc6749)
|
- [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)
|
- [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)
|
- [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)
|
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
|
||||||
|
- [OAuth 2.0 for Native Apps](https://datatracker.ietf.org/doc/html/rfc8252)
|
||||||
## Decision Outcome
|
- [OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628)
|
||||||
|
|
||||||
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.
|
|
||||||
@@ -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
|
### Architecture
|
||||||
```
|
```
|
||||||
Layer 1: MCP Authentication Layer 2: Nextcloud Authorization
|
┌─────────────┐ ┌─────────────────┐ ┌────────────┐
|
||||||
┌─────────────┐ ┌─────────────────┐ ┌─────────────┐
|
│ MCP Client ├───────────────────> │ MCP Server ├────────────────────>│ Nextcloud │
|
||||||
│ MCP Client │ │ MCP Server │ │ Nextcloud │
|
│ (Claude) │ (MCP Protocol) │ (OAuth Client) │ (OIDC + APIs) │ (IdP) │
|
||||||
│ (Claude) │ │ (OAuth Client) │ │OAuth Provider
|
└─────────────┘ └─────────────────┘ └────────────┘
|
||||||
└──────┬──────┘ └────────┬────────┘ └──────┬──────┘
|
│
|
||||||
│ │ │
|
┌──────▼────────┐
|
||||||
│ 1. MCP Request │ 2. Check stored tokens │
|
│ Token Storage │
|
||||||
├─────────────────────────────────────►│ │
|
│ (NC 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!) │
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Characteristics
|
### Characteristics
|
||||||
| Aspect | Description |
|
| Aspect | Description |
|
||||||
|--------|-------------|
|
|--------|-------------|
|
||||||
| **Token Flow** | MCP Server owns Nextcloud tokens |
|
| **Token Flow** | MCP Server uses Nextcloud as identity provider |
|
||||||
| **Token Storage** | ✅ Encrypted refresh tokens |
|
| **Token Storage** | ✅ Encrypted Nextcloud refresh tokens |
|
||||||
| **Offline Access** | ✅ Full support |
|
| **Offline Access** | ✅ Full support |
|
||||||
| **Background Workers** | ✅ Use stored refresh tokens |
|
| **Background Workers** | ✅ Use stored refresh tokens |
|
||||||
| **User Consent** | Two OAuth flows (app + Nextcloud) |
|
| **User Consent** | Single OAuth flow (Nextcloud only) |
|
||||||
| **Complexity** | Medium-High |
|
| **Complexity** | Medium |
|
||||||
| **Security** | High (proper OAuth compliance) |
|
| **Security** | High (with token rotation) |
|
||||||
|
|
||||||
### How It Works
|
### How It Works
|
||||||
1. **Initial Setup**:
|
1. **Initial Setup**:
|
||||||
- User connects to MCP server (Layer 1 auth)
|
- User tries to use MCP tool
|
||||||
- MCP server checks for stored Nextcloud tokens
|
- MCP server returns auth required
|
||||||
- If missing, triggers OAuth flow with Nextcloud
|
- User authenticates with Nextcloud's OIDC endpoint
|
||||||
- User authorizes MCP server to access Nextcloud
|
- Nextcloud may use user_oidc to delegate to external IdP (Keycloak, etc.)
|
||||||
- MCP server stores refresh token (encrypted)
|
- MCP server stores Nextcloud-issued refresh token (encrypted)
|
||||||
|
|
||||||
2. **Subsequent Requests**:
|
2. **Subsequent Requests**:
|
||||||
- MCP server uses stored access token
|
- MCP server uses stored Nextcloud tokens
|
||||||
- Refreshes automatically when expired
|
- Refreshes automatically when expired
|
||||||
- No client involvement needed
|
- No client involvement needed
|
||||||
|
|
||||||
3. **Background Operations**:
|
3. **Background Operations**:
|
||||||
- Worker retrieves stored refresh token
|
- Worker retrieves stored refresh token
|
||||||
- Gets new access token from Nextcloud
|
- Refreshes with Nextcloud directly
|
||||||
- Performs operations independently
|
- Performs operations independently
|
||||||
|
|
||||||
### Advantages
|
### Advantages
|
||||||
|
- ✅ Single sign-on with Nextcloud
|
||||||
- ✅ True offline access capability
|
- ✅ True offline access capability
|
||||||
- ✅ OAuth-compliant with proper consent
|
- ✅ OAuth-compliant with proper consent
|
||||||
- ✅ Background workers can operate independently
|
- ✅ Supports external IdPs via user_oidc
|
||||||
- ✅ Tokens persist across MCP sessions
|
- ✅ Simpler integration - only one OAuth endpoint
|
||||||
- ✅ Users can revoke access anytime
|
|
||||||
|
|
||||||
### Trade-offs
|
### Trade-offs
|
||||||
- Users must authorize twice (MCP + Nextcloud)
|
- Authentication flows through Nextcloud
|
||||||
- More complex token management
|
- Nextcloud manages IdP relationships (via user_oidc)
|
||||||
- Requires secure token storage
|
- 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
|
## Comparison Matrix
|
||||||
|
|
||||||
| Feature | Pass-Through | Token Exchange | MCP as OAuth Client |
|
| Feature | Pass-Through | Token Exchange | Sign-in with NC | Federated Auth |
|
||||||
|---------|--------------|----------------|-------------------|
|
|---------|--------------|----------------|-----------------|----------------|
|
||||||
| **Offline Access** | ❌ No | ❌ No | ✅ Yes |
|
| **Offline Access** | ❌ No | ❌ No | ✅ Yes | ✅ Yes |
|
||||||
| **Background Workers** | ❌ No | ❌ No* | ✅ Yes |
|
| **Background Workers** | ❌ No | ❌ No* | ✅ Yes | ✅ Yes |
|
||||||
| **Token Storage** | None | None | Refresh tokens |
|
| **Token Storage** | None | None | NC refresh tokens | IdP refresh tokens |
|
||||||
| **OAuth Compliance** | ✅ Full | ⚠️ Violates | ✅ Full |
|
| **OAuth Compliance** | ✅ Full | ⚠️ Violates | ✅ Full | ✅ Full |
|
||||||
| **User Consent** | Once | Implicit | Twice |
|
| **User Consent** | Once | Implicit | Once (NC) | Once (IdP) |
|
||||||
| **Implementation Complexity** | Low | High | Medium |
|
| **Implementation Complexity** | Low | High | Medium | Medium-High |
|
||||||
| **Security** | High | Medium | High |
|
| **Security** | High | Medium | High | Highest |
|
||||||
| **Suitable For** | Interactive only | N/A (flawed) | Full platform |
|
| **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*
|
\* *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
|
- **Result**: Circular dependencies, OAuth violations
|
||||||
- **Learning**: MCP protocol constraints are fundamental
|
- **Learning**: MCP protocol constraints are fundamental
|
||||||
|
|
||||||
### Stage 3: Application Pattern ✅
|
### Stage 3: Sign-in with Nextcloud ⚠️
|
||||||
- **Goal**: True offline access with OAuth compliance
|
- **Goal**: True offline access with OAuth compliance
|
||||||
- **Result**: MCP server as independent OAuth client
|
- **Result**: MCP server uses Nextcloud as identity provider
|
||||||
- **Trade-off**: Additional complexity justified by requirements
|
- **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
|
## 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
|
- Only interactive operations required
|
||||||
- Simplicity is priority
|
- Simplicity is priority
|
||||||
|
|
||||||
### For Platform Deployments
|
### For Teams Using Nextcloud
|
||||||
Use **Pattern 3 (MCP as OAuth Client)** if:
|
Use **Pattern 3 (Sign-in with Nextcloud)** if:
|
||||||
- Background sync/indexing required
|
- Background sync/indexing required
|
||||||
- Multiple users need service
|
- Nextcloud manages your authentication
|
||||||
- Building integration platform
|
- Can use external IdPs via user_oidc
|
||||||
- Offline operations critical
|
- 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
|
### Never Use Pattern 2
|
||||||
Token Exchange with service accounts should not be used as it:
|
Token Exchange with service accounts should not be used as it:
|
||||||
|
|||||||
Reference in New Issue
Block a user