docs: WIP with Hybrid token
This commit is contained in:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user