feat: Add Keycloak OAuth provider support with refresh token storage

Implements Keycloak as an external OIDC provider following ADR-002
architecture for background job authentication using offline_access.

## Features

- Keycloak OAuth provider with PKCE and offline_access support
- Refresh token storage with Fernet encryption
- Token verifier for both JWT and opaque tokens
- Multi-client validation (realm-level trust)
- Sample configuration for Keycloak integration

## Implementation

### OAuth Provider (keycloak_oauth.py)
- Authorization Code Flow with PKCE
- Refresh token exchange
- OIDC discovery endpoint support
- Token validation with JWKS

### Token Storage (refresh_token_storage.py)
- Encrypted storage using Fernet symmetric encryption
- SQLite backend for persistence
- Token rotation support
- Per-user token management

### Token Verifier Updates
- Support both JWT (self-encoded) and opaque tokens
- JWKS-based JWT signature verification
- Introspection endpoint fallback for opaque tokens
- Scope extraction from both token types

### Configuration
- .env.keycloak.sample: Example configuration with Keycloak URLs
- docs/keycloak-multi-client-validation.md: Realm-level validation documentation
- app-hooks/post-installation/10-install-user_oidc-app.sh: Updated dependencies

## Architecture Notes

- MCP Server is a protected resource (requires OAuth)
- MCP Client initiates OAuth flow and shares refresh tokens
- Refresh tokens enable background operations without admin credentials
- Supports future token exchange delegation when Keycloak implements it

## References

- ADR-002: Vector Database Background Sync Authentication
- RFC 6749: OAuth 2.0 (offline_access, refresh tokens)
- RFC 7517: JSON Web Key (JWK)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2025-10-31 03:03:58 +01:00
parent 529dc4616b
commit f34366a260
8 changed files with 1484 additions and 53 deletions
+141 -52
View File
@@ -440,6 +440,10 @@ async def setup_oauth_config():
"""
Setup OAuth configuration by performing OIDC discovery and client registration.
Supports two OAuth providers (via OAUTH_PROVIDER environment variable):
- "nextcloud" (default): Nextcloud OIDC app as both IdP and API server
- "keycloak": Keycloak as IdP, Nextcloud user_oidc validates tokens
This is done synchronously before FastMCP initialization because FastMCP
requires token_verifier at construction time.
@@ -453,70 +457,155 @@ async def setup_oauth_config():
)
nextcloud_host = nextcloud_host.rstrip("/")
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Determine OAuth provider
oauth_provider = os.getenv("OAUTH_PROVIDER", "nextcloud").lower()
logger.info(f"OAuth provider: {oauth_provider}")
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
if oauth_provider == "keycloak":
# Keycloak mode: Use Keycloak for OAuth, Nextcloud for token validation
logger.info("Using Keycloak as OAuth identity provider")
logger.info("OIDC discovery successful")
keycloak_discovery_url = os.getenv("KEYCLOAK_DISCOVERY_URL")
if not keycloak_discovery_url:
raise ValueError(
"KEYCLOAK_DISCOVERY_URL environment variable is required for Keycloak mode. "
"Example: http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration"
)
# Validate PKCE support
validate_pkce_support(discovery, discovery_url)
logger.info(f"Performing OIDC discovery: {keycloak_discovery_url}")
# Extract endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
registration_endpoint = discovery.get("registration_endpoint")
# Fetch Keycloak OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(keycloak_discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
if introspection_uri:
logger.info(f" Introspection: {introspection_uri}")
logger.info("Keycloak OIDC discovery successful")
# Allow override of public issuer URL for both client configuration and JWT validation
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
# Validate PKCE support
validate_pkce_support(discovery, keycloak_discovery_url)
# Extract Keycloak endpoints (for OAuth flows)
issuer = discovery["issuer"]
keycloak_userinfo_uri = discovery["userinfo_endpoint"]
keycloak_jwks_uri = discovery.get("jwks_uri")
logger.info("Keycloak OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {keycloak_userinfo_uri}")
logger.info(f" JWKS: {keycloak_jwks_uri}")
# Get static client credentials from environment
client_id = os.getenv("KEYCLOAK_CLIENT_ID")
client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET")
if not client_id or not client_secret:
raise ValueError(
"KEYCLOAK_CLIENT_ID and KEYCLOAK_CLIENT_SECRET environment variables "
"are required for Keycloak mode"
)
logger.info(f"Using Keycloak client: {client_id}")
# Token validation: Use Nextcloud's userinfo endpoint
# Nextcloud's user_oidc app validates Keycloak tokens and provisions users
nextcloud_userinfo_uri = f"{nextcloud_host}/apps/user_oidc/userinfo"
# Override issuer for public access if needed
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL for client configuration: {public_issuer}"
)
issuer = public_issuer
# Create token verifier pointing to Nextcloud (validates via user_oidc)
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=nextcloud_userinfo_uri, # Nextcloud validates Keycloak tokens
jwks_uri=keycloak_jwks_uri, # Keycloak's JWKS for JWT validation
issuer=issuer, # Keycloak issuer
introspection_uri=None, # Not used in Keycloak mode
client_id=client_id,
client_secret=client_secret,
)
# Use public issuer for both client configuration AND JWT validation
issuer = public_issuer
jwt_validation_issuer = public_issuer
logger.info(
"✓ Keycloak OAuth configured - tokens validated by Nextcloud user_oidc app"
)
else:
# Use discovered issuer for both
jwt_validation_issuer = issuer
# Nextcloud mode (default): Use Nextcloud for both OAuth and validation
logger.info("Using Nextcloud OIDC app as OAuth provider")
# Load OAuth client credentials
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
discovery_url = f"{nextcloud_host}/.well-known/openid-configuration"
# Create token verifier with JWT support and introspection
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enable JWT verification if available
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
logger.info(f"Performing OIDC discovery: {discovery_url}")
# Create auth settings
# Fetch OIDC discovery
async with httpx.AsyncClient() as client:
response = await client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("OIDC discovery successful")
# Validate PKCE support
validate_pkce_support(discovery, discovery_url)
# Extract endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
registration_endpoint = discovery.get("registration_endpoint")
logger.info("OIDC endpoints discovered:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
if introspection_uri:
logger.info(f" Introspection: {introspection_uri}")
# Allow override of public issuer URL for both client configuration and JWT validation
# When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080),
# the OIDC app issues JWT tokens with that public URL in the 'iss' claim,
# even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app).
# Therefore, we must validate JWT tokens against the public issuer, not the internal one.
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL for clients and JWT validation: {public_issuer}"
)
# Use public issuer for both client configuration AND JWT validation
issuer = public_issuer
jwt_validation_issuer = public_issuer
else:
# Use discovered issuer for both
jwt_validation_issuer = issuer
# Load OAuth client credentials (dynamic registration or environment)
client_id, client_secret = await load_oauth_client_credentials(
nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint
)
# Create token verifier with JWT support and introspection
token_verifier = NextcloudTokenVerifier(
nextcloud_host=nextcloud_host,
userinfo_uri=userinfo_uri,
jwks_uri=jwks_uri, # Enable JWT verification if available
issuer=jwt_validation_issuer, # Use original issuer for JWT validation
introspection_uri=introspection_uri, # Enable introspection for opaque tokens
client_id=client_id,
client_secret=client_secret,
)
logger.info("✓ Nextcloud OAuth configured")
# Create auth settings (same for both modes)
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Note: We don't set required_scopes here anymore.