docs: WIP with Hybrid token

This commit is contained in:
Chris Coutinho
2025-11-03 01:19:46 +01:00
parent 14a8f70503
commit f48e039e9e
+308 -211
View File
@@ -111,7 +111,7 @@ The IdP (Keycloak) is configured to:
### Authentication Flows ### Authentication Flows
#### Initial Setup with Linked Authorization (One-Time) #### Initial Setup with Hybrid Flow (One-Time)
```mermaid ```mermaid
sequenceDiagram sequenceDiagram
@@ -131,11 +131,13 @@ sequenceDiagram
MCPClient->>MCPServer: GET /oauth/authorize<br/>+ code_challenge<br/>+ redirect_uri=http://localhost:51234/callback MCPClient->>MCPServer: GET /oauth/authorize<br/>+ code_challenge<br/>+ redirect_uri=http://localhost:51234/callback
MCPServer->>MCPServer: Store session with PKCE MCPServer->>MCPServer: Store session with:<br/>- client_redirect_uri<br/>- code_challenge<br/>- state
MCPServer->>MCPClient: 302 Redirect to IdP MCPServer->>MCPClient: 302 Redirect to IdP<br/>redirect_uri=https://mcp-server.com/oauth/callback
MCPClient->>IdP: Authorization Request<br/>+ code_challenge<br/>+ code_challenge_method=S256 Note over MCPServer,IdP: CRITICAL: Server's callback URL,<br/>NOT client's!
Note over IdP: Requested scopes:<br/>- openid profile email<br/>- offline_access<br/>- nextcloud:notes:*<br/>Initial audience: mcp-server
MCPClient->>IdP: Authorization Request<br/>redirect_uri=https://mcp-server.com/oauth/callback
Note over IdP: Requested scopes:<br/>- openid profile email<br/>- offline_access<br/>- nextcloud:notes:*
IdP->>User: Login page IdP->>User: Login page
User->>IdP: Authenticate once User->>IdP: Authenticate once
@@ -144,24 +146,29 @@ sequenceDiagram
Note over IdP: "Allow MCP Server to:<br/>- Authenticate you<br/>- Access data offline<br/>- Access Nextcloud on your behalf" Note over IdP: "Allow MCP Server to:<br/>- Authenticate you<br/>- Access data offline<br/>- Access Nextcloud on your behalf"
User->>IdP: Grant consent User->>IdP: Grant consent
IdP->>MCPClient: 302 Redirect to localhost:51234<br/>with authorization code IdP->>MCPServer: 302 Redirect to MCP server<br/>with IdP authorization code
MCPClient->>MCPServer: POST /oauth/token<br/>code + code_verifier Note over MCPServer: Server receives IdP code!
MCPServer->>MCPServer: Verify PKCE<br/>(SHA256(code_verifier) == code_challenge) MCPServer->>IdP: Exchange IdP code for tokens<br/>+ client_secret
IdP->>MCPServer: Master tokens:<br/>- Access token (aud: mcp-server)<br/>- Master refresh token
MCPServer->>IdP: Exchange code for tokens<br/>+ code_verifier MCPServer->>MCPServer: 1. Store master refresh token (encrypted)<br/>2. Generate MCP auth code: mcp-code-xyz<br/>3. Link to stored code_challenge
IdP->>MCPServer: Tokens with aud:"mcp-server"<br/>+ Master refresh token
Note over MCPServer: Received:<br/>- Access token (aud: mcp-server)<br/>- Master refresh token<br/>(can mint both audiences) MCPServer->>MCPClient: 302 Redirect to client<br/>http://localhost:51234/callback<br/>?code=mcp-code-xyz&state=...
MCPServer->>MCPServer: Store master refresh token<br/>(encrypted) Note over MCPClient: Client receives MCP code<br/>(not IdP code!)
MCPServer-->>MCPClient: Return access token<br/>(aud: mcp-server)
MCPClient->>MCPServer: Retry with token<br/>(aud: mcp-server) MCPClient->>MCPServer: POST /oauth/token<br/>code=mcp-code-xyz<br/>+ code_verifier
MCPServer->>MCPServer: 1. Find session by mcp-code-xyz<br/>2. Verify PKCE: SHA256(code_verifier) == code_challenge<br/>3. Get stored access token from step 4
MCPServer-->>MCPClient: Return:<br/>- Access token (aud: mcp-server)<br/>- NO master refresh token!<br/>- Optional: MCP session refresh token
MCPClient->>MCPServer: API call with token<br/>(aud: mcp-server)
MCPServer->>MCPServer: Validate audience MCPServer->>MCPServer: Validate audience
Note over MCPServer: Need Nextcloud access,<br/>use refresh token Note over MCPServer: Need Nextcloud access,<br/>use stored master refresh token
MCPServer->>IdP: POST /token<br/>refresh_token + audience=nextcloud MCPServer->>IdP: POST /token<br/>refresh_token + audience=nextcloud
IdP->>MCPServer: New token (aud: nextcloud) IdP->>MCPServer: New token (aud: nextcloud)
@@ -173,6 +180,13 @@ sequenceDiagram
MCPServer-->>MCPClient: Success MCPServer-->>MCPClient: Success
``` ```
**Key Changes in the Hybrid Flow:**
1. **Server Intercepts Code**: The IdP redirects to the MCP server's `/oauth/callback`, not the client's
2. **Token Swap**: The server exchanges the IdP code for master tokens and stores them
3. **Client Handoff**: The server generates its own code (`mcp-code-xyz`) and redirects the client with it
4. **PKCE Completion**: The client exchanges the server's code using the original code_verifier
5. **Master Token Protection**: The client never receives the master refresh token
#### Subsequent MCP Sessions (Token Broker Pattern) #### Subsequent MCP Sessions (Token Broker Pattern)
```mermaid ```mermaid
@@ -195,8 +209,9 @@ sequenceDiagram
alt Nextcloud Token Expired or Missing alt Nextcloud Token Expired or Missing
MCPServer->>IdP: POST /token<br/>grant_type=refresh_token<br/>audience=nextcloud MCPServer->>IdP: POST /token<br/>grant_type=refresh_token<br/>audience=nextcloud
IdP->>MCPServer: New access token<br/>(aud: nextcloud) IdP->>MCPServer: New access token ONLY<br/>(aud: nextcloud)
MCPServer->>TokenStore: Cache Nextcloud token<br/>(short TTL) Note over IdP,MCPServer: NO refresh token rotation here!<br/>Master refresh token unchanged
MCPServer->>TokenStore: Cache Nextcloud access token<br/>(5 min TTL)
end end
MCPServer->>Nextcloud: API call with token<br/>(aud: nextcloud) MCPServer->>Nextcloud: API call with token<br/>(aud: nextcloud)
@@ -217,33 +232,40 @@ sequenceDiagram
participant IdP as Shared IdP participant IdP as Shared IdP
participant Nextcloud participant Nextcloud
Worker->>TokenStore: Get user's active refresh token Worker->>TokenStore: Get user's master refresh token
TokenStore-->>Worker: Encrypted IdP refresh token TokenStore-->>Worker: Encrypted master refresh token
Worker->>TokenStore: Mark token as 'used'
Worker->>Worker: Decrypt token Worker->>Worker: Decrypt token
Worker->>IdP: Exchange refresh token Worker->>IdP: POST /token<br/>grant_type=refresh_token<br/>audience=nextcloud
IdP->>Worker: New access + refresh tokens IdP->>Worker: New access token ONLY<br/>(aud: nextcloud)
Worker->>TokenStore: Store new tokens (status='active') Note over IdP,Worker: Access token for Nextcloud<br/>Master refresh token unchanged
Worker->>Nextcloud: API call with access token Worker->>Nextcloud: API call with access token
Nextcloud->>IdP: Validate token Nextcloud->>IdP: Validate token
IdP-->>Nextcloud: Valid + scopes IdP-->>Nextcloud: Valid + scopes
Nextcloud-->>Worker: API response Nextcloud-->>Worker: API response
Note over Worker: No MCP client involvement! Note over Worker: No MCP client involvement!<br/>No refresh token rotation!
``` ```
**Token Rotation Strategy:**
- **Access Tokens**: Refreshed frequently (every 5-60 minutes) as needed
- **Master Refresh Token**: Only rotated periodically (e.g., daily/weekly) or when explicitly refreshing the MCP session
- **Separation**: Getting Nextcloud access tokens does NOT rotate the master refresh token
## Implementation ## Implementation
### 1. Token Broker Verifier ### 1. Token Broker Service
```python ```python
import jwt import jwt
from datetime import datetime, timedelta from datetime import datetime, timedelta
class TokenBrokerVerifier(TokenVerifier): class TokenBrokerService:
"""Token broker that maintains audience isolation between MCP and Nextcloud.""" """
Token broker that exchanges master refresh tokens for audience-specific access tokens.
Works alongside the required_scopes decorator which handles MCP token validation.
"""
def __init__(self, def __init__(self,
token_storage: RefreshTokenStorage, token_storage: RefreshTokenStorage,
@@ -252,51 +274,25 @@ class TokenBrokerVerifier(TokenVerifier):
self.idp_client = idp_client self.idp_client = idp_client
self.nextcloud_token_cache = {} # Short-lived cache self.nextcloud_token_cache = {} # Short-lived cache
async def verify_mcp_token(self, token: str) -> dict | None:
"""Verify IdP-issued token has MCP server audience."""
try:
# Decode without verification (IdP signed it)
# In production, verify with IdP public key
payload = jwt.decode(
token,
options={"verify_signature": False}
)
# CRITICAL: Verify audience is MCP server
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'mcp-server' not in audiences:
logger.warning(f"Token rejected: wrong audience {audiences}")
return None # Not for MCP server
# Check expiry
if payload.get('exp', 0) < datetime.utcnow().timestamp():
return None
return {
'user_id': payload['sub'],
'session_id': payload.get('jti'),
'scopes': payload.get('scope', '').split()
}
except jwt.InvalidTokenError:
return None
async def get_nextcloud_token(self, user_id: str) -> str | None: async def get_nextcloud_token(self, user_id: str) -> str | None:
"""Get or refresh token with Nextcloud audience.""" """
Get or refresh token with Nextcloud audience.
Called AFTER the required_scopes decorator has validated the MCP token.
"""
# Check cache first # Check cache first
cached = self.nextcloud_token_cache.get(user_id) cached = self.nextcloud_token_cache.get(user_id)
if cached and cached['exp'] > datetime.utcnow().timestamp(): if cached and cached['exp'] > datetime.utcnow().timestamp():
return cached['token'] return cached['token']
# Get master refresh token # Get master refresh token (stored during OAuth flow)
refresh_token = await self.storage.get_refresh_token(user_id) refresh_token = await self.storage.get_refresh_token(user_id)
if not refresh_token: if not refresh_token:
logger.warning(f"No refresh token for user {user_id}")
return None # User needs to re-authenticate return None # User needs to re-authenticate
try: try:
# Request new token with Nextcloud audience # Request new ACCESS token with Nextcloud audience
# This does NOT rotate the master refresh token!
response = await self.idp_client.refresh_token( response = await self.idp_client.refresh_token(
refresh_token=refresh_token, refresh_token=refresh_token,
audience='nextcloud' # CRITICAL: Request Nextcloud audience audience='nextcloud' # CRITICAL: Request Nextcloud audience
@@ -327,31 +323,12 @@ class TokenBrokerVerifier(TokenVerifier):
logger.error(f"Failed to get Nextcloud token: {e}") logger.error(f"Failed to get Nextcloud token: {e}")
return None return None
async def verify_token(self, token: str) -> AccessToken | None:
"""Main verification for MCP protocol with token brokering."""
# Step 1: Verify token has MCP audience
mcp_auth = await self.verify_mcp_token(token)
if not mcp_auth:
return None # Triggers 401 response
# Step 2: Get separate token for Nextcloud access
nextcloud_token = await self.get_nextcloud_token(mcp_auth['user_id'])
if not nextcloud_token:
return None # Failed to get backend token
# Return Nextcloud token for backend use
# MCP client never sees this token
return AccessToken(
token=nextcloud_token, # Token with aud: nextcloud
scopes=mcp_auth['scopes'],
resource=json.dumps({
"user_id": mcp_auth['user_id'],
"session_id": mcp_auth.get('session_id')
})
)
async def refresh_master_token(self, user_id: str): async def refresh_master_token(self, user_id: str):
"""Refresh the master refresh token (with rotation).""" """
Refresh the master refresh token (with rotation).
This should only be called periodically (e.g., weekly) or when
explicitly refreshing the MCP session, NOT on every API call.
"""
old_refresh = await self.storage.get_refresh_token(user_id) old_refresh = await self.storage.get_refresh_token(user_id)
if not old_refresh: if not old_refresh:
raise ValueError("No refresh token found") raise ValueError("No refresh token found")
@@ -381,6 +358,46 @@ class TokenBrokerVerifier(TokenVerifier):
await self.storage.revoke_token_family(old_refresh.token_family_id) await self.storage.revoke_token_family(old_refresh.token_family_id)
await self.alert_user_possible_breach(user_id) await self.alert_user_possible_breach(user_id)
raise raise
# Integration with FastMCP framework
class MCPTokenVerifier(TokenVerifier):
"""
Simple verifier that checks audience for MCP tokens.
Used by FastMCP framework alongside required_scopes decorator.
"""
async def verify_token(self, token: str) -> AccessToken | None:
"""Verify token has correct audience for MCP server."""
try:
payload = jwt.decode(
token,
options={"verify_signature": False} # IdP handles signature
)
# CRITICAL: Verify audience is MCP server
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'mcp-server' not in audiences:
logger.warning(f"Token rejected: wrong audience {audiences}")
return None
# Check expiry
if payload.get('exp', 0) < datetime.utcnow().timestamp():
return None
return AccessToken(
token=token, # Keep original MCP token
scopes=payload.get('scope', '').split(),
resource=json.dumps({
"user_id": payload['sub'],
"session_id": payload.get('jti')
})
)
except jwt.InvalidTokenError:
return None
``` ```
### 2. OAuth Endpoints with PKCE (Native Client Support) ### 2. OAuth Endpoints with PKCE (Native Client Support)
@@ -407,31 +424,29 @@ async def oauth_authorize(
# Store MCP client details with PKCE # Store MCP client details with PKCE
session_id = str(uuid4()) session_id = str(uuid4())
authorization_code = secrets.token_urlsafe(32) mcp_authorization_code = f"mcp-code-{secrets.token_urlsafe(32)}"
await store_oauth_session( await store_oauth_session(
session_id=session_id, session_id=session_id,
client_id=client_id, client_id=client_id,
redirect_uri=redirect_uri, client_redirect_uri=redirect_uri, # Store client's redirect URI
state=state, state=state,
code_challenge=code_challenge, code_challenge=code_challenge,
code_challenge_method=code_challenge_method, code_challenge_method=code_challenge_method,
authorization_code=authorization_code # Pre-generate for later mcp_authorization_code=mcp_authorization_code # Pre-generate MCP code
) )
# Build IdP authorization URL with all needed scopes # Build IdP authorization URL
# CRITICAL: Use MCP server's callback URL, NOT the client's!
idp_params = { idp_params = {
"client_id": MCP_SERVER_CLIENT_ID, "client_id": MCP_SERVER_CLIENT_ID,
"redirect_uri": f"{MCP_SERVER_URL}/oauth/callback", "redirect_uri": f"{MCP_SERVER_URL}/oauth/callback", # Server's callback!
"response_type": "code", "response_type": "code",
"scope": "openid profile email offline_access " # Identity + offline "scope": "openid profile email offline_access " # Identity + offline
"nextcloud:notes:read nextcloud:notes:write " # Nextcloud scopes "nextcloud:notes:read nextcloud:notes:write " # Nextcloud scopes
"nextcloud:calendar:read nextcloud:calendar:write", "nextcloud:calendar:read nextcloud:calendar:write",
"state": f"{session_id}:{state}", # Preserve client state "state": f"{session_id}:{state}", # Preserve client state
"prompt": "consent", # Ensure refresh token "prompt": "consent" # Ensure refresh token
# Pass PKCE to IdP if supported
"code_challenge": code_challenge,
"code_challenge_method": code_challenge_method
} }
idp_auth_url = f"{IDP_AUTHORIZATION_ENDPOINT}?{urlencode(idp_params)}" idp_auth_url = f"{IDP_AUTHORIZATION_ENDPOINT}?{urlencode(idp_params)}"
@@ -439,7 +454,10 @@ async def oauth_authorize(
@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 IdP callback and redirect to native client.""" """
Handle IdP callback - the server receives the IdP code!
This is the CRITICAL difference in the Hybrid Flow.
"""
# Extract session ID and original client state # Extract session ID and original client state
try: try:
session_id, client_state = state.split(":", 1) session_id, client_state = state.split(":", 1)
@@ -450,13 +468,29 @@ async def oauth_callback(code: str, state: str):
if not oauth_session: if not oauth_session:
return {"error": "invalid_session"} return {"error": "invalid_session"}
# Exchange code with IdP for tokens # STEP 1: Exchange IdP code for master tokens
# The server gets the master refresh token!
tokens = await idp_client.exchange_code( tokens = await idp_client.exchange_code(
code=code, code=code, # IdP authorization code
redirect_uri=f"{MCP_SERVER_URL}/oauth/callback", redirect_uri=f"{MCP_SERVER_URL}/oauth/callback",
code_verifier=oauth_session.get('code_verifier') # If IdP supports PKCE client_id=MCP_SERVER_CLIENT_ID,
client_secret=MCP_SERVER_CLIENT_SECRET # Server has client secret
) )
# Verify the access token has correct audience
payload = jwt.decode(
tokens.access_token,
options={"verify_signature": False}
)
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'mcp-server' not in audiences:
logger.error(f"IdP returned token with wrong audience: {audiences}")
return {"error": "invalid_token", "error_description": "Wrong audience"}
# Decode ID token to get user info # Decode ID token to get user info
userinfo = decode_id_token(tokens.id_token) userinfo = decode_id_token(tokens.id_token)
@@ -470,28 +504,33 @@ async def oauth_callback(code: str, state: str):
# Generate new token family for rotation # Generate new token family for rotation
token_family_id = str(uuid4()) token_family_id = str(uuid4())
# Store IdP tokens (these have Nextcloud scopes) # STEP 2: Store master tokens (encrypted)
# These are the IdP tokens with offline_access!
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, # Initial MCP access token
refresh_token=tokens.refresh_token, refresh_token=tokens.refresh_token, # Master refresh token!
status='active', status='active',
scopes=tokens.scope, scopes=tokens.scope,
idp_subject=userinfo.sub idp_subject=userinfo.sub
) )
# Update session with user_id for token exchange # Link session to user and store the access token for later
await update_oauth_session(session_id, user_id=user.id) await update_oauth_session(
session_id,
user_id=user.id,
idp_access_token=tokens.access_token # Store for /oauth/token endpoint
)
# CRITICAL: Redirect to native client with authorization code # STEP 3: Redirect to native client with MCP-generated code
# No HTML page! Native clients expect 302 redirect # Client will exchange this code for tokens at /oauth/token
redirect_params = { redirect_params = {
"code": oauth_session.authorization_code, "code": oauth_session.mcp_authorization_code, # MCP code, NOT IdP code!
"state": client_state # Return original client state "state": client_state # Return original client state
} }
redirect_url = f"{oauth_session.redirect_uri}?{urlencode(redirect_params)}" redirect_url = f"{oauth_session.client_redirect_uri}?{urlencode(redirect_params)}"
return RedirectResponse(redirect_url, status_code=302) return RedirectResponse(redirect_url, status_code=302)
@app.post("/oauth/token") @app.post("/oauth/token")
@@ -503,11 +542,14 @@ async def oauth_token(
client_id: str = Form(None), client_id: str = Form(None),
refresh_token: str = Form(None) refresh_token: str = Form(None)
): ):
"""Token endpoint that returns IdP tokens with MCP audience.""" """
Token endpoint - client exchanges MCP code for tokens.
CRITICAL: The client sends the MCP-generated code, NOT the IdP code!
"""
if grant_type == "authorization_code": if grant_type == "authorization_code":
# Find session by authorization code # Find session by MCP authorization code (e.g., mcp-code-xyz...)
oauth_session = await get_oauth_session_by_code(code) oauth_session = await get_oauth_session_by_mcp_code(code)
if not oauth_session: if not oauth_session:
return JSONResponse( return JSONResponse(
{"error": "invalid_grant", "error_description": "Invalid authorization code"}, {"error": "invalid_grant", "error_description": "Invalid authorization code"},
@@ -534,43 +576,33 @@ async def oauth_token(
) )
# Verify redirect_uri matches # Verify redirect_uri matches
if redirect_uri != oauth_session.redirect_uri: if redirect_uri != oauth_session.client_redirect_uri:
return JSONResponse( return JSONResponse(
{"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, {"error": "invalid_grant", "error_description": "redirect_uri mismatch"},
status_code=400 status_code=400
) )
# Get stored IdP tokens for this session # Get the IdP access token that was stored during /oauth/callback
# These were stored during the callback from IdP # This token was already obtained when the server exchanged the IdP code
idp_tokens = await get_idp_tokens_for_session(oauth_session.session_id) idp_access_token = oauth_session.idp_access_token
# Verify the access token has MCP audience # Get user's refresh token from storage (for creating response)
payload = jwt.decode( # But DO NOT return the master refresh token to the client!
idp_tokens.access_token, user_tokens = await get_user_tokens(oauth_session.user_id)
options={"verify_signature": False}
)
audiences = payload.get('aud', []) # Invalidate MCP authorization code (one-time use)
if isinstance(audiences, str):
audiences = [audiences]
if 'mcp-server' not in audiences:
return JSONResponse(
{"error": "invalid_grant", "error_description": "Token missing MCP audience"},
status_code=400
)
# Invalidate authorization code
await invalidate_oauth_session(oauth_session.session_id) await invalidate_oauth_session(oauth_session.session_id)
# Return IdP tokens (with aud: mcp-server) # Return tokens to client
# Client gets the actual IdP token, not an MCP-generated one # CRITICAL: Client gets access token but NOT the master refresh token
return { return {
"access_token": idp_tokens.access_token, # IdP token with aud: mcp-server "access_token": idp_access_token, # IdP token with aud: mcp-server
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": idp_tokens.expires_in, "expires_in": 3600,
"scope": idp_tokens.scope, "scope": user_tokens.scope,
"refresh_token": idp_tokens.refresh_token # Master refresh token # Optional: Return an MCP session refresh token (NOT the master token!)
# This allows the client to refresh without re-auth
"refresh_token": await generate_mcp_session_refresh_token(oauth_session.user_id)
} }
elif grant_type == "refresh_token": elif grant_type == "refresh_token":
@@ -617,30 +649,77 @@ async def oauth_token(
) )
``` ```
### 3. 401 Response with WWW-Authenticate ### 3. MCP Tool Token Verification with Audience Check
```python ```python
@mcp.tool() from functools import wraps
async def list_notes(ctx: Context) -> dict:
"""List notes - automatically triggers OAuth if needed.""" def required_scopes(*scopes):
try: """
# FastMCP automatically calls token verifier Decorator that verifies token audience and scopes.
# If it returns None, a 401 is sent The existing required_scopes decorator needs to be updated
client = get_client_from_context(ctx) to verify audience: "mcp-server" for all incoming tokens.
notes = await client.notes.list_notes() """
return {"notes": notes} def decorator(func):
except Unauthorized: @wraps(func)
# Return 401 with WWW-Authenticate header async def wrapper(ctx: Context, *args, **kwargs):
raise HTTPException( # Get token from context (set by FastMCP framework)
status_code=401, token = ctx.authorization.token if ctx.authorization else None
headers={
"WWW-Authenticate": ( if not token:
f'Bearer realm="{MCP_SERVER_URL}/oauth/authorize", ' raise Unauthorized("No token provided")
f'error="invalid_token", '
f'error_description="Authentication required"' # Decode and verify audience
try:
payload = jwt.decode(
token,
options={"verify_signature": False} # IdP handles signature
) )
}
) # CRITICAL: Verify token is for MCP server
audiences = payload.get('aud', [])
if isinstance(audiences, str):
audiences = [audiences]
if 'mcp-server' not in audiences:
raise Unauthorized(f"Invalid audience: {audiences}")
# Verify required scopes
token_scopes = set(payload.get('scope', '').split())
required = set(scopes)
if not required.issubset(token_scopes):
missing = required - token_scopes
raise Forbidden(f"Missing scopes: {missing}")
# Token is valid for MCP server with required scopes
return await func(ctx, *args, **kwargs)
except jwt.InvalidTokenError as e:
raise Unauthorized(f"Invalid token: {e}")
return wrapper
return decorator
# Example usage in MCP tools
@mcp.tool()
@required_scopes("notes:read")
async def list_notes(ctx: Context) -> dict:
"""List notes - token audience and scopes are automatically verified."""
# Token already verified to have audience: "mcp-server"
# Now get Nextcloud token for backend access
token_broker = get_token_broker(ctx)
nextcloud_token = await token_broker.get_nextcloud_token(ctx.user_id)
# Use Nextcloud token for API access
client = NextcloudClient.from_token(
base_url=NEXTCLOUD_HOST,
token=nextcloud_token # Token with aud: "nextcloud"
)
notes = await client.notes.list_notes()
return {"notes": notes}
``` ```
### 4. Token Storage Schema ### 4. Token Storage Schema
@@ -692,12 +771,13 @@ CREATE TABLE mcp_sessions (
CREATE TABLE oauth_sessions ( CREATE TABLE oauth_sessions (
session_id TEXT PRIMARY KEY, session_id TEXT PRIMARY KEY,
client_id TEXT, client_id TEXT,
redirect_uri TEXT NOT NULL, client_redirect_uri TEXT NOT NULL, -- Client's localhost redirect URI
state TEXT, state TEXT,
code_challenge TEXT, -- PKCE code challenge code_challenge TEXT, -- PKCE code challenge from client
code_challenge_method TEXT, -- PKCE method (S256) code_challenge_method TEXT, -- PKCE method (S256)
authorization_code TEXT UNIQUE, -- Pre-generated auth code mcp_authorization_code TEXT UNIQUE, -- MCP-generated code (e.g., mcp-code-xyz)
user_id TEXT, -- Set after IdP authentication idp_access_token TEXT, -- Stored after IdP exchange in /oauth/callback
user_id TEXT, -- Set after IdP authentication
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL expires_at INTEGER NOT NULL
); );
@@ -715,63 +795,73 @@ CREATE TABLE token_audit_log (
); );
``` ```
### 5. Background Worker (IdP Tokens Only) ### 5. Background Worker (Access Token Only)
```python ```python
class BackgroundSyncWorker: class BackgroundSyncWorker:
"""Background workers use IdP tokens directly - no MCP session tokens.""" """Background workers use master refresh token to get Nextcloud access 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.idp_client = OAuthClient.from_discovery(IDP_DISCOVERY_URL)
self.nextcloud_url = os.getenv("NEXTCLOUD_HOST") self.nextcloud_url = os.getenv("NEXTCLOUD_HOST")
self.nextcloud_token_cache = {} # Short-lived cache
async def sync_user_data(self, user_id: str): async def sync_user_data(self, user_id: str):
""" """
Sync data using IdP tokens ONLY. Sync data using master refresh token to get Nextcloud access tokens.
Key Points: Key Points:
- Workers NEVER use MCP session tokens (those are for client auth) - Workers use the master refresh token stored during OAuth flow
- Workers directly refresh IdP tokens with the IdP - Workers request access tokens with audience: "nextcloud"
- IdP tokens have audience: "nextcloud" for backend access - NO refresh token rotation during normal operations
- No MCP client involvement required - Master refresh token only rotated periodically (e.g., weekly)
""" """
# Get active IdP refresh token (NOT MCP token) # Get master refresh token (stored during initial OAuth)
idp_tokens = await self.storage.get_active_tokens(user_id) master_refresh_token = await self.storage.get_refresh_token(user_id)
if not idp_tokens: if not master_refresh_token:
logger.warning(f"No active IdP tokens for user {user_id}") logger.warning(f"No master refresh token for user {user_id}")
return return
# Mark token as used immediately (rotation)
await self.storage.mark_token_used(idp_tokens.id)
try: try:
# Exchange with IdP for new tokens (direct IdP communication) # Check cache for valid Nextcloud access token
new_tokens = await self.idp_client.refresh(idp_tokens.refresh_token) cached = self.nextcloud_token_cache.get(user_id)
if cached and cached['exp'] > datetime.utcnow().timestamp():
nextcloud_token = cached['token']
else:
# Get new ACCESS token with Nextcloud audience
# This does NOT rotate the refresh token!
response = await self.idp_client.refresh_token(
refresh_token=master_refresh_token,
audience='nextcloud' # Request Nextcloud audience
)
# Verify audience is for Nextcloud (security check) # Verify audience is for Nextcloud (security check)
id_token_claims = jwt.decode( payload = jwt.decode(
new_tokens.id_token, response.access_token,
options={"verify_signature": False} options={"verify_signature": False}
) )
if 'nextcloud' not in id_token_claims.get('aud', []):
raise ValueError("IdP token missing Nextcloud audience")
# Store new tokens in same family audiences = payload.get('aud', [])
await self.storage.store_tokens( if isinstance(audiences, str):
user_id=user_id, audiences = [audiences]
token_family_id=idp_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 if 'nextcloud' not in audiences:
raise ValueError(f"IdP returned wrong audience: {audiences}")
# Cache the access token for 5 minutes
self.nextcloud_token_cache[user_id] = {
'token': response.access_token,
'exp': payload.get('exp', 0)
}
nextcloud_token = response.access_token
# Create Nextcloud client with access token
# Token has audience: "nextcloud" and proper scopes # Token has audience: "nextcloud" and proper scopes
client = NextcloudClient.from_token( client = NextcloudClient.from_token(
base_url=self.nextcloud_url, base_url=self.nextcloud_url,
token=new_tokens.access_token, # IdP token, NOT MCP token token=nextcloud_token, # Access token with aud: nextcloud
username=idp_tokens.username username=user_id
) )
# Perform sync operations with Nextcloud # Perform sync operations with Nextcloud
@@ -783,25 +873,16 @@ class BackgroundSyncWorker:
except HTTPStatusError as e: except HTTPStatusError as e:
if e.response.status_code == 401: if e.response.status_code == 401:
# Token rejected by IdP or Nextcloud # Access token rejected - try clearing cache
await self.storage.revoke_token_family(idp_tokens.token_family_id) self.nextcloud_token_cache.pop(user_id, None)
# If persistent, may need to trigger re-authentication
await self.log_security_event( await self.log_security_event(
user_id, user_id,
"token_revoked", "access_token_rejected",
f"Token family {idp_tokens.token_family_id} revoked due to 401" f"Nextcloud rejected access token for user {user_id}"
) )
raise raise
except RefreshTokenReuseError:
# Detected token reuse - possible security breach
await self.log_security_event(
user_id,
"reuse_detected",
f"Token reuse detected for family {idp_tokens.token_family_id}"
)
raise
except Exception as e: except Exception as e:
# Revert token status on failure
await self.storage.revert_token_status(idp_tokens.id)
logger.error(f"Background sync failed for user {user_id}: {e}") logger.error(f"Background sync failed for user {user_id}: {e}")
raise raise
@@ -1137,13 +1218,29 @@ grant_type=urn:ietf:params:oauth:grant-type:token-exchange
## Decision Outcome ## Decision Outcome
The Token Broker Architecture with Audience Isolation provides a secure, enterprise-ready solution for offline access while maintaining strict security boundaries. By using a shared identity provider with audience-specific tokens, we achieve: The Token Broker Architecture with **Hybrid Flow** and Audience Isolation provides a secure, enterprise-ready solution for offline access while maintaining strict security boundaries. By using a shared identity provider with audience-specific tokens, we achieve:
1. **Security through isolation**: Different audiences prevent token misuse 1. **Security through isolation**: Different audiences prevent token misuse
2. **Single authentication**: Users authenticate once to the IdP 2. **Single authentication**: Users authenticate once to the IdP
3. **Offline capabilities**: Master refresh tokens enable background operations 3. **Offline capabilities**: Master refresh tokens enable background operations
4. **Enterprise compliance**: Follows OAuth best practices and security standards 4. **Enterprise compliance**: Follows OAuth best practices and security standards
### Key Implementation: The Hybrid Flow
The **Hybrid Flow** solves the critical problem of getting the master refresh token to the server while maintaining PKCE security for the client:
1. **Server Intercepts Code**: The IdP redirects to the MCP server's `/oauth/callback`, not the client's
2. **Server Gets Master Token**: The server exchanges the IdP code for the master refresh token and stores it
3. **Client Handoff**: The server generates its own authorization code and redirects the client
4. **PKCE Completion**: The client exchanges the server's code using the original PKCE verifier
5. **Token Protection**: The client never sees or handles the master refresh token
### Token Lifecycle Clarification
- **Access Token Refresh**: Happens frequently (every 5-60 minutes) without rotating the master refresh token
- **Master Refresh Token**: Only rotated periodically (e.g., weekly) or during explicit session refresh
- **Audience Separation**: Each token request specifies the target audience (mcp-server or nextcloud)
This architecture follows industry best practices for federated systems and positions the MCP server as a secure token broker in an enterprise identity ecosystem. This architecture follows industry best practices for federated systems and positions the MCP server as a secure token broker in an enterprise identity ecosystem.
## References ## References