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>
24 KiB
ADR-004: Federated Authentication Architecture for Offline Access
Status: Draft Date: 2025-11-02 Supersedes: ADR-002
Context
ADR-002 attempted to solve the problem of background workers accessing user data by proposing token exchange patterns. However, it fundamentally misunderstood the MCP protocol's authentication architecture and OAuth delegation patterns.
The real challenge is that:
- The MCP server needs to access Nextcloud APIs on behalf of users
- Background workers need to operate when users are offline
- We need proper OAuth compliance with user consent
- Modern enterprise environments use federated identity providers
The solution is a Federated Authentication Architecture where both the MCP server and Nextcloud trust the same Identity Provider (IdP).
Problem Statement
We need a way for:
- Users to authenticate once to a central identity provider
- The MCP server to obtain delegated access to Nextcloud resources
- Background workers to access user data using stored refresh tokens
- Clean separation between identity management and resource access
The core issue: How can the MCP server obtain refresh tokens from a shared IdP to access Nextcloud on behalf of users?
Decision
We will implement a Federated Authentication Architecture using a Shared Identity Provider where:
- Shared IdP: A central identity provider (e.g., Keycloak, Okta, Azure AD) manages user authentication
- MCP Server as OAuth Client: The MCP server registers with the shared IdP to request tokens
- Nextcloud as Resource Server: Nextcloud validates tokens issued by the shared IdP
- On-Behalf-Of Flow: The MCP server requests tokens scoped for Nextcloud access
The MCP server will:
- Act as an OAuth client to the shared IdP
- Request tokens on behalf of users, scoped for Nextcloud API access
- Store refresh tokens securely with rotation
- Use stored tokens for both MCP sessions and background operations
Architecture
Federated OAuth Architecture
┌─────────────┐ ┌─────────────────┐ ┌──────────────┐ ┌────────────┐
│ 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)
sequenceDiagram
participant User
participant Browser
participant MCPClient as MCP Client
participant MCPServer as MCP Server
participant IdP as Shared IdP (Keycloak)
participant Nextcloud
User->>MCPClient: Connect to MCP
MCPClient->>MCPServer: Initial request
MCPServer-->>MCPClient: 401 Unauthorized
Note over MCPClient: WWW-Authenticate header<br/>points to IdP OAuth
MCPClient->>Browser: Open IdP OAuth URL
Browser->>MCPServer: GET /oauth/authorize
MCPServer->>Browser: Redirect to IdP
Browser->>IdP: Authorization Request
Note over IdP: Scopes include:<br/>- openid profile email<br/>- offline_access<br/>- nextcloud:notes:*
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
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: 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->>IdP: Refresh token request
IdP->>MCPServer: New access + refresh tokens
MCPServer->>TokenStore: Store new tokens (status='active')
end
MCPServer->>Nextcloud: API call with IdP access token
Nextcloud->>IdP: Validate token
IdP-->>Nextcloud: Valid + scopes
Nextcloud-->>MCPServer: API response
MCPServer-->>MCPClient: MCP response
Background Operations
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 IdP refresh token
Worker->>TokenStore: Mark token as 'used'
Worker->>Worker: Decrypt token
Worker->>IdP: Exchange refresh token
IdP->>Worker: New access + refresh tokens
Worker->>TokenStore: Store new tokens (status='active')
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. Federated Token Verifier
class FederatedTokenVerifier(TokenVerifier):
"""Verifies MCP session tokens and manages IdP tokens."""
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:
# Verify MCP session token
session = await self.verify_mcp_session(token)
if not session:
return None # Will trigger 401 response
# Get stored IdP tokens for this user
idp_tokens = await self.storage.get_active_tokens(session.user_id)
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 idp_tokens.is_expired():
idp_tokens = await self.rotate_refresh_token(
session.user_id,
idp_tokens
)
# Return IdP access token for Nextcloud API use
return AccessToken(
token=idp_tokens.access_token,
scopes=idp_tokens.scopes,
resource=json.dumps({
"user_id": session.user_id,
"idp_sub": idp_tokens.subject
})
)
async def rotate_refresh_token(self, user_id: str, old_tokens: TokenSet):
"""Rotate IdP refresh tokens with reuse detection."""
# Mark old token as 'used'
await self.storage.mark_token_used(old_tokens.token_id)
try:
# 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(
user_id=user_id,
token_family_id=old_tokens.token_family_id,
access_token=new_tokens.access_token,
refresh_token=new_tokens.refresh_token,
status='active'
)
return new_tokens
except RefreshTokenReuseError:
# Possible token theft - revoke entire family
await self.storage.revoke_token_family(old_tokens.token_family_id)
await self.alert_user_possible_breach(user_id)
raise
2. OAuth Endpoints (MCP Server as OAuth Client)
@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
)
# 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 " # 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 RedirectResponse(idp_auth_url)
@app.get("/oauth/callback")
async def oauth_callback(code: str, state: str):
"""Handle callback from Shared IdP."""
# Extract session ID from state
session_id, _ = state.split(":", 1)
oauth_session = await get_oauth_session(session_id)
if not oauth_session:
return {"error": "Invalid session"}
# Exchange code with IdP for tokens
tokens = await idp_client.exchange_code(
code=code,
redirect_uri=f"{MCP_SERVER_URL}/oauth/callback"
)
# Decode ID token to get user info
userinfo = decode_id_token(tokens.id_token)
# Create or update user account
user = await create_or_update_user(
idp_sub=userinfo.sub,
username=userinfo.preferred_username,
email=userinfo.email
)
# Generate new token family for rotation
token_family_id = str(uuid4())
# 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',
scopes=tokens.scope,
idp_subject=userinfo.sub
)
# Generate MCP session token for the client
mcp_session_token = generate_mcp_session_token(user.id)
# 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>
// 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. 401 Response with WWW-Authenticate
@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
-- User accounts (created from IdP identity)
CREATE TABLE users (
id TEXT PRIMARY KEY,
idp_sub TEXT UNIQUE NOT NULL, -- IdP subject identifier
username TEXT NOT NULL,
email TEXT,
created_at INTEGER NOT NULL,
last_login INTEGER NOT NULL
);
-- IdP tokens with rotation support
CREATE TABLE idp_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id),
token_family_id TEXT NOT NULL, -- Groups all tokens in rotation chain
encrypted_access_token BLOB NOT NULL,
encrypted_refresh_token BLOB NOT NULL,
access_expires_at INTEGER NOT NULL,
status TEXT NOT NULL CHECK(status IN ('active', 'used', 'revoked')),
scopes TEXT NOT NULL, -- Includes Nextcloud scopes
idp_subject TEXT NOT NULL, -- IdP user identifier
created_at INTEGER NOT NULL,
used_at INTEGER, -- When token was exchanged
-- Only one active token per family
UNIQUE(token_family_id, status) WHERE status = 'active'
);
-- Index for quick lookups
CREATE INDEX idx_active_tokens ON idp_tokens(user_id, status)
WHERE status = 'active';
CREATE INDEX idx_token_families ON idp_tokens(token_family_id);
-- MCP session tokens (separate from IdP tokens)
CREATE TABLE mcp_sessions (
session_token TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
last_used INTEGER
);
-- OAuth flow sessions (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
);
-- Audit log for security
CREATE TABLE token_audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
token_family_id TEXT,
operation TEXT NOT NULL, -- 'authorize', 'refresh', 'revoke', 'reuse_detected'
timestamp INTEGER NOT NULL,
ip_address TEXT,
user_agent TEXT,
details TEXT
);
5. Background Worker with IdP Token Refresh
class BackgroundSyncWorker:
"""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 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 IdP tokens for user {user_id}")
return
# Mark token as used immediately (rotation)
await self.storage.mark_token_used(tokens.id)
try:
# 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(
user_id=user_id,
token_family_id=tokens.token_family_id,
access_token=new_tokens.access_token,
refresh_token=new_tokens.refresh_token,
status='active'
)
# 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.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 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
except Exception as e:
# 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
)
6. Configuration
# Environment variables for federated setup
IDP_DISCOVERY_URL = os.getenv("IDP_DISCOVERY_URL") # e.g., https://keycloak.example.com/realms/master/.well-known/openid-configuration
MCP_SERVER_CLIENT_ID = os.getenv("MCP_SERVER_CLIENT_ID") # MCP server's client ID in IdP
MCP_SERVER_CLIENT_SECRET = os.getenv("MCP_SERVER_CLIENT_SECRET") # Client secret
MCP_SERVER_URL = os.getenv("MCP_SERVER_URL", "http://localhost:8000")
# Nextcloud configuration
NEXTCLOUD_HOST = os.getenv("NEXTCLOUD_HOST") # Nextcloud instance URL
# Parse IdP discovery document
async def setup_idp_client():
"""Initialize OAuth client from IdP discovery."""
async with httpx.AsyncClient() as client:
discovery = await client.get(IDP_DISCOVERY_URL)
discovery_doc = discovery.json()
return OAuthClient(
authorization_endpoint=discovery_doc["authorization_endpoint"],
token_endpoint=discovery_doc["token_endpoint"],
introspection_endpoint=discovery_doc.get("introspection_endpoint"),
userinfo_endpoint=discovery_doc["userinfo_endpoint"],
client_id=MCP_SERVER_CLIENT_ID,
client_secret=MCP_SERVER_CLIENT_SECRET
)
Advantages
- Single Sign-On: Users authenticate once to the shared IdP
- Federated Identity: Enterprise-ready with support for SAML, LDAP backends
- True Offline Access: Background workers operate with stored IdP refresh tokens
- OAuth Compliant: Proper delegation with on-behalf-of pattern
- Security Isolation: MCP clients never see IdP or Nextcloud credentials
- Flexible Backend: Can swap Nextcloud for other resources without changing auth
- Standard Pattern: Industry-standard federated OAuth architecture
Disadvantages
- IdP Dependency: Requires a shared identity provider infrastructure
- Complex Token Lifecycle: Managing tokens from IdP for Nextcloud access
- Token Validation Overhead: Nextcloud must validate tokens with IdP
- Migration Complexity: Existing deployments need IdP setup
Security Considerations
Token Storage
- All refresh tokens MUST be encrypted at rest (Fernet or similar)
- Database access must be restricted to the MCP server process
- Consider using hardware security modules (HSM) for production
Token Rotation
- Full rotation implemented: Each refresh creates new access AND refresh tokens
- Reuse detection: Any attempt to use an already-used token revokes the entire family
- Atomic operations: Token status updates must be atomic to prevent race conditions
- Audit logging: All token operations are logged for security analysis
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
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: IdP Setup
- Deploy shared IdP (Keycloak recommended)
- Register MCP server as OAuth client
- Configure Nextcloud to accept IdP tokens
- Test token validation flow
Phase 2: Parallel Operation
- Keep existing pass-through authentication
- Add federated auth as optional feature flag
- Test with subset of users
- Monitor token lifecycle and refresh patterns
Phase 3: Migration
- Migrate existing users to IdP accounts
- Map existing permissions to IdP scopes
- Update clients to use new OAuth flow
- Maintain backward compatibility period
Phase 4: Deprecation
- Announce end-of-life for pass-through mode
- Complete user migration
- Remove legacy authentication code
- Document new auth flow
Alternatives Considered
1. Pass-Through Only (Current)
- Pros: Simple, stateless
- Cons: No offline access possible
- Rejected: Doesn't meet requirements
2. Service Accounts (ADR-002 Tier 1)
- Pros: Simple to implement
- Cons: Violates OAuth principles, creates audit issues
- Rejected: Security and compliance concerns
3. Token Exchange (ADR-002 Tier 2)
- Pros: Standards-based (RFC 8693)
- Cons: Circular dependency, doesn't solve bootstrap problem
- Rejected: Doesn't enable true offline access
4. Sign-in with Nextcloud (Previous ADR-004)
- Pros: Direct Nextcloud integration
- Cons: Tight coupling, no enterprise IdP support
- Rejected: Not suitable for federated environments
5. Double OAuth (Manual)
- Pros: Clear separation of concerns
- Cons: Poor UX with two login prompts
- Rejected: Users shouldn't authenticate twice
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:
- Single user authentication to a trusted IdP
- Delegated access to Nextcloud resources via scoped tokens
- Offline capabilities through secure refresh token storage and rotation
- 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.