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:
Chris Coutinho
2025-11-02 02:04:41 +01:00
parent 37b0b4a281
commit e331544cee
10 changed files with 2072 additions and 1022 deletions
+133 -55
View File
@@ -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")
+160 -3
View File
@@ -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)