diff --git a/docs/ADR-004-mcp-application-oauth.md b/docs/ADR-004-mcp-application-oauth.md index 276ab4b..861b2b7 100644 --- a/docs/ADR-004-mcp-application-oauth.md +++ b/docs/ADR-004-mcp-application-oauth.md @@ -111,7 +111,7 @@ The IdP (Keycloak) is configured to: ### Authentication Flows -#### Initial Setup with Linked Authorization (One-Time) +#### Initial Setup with Hybrid Flow (One-Time) ```mermaid sequenceDiagram @@ -131,11 +131,13 @@ sequenceDiagram MCPClient->>MCPServer: GET /oauth/authorize
+ code_challenge
+ redirect_uri=http://localhost:51234/callback - MCPServer->>MCPServer: Store session with PKCE - MCPServer->>MCPClient: 302 Redirect to IdP + MCPServer->>MCPServer: Store session with:
- client_redirect_uri
- code_challenge
- state + MCPServer->>MCPClient: 302 Redirect to IdP
redirect_uri=https://mcp-server.com/oauth/callback - MCPClient->>IdP: Authorization Request
+ code_challenge
+ code_challenge_method=S256 - Note over IdP: Requested scopes:
- openid profile email
- offline_access
- nextcloud:notes:*
Initial audience: mcp-server + Note over MCPServer,IdP: CRITICAL: Server's callback URL,
NOT client's! + + MCPClient->>IdP: Authorization Request
redirect_uri=https://mcp-server.com/oauth/callback + Note over IdP: Requested scopes:
- openid profile email
- offline_access
- nextcloud:notes:* IdP->>User: Login page User->>IdP: Authenticate once @@ -144,24 +146,29 @@ sequenceDiagram Note over IdP: "Allow MCP Server to:
- Authenticate you
- Access data offline
- Access Nextcloud on your behalf" User->>IdP: Grant consent - IdP->>MCPClient: 302 Redirect to localhost:51234
with authorization code + IdP->>MCPServer: 302 Redirect to MCP server
with IdP authorization code - MCPClient->>MCPServer: POST /oauth/token
code + code_verifier + Note over MCPServer: Server receives IdP code! - MCPServer->>MCPServer: Verify PKCE
(SHA256(code_verifier) == code_challenge) + MCPServer->>IdP: Exchange IdP code for tokens
+ client_secret + IdP->>MCPServer: Master tokens:
- Access token (aud: mcp-server)
- Master refresh token - MCPServer->>IdP: Exchange code for tokens
+ code_verifier - IdP->>MCPServer: Tokens with aud:"mcp-server"
+ Master refresh token + MCPServer->>MCPServer: 1. Store master refresh token (encrypted)
2. Generate MCP auth code: mcp-code-xyz
3. Link to stored code_challenge - Note over MCPServer: Received:
- Access token (aud: mcp-server)
- Master refresh token
(can mint both audiences) + MCPServer->>MCPClient: 302 Redirect to client
http://localhost:51234/callback
?code=mcp-code-xyz&state=... - MCPServer->>MCPServer: Store master refresh token
(encrypted) - MCPServer-->>MCPClient: Return access token
(aud: mcp-server) + Note over MCPClient: Client receives MCP code
(not IdP code!) - MCPClient->>MCPServer: Retry with token
(aud: mcp-server) + MCPClient->>MCPServer: POST /oauth/token
code=mcp-code-xyz
+ code_verifier + + MCPServer->>MCPServer: 1. Find session by mcp-code-xyz
2. Verify PKCE: SHA256(code_verifier) == code_challenge
3. Get stored access token from step 4 + + MCPServer-->>MCPClient: Return:
- Access token (aud: mcp-server)
- NO master refresh token!
- Optional: MCP session refresh token + + MCPClient->>MCPServer: API call with token
(aud: mcp-server) MCPServer->>MCPServer: Validate audience - Note over MCPServer: Need Nextcloud access,
use refresh token + Note over MCPServer: Need Nextcloud access,
use stored master refresh token MCPServer->>IdP: POST /token
refresh_token + audience=nextcloud IdP->>MCPServer: New token (aud: nextcloud) @@ -173,6 +180,13 @@ sequenceDiagram 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) ```mermaid @@ -195,8 +209,9 @@ sequenceDiagram alt Nextcloud Token Expired or Missing MCPServer->>IdP: POST /token
grant_type=refresh_token
audience=nextcloud - IdP->>MCPServer: New access token
(aud: nextcloud) - MCPServer->>TokenStore: Cache Nextcloud token
(short TTL) + IdP->>MCPServer: New access token ONLY
(aud: nextcloud) + Note over IdP,MCPServer: NO refresh token rotation here!
Master refresh token unchanged + MCPServer->>TokenStore: Cache Nextcloud access token
(5 min TTL) end MCPServer->>Nextcloud: API call with token
(aud: nextcloud) @@ -217,33 +232,40 @@ sequenceDiagram 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->>TokenStore: Get user's master refresh token + TokenStore-->>Worker: Encrypted master refresh token Worker->>Worker: Decrypt token - Worker->>IdP: Exchange refresh token - IdP->>Worker: New access + refresh tokens - Worker->>TokenStore: Store new tokens (status='active') + Worker->>IdP: POST /token
grant_type=refresh_token
audience=nextcloud + IdP->>Worker: New access token ONLY
(aud: nextcloud) + Note over IdP,Worker: Access token for Nextcloud
Master refresh token unchanged 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!
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 -### 1. Token Broker Verifier +### 1. Token Broker Service ```python import jwt from datetime import datetime, timedelta -class TokenBrokerVerifier(TokenVerifier): - """Token broker that maintains audience isolation between MCP and Nextcloud.""" +class TokenBrokerService: + """ + 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, token_storage: RefreshTokenStorage, @@ -252,51 +274,25 @@ class TokenBrokerVerifier(TokenVerifier): self.idp_client = idp_client 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: - """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 cached = self.nextcloud_token_cache.get(user_id) if cached and cached['exp'] > datetime.utcnow().timestamp(): 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) if not refresh_token: + logger.warning(f"No refresh token for user {user_id}") return None # User needs to re-authenticate 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( refresh_token=refresh_token, audience='nextcloud' # CRITICAL: Request Nextcloud audience @@ -327,31 +323,12 @@ class TokenBrokerVerifier(TokenVerifier): logger.error(f"Failed to get Nextcloud token: {e}") 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): - """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) if not old_refresh: 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.alert_user_possible_breach(user_id) 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) @@ -407,31 +424,29 @@ async def oauth_authorize( # Store MCP client details with PKCE session_id = str(uuid4()) - authorization_code = secrets.token_urlsafe(32) + mcp_authorization_code = f"mcp-code-{secrets.token_urlsafe(32)}" await store_oauth_session( session_id=session_id, client_id=client_id, - redirect_uri=redirect_uri, + client_redirect_uri=redirect_uri, # Store client's redirect URI state=state, code_challenge=code_challenge, 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 = { "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", "scope": "openid profile email offline_access " # Identity + offline "nextcloud:notes:read nextcloud:notes:write " # Nextcloud scopes "nextcloud:calendar:read nextcloud:calendar:write", "state": f"{session_id}:{state}", # Preserve client state - "prompt": "consent", # Ensure refresh token - # Pass PKCE to IdP if supported - "code_challenge": code_challenge, - "code_challenge_method": code_challenge_method + "prompt": "consent" # Ensure refresh token } idp_auth_url = f"{IDP_AUTHORIZATION_ENDPOINT}?{urlencode(idp_params)}" @@ -439,7 +454,10 @@ async def oauth_authorize( @app.get("/oauth/callback") 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 try: session_id, client_state = state.split(":", 1) @@ -450,13 +468,29 @@ async def oauth_callback(code: str, state: str): if not oauth_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( - code=code, + code=code, # IdP authorization code 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 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 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( user_id=user.id, token_family_id=token_family_id, - access_token=tokens.access_token, - refresh_token=tokens.refresh_token, + access_token=tokens.access_token, # Initial MCP access token + refresh_token=tokens.refresh_token, # Master refresh token! status='active', scopes=tokens.scope, idp_subject=userinfo.sub ) - # Update session with user_id for token exchange - await update_oauth_session(session_id, user_id=user.id) + # Link session to user and store the access token for later + 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 - # No HTML page! Native clients expect 302 redirect + # STEP 3: Redirect to native client with MCP-generated code + # Client will exchange this code for tokens at /oauth/token 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 } - 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) @app.post("/oauth/token") @@ -503,11 +542,14 @@ async def oauth_token( client_id: 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": - # Find session by authorization code - oauth_session = await get_oauth_session_by_code(code) + # Find session by MCP authorization code (e.g., mcp-code-xyz...) + oauth_session = await get_oauth_session_by_mcp_code(code) if not oauth_session: return JSONResponse( {"error": "invalid_grant", "error_description": "Invalid authorization code"}, @@ -534,43 +576,33 @@ async def oauth_token( ) # Verify redirect_uri matches - if redirect_uri != oauth_session.redirect_uri: + if redirect_uri != oauth_session.client_redirect_uri: return JSONResponse( {"error": "invalid_grant", "error_description": "redirect_uri mismatch"}, status_code=400 ) - # Get stored IdP tokens for this session - # These were stored during the callback from IdP - idp_tokens = await get_idp_tokens_for_session(oauth_session.session_id) + # Get the IdP access token that was stored during /oauth/callback + # This token was already obtained when the server exchanged the IdP code + idp_access_token = oauth_session.idp_access_token - # Verify the access token has MCP audience - payload = jwt.decode( - idp_tokens.access_token, - options={"verify_signature": False} - ) + # Get user's refresh token from storage (for creating response) + # But DO NOT return the master refresh token to the client! + user_tokens = await get_user_tokens(oauth_session.user_id) - audiences = payload.get('aud', []) - 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 + # Invalidate MCP authorization code (one-time use) await invalidate_oauth_session(oauth_session.session_id) - # Return IdP tokens (with aud: mcp-server) - # Client gets the actual IdP token, not an MCP-generated one + # Return tokens to client + # CRITICAL: Client gets access token but NOT the master refresh token 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", - "expires_in": idp_tokens.expires_in, - "scope": idp_tokens.scope, - "refresh_token": idp_tokens.refresh_token # Master refresh token + "expires_in": 3600, + "scope": user_tokens.scope, + # 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": @@ -617,30 +649,77 @@ async def oauth_token( ) ``` -### 3. 401 Response with WWW-Authenticate +### 3. MCP Tool Token Verification with Audience Check ```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"' +from functools import wraps + +def required_scopes(*scopes): + """ + Decorator that verifies token audience and scopes. + The existing required_scopes decorator needs to be updated + to verify audience: "mcp-server" for all incoming tokens. + """ + def decorator(func): + @wraps(func) + async def wrapper(ctx: Context, *args, **kwargs): + # Get token from context (set by FastMCP framework) + token = ctx.authorization.token if ctx.authorization else None + + if not token: + raise Unauthorized("No token provided") + + # 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 @@ -692,12 +771,13 @@ CREATE TABLE mcp_sessions ( CREATE TABLE oauth_sessions ( session_id TEXT PRIMARY KEY, client_id TEXT, - redirect_uri TEXT NOT NULL, + client_redirect_uri TEXT NOT NULL, -- Client's localhost redirect URI state TEXT, - code_challenge TEXT, -- PKCE code challenge - code_challenge_method TEXT, -- PKCE method (S256) - authorization_code TEXT UNIQUE, -- Pre-generated auth code - user_id TEXT, -- Set after IdP authentication + code_challenge TEXT, -- PKCE code challenge from client + code_challenge_method TEXT, -- PKCE method (S256) + mcp_authorization_code TEXT UNIQUE, -- MCP-generated code (e.g., mcp-code-xyz) + idp_access_token TEXT, -- Stored after IdP exchange in /oauth/callback + user_id TEXT, -- Set after IdP authentication created_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 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): self.storage = token_storage self.idp_client = OAuthClient.from_discovery(IDP_DISCOVERY_URL) self.nextcloud_url = os.getenv("NEXTCLOUD_HOST") + self.nextcloud_token_cache = {} # Short-lived cache 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: - - Workers NEVER use MCP session tokens (those are for client auth) - - Workers directly refresh IdP tokens with the IdP - - IdP tokens have audience: "nextcloud" for backend access - - No MCP client involvement required + - Workers use the master refresh token stored during OAuth flow + - Workers request access tokens with audience: "nextcloud" + - NO refresh token rotation during normal operations + - Master refresh token only rotated periodically (e.g., weekly) """ - # Get active IdP refresh token (NOT MCP token) - idp_tokens = await self.storage.get_active_tokens(user_id) - if not idp_tokens: - logger.warning(f"No active IdP tokens for user {user_id}") + # Get master refresh token (stored during initial OAuth) + master_refresh_token = await self.storage.get_refresh_token(user_id) + if not master_refresh_token: + logger.warning(f"No master refresh token for user {user_id}") return - # Mark token as used immediately (rotation) - await self.storage.mark_token_used(idp_tokens.id) - try: - # Exchange with IdP for new tokens (direct IdP communication) - new_tokens = await self.idp_client.refresh(idp_tokens.refresh_token) + # Check cache for valid Nextcloud access 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) - id_token_claims = jwt.decode( - new_tokens.id_token, - options={"verify_signature": False} - ) - if 'nextcloud' not in id_token_claims.get('aud', []): - raise ValueError("IdP token missing Nextcloud audience") + # Verify audience is for Nextcloud (security check) + payload = jwt.decode( + response.access_token, + options={"verify_signature": False} + ) - # Store new tokens in same family - await self.storage.store_tokens( - user_id=user_id, - token_family_id=idp_tokens.token_family_id, - access_token=new_tokens.access_token, - refresh_token=new_tokens.refresh_token, - status='active' - ) + audiences = payload.get('aud', []) + if isinstance(audiences, str): + audiences = [audiences] - # 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 client = NextcloudClient.from_token( base_url=self.nextcloud_url, - token=new_tokens.access_token, # IdP token, NOT MCP token - username=idp_tokens.username + token=nextcloud_token, # Access token with aud: nextcloud + username=user_id ) # Perform sync operations with Nextcloud @@ -783,25 +873,16 @@ class BackgroundSyncWorker: except HTTPStatusError as e: if e.response.status_code == 401: - # Token rejected by IdP or Nextcloud - await self.storage.revoke_token_family(idp_tokens.token_family_id) + # Access token rejected - try clearing cache + self.nextcloud_token_cache.pop(user_id, None) + # If persistent, may need to trigger re-authentication await self.log_security_event( user_id, - "token_revoked", - f"Token family {idp_tokens.token_family_id} revoked due to 401" + "access_token_rejected", + f"Nextcloud rejected access token for user {user_id}" ) 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: - # 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}") raise @@ -1137,13 +1218,29 @@ grant_type=urn:ietf:params:oauth:grant-type:token-exchange ## 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 2. **Single authentication**: Users authenticate once to the IdP 3. **Offline capabilities**: Master refresh tokens enable background operations 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. ## References