feat: Implement RFC 8693 token exchange for Keycloak (ADR-002 Tier 2)
Implements OAuth 2.0 Token Exchange (RFC 8693) enabling the MCP server to exchange service account tokens for user-scoped tokens. This provides an alternative to refresh tokens for background operations. **Core Implementation:** - Added `get_service_account_token()` method to KeycloakOAuthClient for client_credentials grant - Added `exchange_token_for_user()` method implementing RFC 8693 token exchange - Fixed Fernet encryption key handling in RefreshTokenStorage (was incorrectly base64 decoding already-encoded keys) - Updated OAuth configuration to support offline_access scope and refresh token storage infrastructure **Keycloak Configuration:** - Enabled `serviceAccountsEnabled` in realm-export.json - Added `token.exchange.grant.enabled` attribute - Added `client.token.exchange.standard.enabled` attribute (required for Keycloak 26.2+ Standard Token Exchange V2) - Fresh Keycloak imports now correctly enable token exchange **Docker Compose:** - Added TOKEN_ENCRYPTION_KEY and ENABLE_OFFLINE_ACCESS environment variables - Created oauth-tokens volume for refresh token storage - Configured both mcp-oauth and mcp-keycloak services **Testing & Documentation:** - Added tests/manual/test_token_exchange.py - Validates complete RFC 8693 flow - Added tests/manual/test_nextcloud_impersonate.py - Documents session-based impersonation limitations - Added docs/oauth-impersonation-findings.md - Comprehensive investigation findings and resolution documentation **Verified Working:** ✅ Service account token acquisition (client_credentials grant) ✅ RFC 8693 token exchange for internal-to-internal tokens ✅ Exchanged tokens validate with Nextcloud APIs ✅ Keycloak 26.4.2 Standard Token Exchange V2 support **Known Limitations:** - User impersonation (requested_subject) requires Keycloak Legacy V1 with preview features - Cross-client token exchange limited to same realm - Refresh token storage infrastructure ready but unused (MCP protocol limitation) Dependencies: aiosqlite>=0.20.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+133
-55
@@ -3,6 +3,10 @@ import os
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Optional
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@@ -208,6 +212,9 @@ class OAuthAppContext:
|
||||
|
||||
nextcloud_host: str
|
||||
token_verifier: NextcloudTokenVerifier
|
||||
refresh_token_storage: Optional["RefreshTokenStorage"] = None
|
||||
oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient
|
||||
oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak"
|
||||
|
||||
|
||||
def is_oauth_mode() -> bool:
|
||||
@@ -306,6 +313,17 @@ async def load_oauth_client_credentials(
|
||||
"sharing:read sharing:write"
|
||||
)
|
||||
scopes = os.getenv("NEXTCLOUD_OIDC_SCOPES", default_scopes)
|
||||
|
||||
# Add offline_access scope if refresh tokens are enabled
|
||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
if enable_offline_access and "offline_access" not in scopes:
|
||||
scopes = f"{scopes} offline_access"
|
||||
logger.info("✓ offline_access scope enabled for refresh tokens")
|
||||
|
||||
logger.info(f"Requesting OAuth scopes: {scopes}")
|
||||
|
||||
# Get token type from environment (Bearer or jwt)
|
||||
@@ -372,68 +390,42 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]:
|
||||
"""
|
||||
Manage application lifecycle for OAuth mode.
|
||||
|
||||
Initializes OAuth client registration and token verifier.
|
||||
Uses pre-initialized OAuth configuration from setup_oauth_config().
|
||||
Does NOT create a Nextcloud client - clients are created per-request.
|
||||
"""
|
||||
logger.info("Starting MCP server in OAuth mode")
|
||||
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
raise ValueError("NEXTCLOUD_HOST environment variable is required")
|
||||
# Get pre-initialized OAuth context from server dependencies
|
||||
oauth_ctx = server.dependencies
|
||||
|
||||
nextcloud_host = nextcloud_host.rstrip("/")
|
||||
nextcloud_host = oauth_ctx["nextcloud_host"]
|
||||
token_verifier = oauth_ctx["token_verifier"]
|
||||
refresh_token_storage = oauth_ctx["refresh_token_storage"]
|
||||
oauth_client = oauth_ctx["oauth_client"]
|
||||
oauth_provider = oauth_ctx["oauth_provider"]
|
||||
|
||||
# Get OAuth discovery endpoint
|
||||
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
|
||||
logger.info(f"Using OAuth provider: {oauth_provider}")
|
||||
if refresh_token_storage:
|
||||
logger.info("Refresh token storage is available")
|
||||
if oauth_client:
|
||||
logger.info("OAuth client is available for token refresh")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
try:
|
||||
# Fetch OIDC discovery
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
discovery = response.json()
|
||||
|
||||
logger.info(f"OIDC discovery successful: {discovery_url}")
|
||||
|
||||
# Extract endpoints
|
||||
userinfo_uri = discovery["userinfo_endpoint"]
|
||||
registration_endpoint = discovery.get("registration_endpoint")
|
||||
introspection_uri = discovery.get("introspection_endpoint")
|
||||
|
||||
logger.info(f"Userinfo endpoint: {userinfo_uri}")
|
||||
if introspection_uri:
|
||||
logger.info(f"Introspection endpoint: {introspection_uri}")
|
||||
|
||||
# Load OAuth client credentials
|
||||
client_id, client_secret = await load_oauth_client_credentials(
|
||||
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
|
||||
)
|
||||
|
||||
# Create token verifier with introspection support
|
||||
token_verifier = NextcloudTokenVerifier(
|
||||
yield OAuthAppContext(
|
||||
nextcloud_host=nextcloud_host,
|
||||
userinfo_uri=userinfo_uri,
|
||||
introspection_uri=introspection_uri,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
token_verifier=token_verifier,
|
||||
refresh_token_storage=refresh_token_storage,
|
||||
oauth_client=oauth_client,
|
||||
oauth_provider=oauth_provider,
|
||||
)
|
||||
|
||||
logger.info("OAuth initialization complete")
|
||||
|
||||
# Initialize document processors
|
||||
initialize_document_processors()
|
||||
|
||||
try:
|
||||
yield OAuthAppContext(
|
||||
nextcloud_host=nextcloud_host, token_verifier=token_verifier
|
||||
)
|
||||
finally:
|
||||
logger.info("Shutting down OAuth mode")
|
||||
await token_verifier.close()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize OAuth mode: {e}")
|
||||
raise
|
||||
finally:
|
||||
logger.info("Shutting down OAuth mode")
|
||||
# Close OAuth client if it exists
|
||||
if oauth_client and hasattr(oauth_client, "close"):
|
||||
await oauth_client.close()
|
||||
|
||||
|
||||
async def setup_oauth_config():
|
||||
@@ -448,7 +440,7 @@ async def setup_oauth_config():
|
||||
requires token_verifier at construction time.
|
||||
|
||||
Returns:
|
||||
Tuple of (nextcloud_host, token_verifier, auth_settings)
|
||||
Tuple of (nextcloud_host, token_verifier, auth_settings, refresh_token_storage, oauth_client, oauth_provider)
|
||||
"""
|
||||
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
|
||||
if not nextcloud_host:
|
||||
@@ -462,6 +454,41 @@ async def setup_oauth_config():
|
||||
oauth_provider = os.getenv("OAUTH_PROVIDER", "nextcloud").lower()
|
||||
logger.info(f"OAuth provider: {oauth_provider}")
|
||||
|
||||
# Check if offline access (refresh tokens) is enabled
|
||||
enable_offline_access = os.getenv("ENABLE_OFFLINE_ACCESS", "false").lower() in (
|
||||
"true",
|
||||
"1",
|
||||
"yes",
|
||||
)
|
||||
|
||||
# Initialize refresh token storage if enabled
|
||||
refresh_token_storage = None
|
||||
if enable_offline_access:
|
||||
try:
|
||||
from nextcloud_mcp_server.auth.refresh_token_storage import (
|
||||
RefreshTokenStorage,
|
||||
)
|
||||
|
||||
# Validate encryption key before initializing
|
||||
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
|
||||
if not encryption_key:
|
||||
logger.warning(
|
||||
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
|
||||
"Refresh tokens will NOT be stored. Generate a key with:\n"
|
||||
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
|
||||
)
|
||||
else:
|
||||
refresh_token_storage = RefreshTokenStorage.from_env()
|
||||
await refresh_token_storage.initialize()
|
||||
logger.info(
|
||||
"✓ Refresh token storage initialized (offline_access enabled)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize refresh token storage: {e}")
|
||||
logger.warning(
|
||||
"Continuing without refresh token storage - users will need to re-authenticate after token expiration"
|
||||
)
|
||||
|
||||
if oauth_provider == "keycloak":
|
||||
# Keycloak mode: Use Keycloak for OAuth, Nextcloud for token validation
|
||||
logger.info("Using Keycloak as OAuth identity provider")
|
||||
@@ -536,6 +563,26 @@ async def setup_oauth_config():
|
||||
"✓ Keycloak OAuth configured - tokens validated by Nextcloud user_oidc app"
|
||||
)
|
||||
|
||||
# Create Keycloak OAuth client for server-initiated flows (e.g., background workers)
|
||||
oauth_client = None
|
||||
if enable_offline_access and refresh_token_storage:
|
||||
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
|
||||
|
||||
mcp_server_url = os.getenv(
|
||||
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
|
||||
)
|
||||
redirect_uri = f"{mcp_server_url}/oauth/callback"
|
||||
|
||||
oauth_client = KeycloakOAuthClient(
|
||||
keycloak_url=os.getenv("KEYCLOAK_URL", ""),
|
||||
realm=os.getenv("KEYCLOAK_REALM", ""),
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
redirect_uri=redirect_uri,
|
||||
)
|
||||
await oauth_client.discover()
|
||||
logger.info("✓ Keycloak OAuth client initialized for token refresh")
|
||||
|
||||
else:
|
||||
# Nextcloud mode (default): Use Nextcloud for both OAuth and validation
|
||||
logger.info("Using Nextcloud OIDC app as OAuth provider")
|
||||
@@ -605,6 +652,11 @@ async def setup_oauth_config():
|
||||
|
||||
logger.info("✓ Nextcloud OAuth configured")
|
||||
|
||||
# For Nextcloud mode, we could create a generic OAuth client for token refresh
|
||||
# For now, set to None - token refresh can use httpx directly with discovered endpoints
|
||||
oauth_client = None
|
||||
# TODO: Create NextcloudOAuthClient or use generic OAuth 2.0 client for Nextcloud mode
|
||||
|
||||
# Create auth settings (same for both modes)
|
||||
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
|
||||
|
||||
@@ -618,7 +670,14 @@ async def setup_oauth_config():
|
||||
|
||||
logger.info("OAuth configuration complete")
|
||||
|
||||
return nextcloud_host, token_verifier, auth_settings
|
||||
return (
|
||||
nextcloud_host,
|
||||
token_verifier,
|
||||
auth_settings,
|
||||
refresh_token_storage,
|
||||
oauth_client,
|
||||
oauth_provider,
|
||||
)
|
||||
|
||||
|
||||
def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
@@ -632,12 +691,31 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None):
|
||||
# Asynchronously get the OAuth configuration
|
||||
import anyio
|
||||
|
||||
_, token_verifier, auth_settings = anyio.run(setup_oauth_config)
|
||||
(
|
||||
nextcloud_host,
|
||||
token_verifier,
|
||||
auth_settings,
|
||||
refresh_token_storage,
|
||||
oauth_client,
|
||||
oauth_provider,
|
||||
) = anyio.run(setup_oauth_config)
|
||||
|
||||
# Store OAuth context for lifespan to access
|
||||
# We'll pass this to the lifespan via server.deps
|
||||
oauth_context = {
|
||||
"nextcloud_host": nextcloud_host,
|
||||
"token_verifier": token_verifier,
|
||||
"refresh_token_storage": refresh_token_storage,
|
||||
"oauth_client": oauth_client,
|
||||
"oauth_provider": oauth_provider,
|
||||
}
|
||||
|
||||
mcp = FastMCP(
|
||||
"Nextcloud MCP",
|
||||
lifespan=app_lifespan_oauth,
|
||||
token_verifier=token_verifier,
|
||||
auth=auth_settings,
|
||||
dependencies=oauth_context,
|
||||
)
|
||||
else:
|
||||
logger.info("Configuring MCP server for BasicAuth mode")
|
||||
|
||||
@@ -17,8 +17,6 @@ from urllib.parse import urlencode, urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from nextcloud_mcp_server.auth.client_registration import generate_state, verify_state
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -340,6 +338,165 @@ class KeycloakOAuthClient:
|
||||
|
||||
return userinfo
|
||||
|
||||
async def get_service_account_token(self, scopes: list[str] | None = None) -> dict:
|
||||
"""
|
||||
Get a service account token using client_credentials grant.
|
||||
|
||||
This requires the client to have serviceAccountsEnabled=true in Keycloak.
|
||||
The service account token can be used for server-initiated operations
|
||||
or as the subject_token for token exchange.
|
||||
|
||||
Args:
|
||||
scopes: Optional list of scopes to request (default: openid profile email)
|
||||
|
||||
Returns:
|
||||
Token response dictionary with:
|
||||
- access_token: Service account access token
|
||||
- token_type: Bearer
|
||||
- expires_in: Token lifetime in seconds
|
||||
- scope: Granted scopes
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If token request fails
|
||||
|
||||
Note:
|
||||
This is used for ADR-002 Tier 2 (Token Exchange). The service account
|
||||
token is exchanged for user-scoped tokens via RFC 8693.
|
||||
"""
|
||||
if not self.token_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.token_endpoint:
|
||||
raise RuntimeError("Token endpoint not discovered")
|
||||
|
||||
# Default scopes
|
||||
if scopes is None:
|
||||
scopes = ["openid", "profile", "email"]
|
||||
|
||||
scope_str = " ".join(scopes)
|
||||
|
||||
logger.info(f"Requesting service account token with scopes: {scope_str}")
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.post(
|
||||
self.token_endpoint,
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"scope": scope_str,
|
||||
},
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info("✓ Service account token acquired")
|
||||
|
||||
return token_data
|
||||
|
||||
async def exchange_token_for_user(
|
||||
self,
|
||||
subject_token: str,
|
||||
target_user_id: str | None = None,
|
||||
audience: str | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Exchange a token for a user-scoped token using RFC 8693 Token Exchange.
|
||||
|
||||
This allows the MCP server (with a service account token) to obtain
|
||||
user-scoped access tokens for background operations without needing
|
||||
refresh tokens.
|
||||
|
||||
Args:
|
||||
subject_token: The token being exchanged (service account or user token)
|
||||
target_user_id: Optional user ID to impersonate/exchange for
|
||||
audience: Optional target audience (client ID)
|
||||
scopes: Optional list of scopes for the new token
|
||||
|
||||
Returns:
|
||||
Token response dictionary with:
|
||||
- access_token: User-scoped access token
|
||||
- issued_token_type: urn:ietf:params:oauth:token-type:access_token
|
||||
- token_type: Bearer
|
||||
- expires_in: Token lifetime in seconds
|
||||
|
||||
Raises:
|
||||
httpx.HTTPError: If token exchange fails (403 if not authorized)
|
||||
|
||||
Example:
|
||||
# Get service account token
|
||||
service_token = await client.get_service_account_token()
|
||||
|
||||
# Exchange for user-scoped token
|
||||
user_token = await client.exchange_token_for_user(
|
||||
subject_token=service_token["access_token"],
|
||||
target_user_id="admin", # Username or sub claim
|
||||
audience="nextcloud",
|
||||
scopes=["notes:read", "files:read"]
|
||||
)
|
||||
|
||||
Note:
|
||||
This implements ADR-002 Tier 2. Requires:
|
||||
- Keycloak Standard Token Exchange V2 enabled (default in modern Keycloak)
|
||||
- Client has token.exchange.grant.enabled=true
|
||||
- Client has serviceAccountsEnabled=true
|
||||
- Appropriate exchange permissions configured in Keycloak
|
||||
"""
|
||||
if not self.token_endpoint:
|
||||
await self.discover()
|
||||
|
||||
if not self.token_endpoint:
|
||||
raise RuntimeError("Token endpoint not discovered")
|
||||
|
||||
# Build token exchange request
|
||||
data = {
|
||||
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
|
||||
"subject_token": subject_token,
|
||||
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if audience:
|
||||
data["audience"] = audience
|
||||
|
||||
if scopes:
|
||||
data["scope"] = " ".join(scopes)
|
||||
|
||||
if target_user_id:
|
||||
# Use requested_subject for user impersonation
|
||||
data["requested_subject"] = target_user_id
|
||||
|
||||
logger.info(f"Exchanging token for user: {target_user_id or 'current'}")
|
||||
|
||||
client = await self._get_http_client()
|
||||
response = await client.post(
|
||||
self.token_endpoint,
|
||||
data=data,
|
||||
auth=(self.client_id, self.client_secret),
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
error_data = (
|
||||
response.json()
|
||||
if response.headers.get("content-type", "").startswith(
|
||||
"application/json"
|
||||
)
|
||||
else {"error": "unknown"}
|
||||
)
|
||||
logger.error(f"Token exchange failed: {response.status_code}")
|
||||
logger.error(f"Error response: {error_data}")
|
||||
|
||||
response.raise_for_status()
|
||||
token_data = response.json()
|
||||
|
||||
logger.info(
|
||||
f"✓ Token exchange successful, issued_token_type: {token_data.get('issued_token_type')}"
|
||||
)
|
||||
|
||||
return token_data
|
||||
|
||||
async def check_token_exchange_support(self) -> bool:
|
||||
"""
|
||||
Check if Keycloak supports RFC 8693 token exchange.
|
||||
@@ -380,4 +537,4 @@ class KeycloakOAuthClient:
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["KeycloakOAuthClient", "generate_state", "verify_state"]
|
||||
__all__ = ["KeycloakOAuthClient"]
|
||||
|
||||
@@ -5,7 +5,6 @@ Securely stores and manages user refresh tokens for background operations.
|
||||
Tokens are encrypted at rest using Fernet symmetric encryption.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
@@ -58,12 +57,21 @@ class RefreshTokenStorage:
|
||||
"print(Fernet.generate_key().decode())'"
|
||||
)
|
||||
|
||||
# Fernet expects a base64url-encoded key as bytes, not decoded bytes
|
||||
# The key from Fernet.generate_key() is already base64url-encoded
|
||||
try:
|
||||
encryption_key = base64.b64decode(encryption_key_b64)
|
||||
# Convert string to bytes if needed
|
||||
if isinstance(encryption_key_b64, str):
|
||||
encryption_key = encryption_key_b64.encode()
|
||||
else:
|
||||
encryption_key = encryption_key_b64
|
||||
|
||||
# Validate the key by trying to create a Fernet instance
|
||||
Fernet(encryption_key)
|
||||
except Exception as e:
|
||||
raise ValueError(
|
||||
f"Invalid TOKEN_ENCRYPTION_KEY: {e}. "
|
||||
"Must be a base64-encoded Fernet key."
|
||||
"Must be a valid Fernet key (base64url-encoded 32 bytes)."
|
||||
) from e
|
||||
|
||||
return cls(db_path=db_path, encryption_key=encryption_key)
|
||||
|
||||
Reference in New Issue
Block a user