Files
nextcloud-mcp-server/nextcloud_mcp_server/auth/oauth_routes.py
T
Chris Coutinho c896a2de63 feat: Complete ADR-004 Progressive Consent OAuth flows implementation
Implement dual OAuth flows for Progressive Consent architecture:

Flow 1 (Client Authentication):
- Client authenticates directly to IdP with its own client_id
- Server validates client_id against ALLOWED_MCP_CLIENTS whitelist
- Issues tokens with aud: "mcp-server" for MCP authentication only
- Progressive mode detected via ENABLE_PROGRESSIVE_CONSENT env var

Flow 2 (Resource Provisioning):
- New endpoints: /oauth/authorize-nextcloud, /oauth/callback-nextcloud
- MCP server acts as OAuth client for delegated Nextcloud access
- Stores master refresh tokens with flow_type and audience metadata
- Returns success HTML page after provisioning completion

Scope Authorization Updates:
- Added ProvisioningRequiredError for missing Flow 2 provisioning
- Decorator checks if Nextcloud scopes require provisioning in Progressive mode
- Validates token has Nextcloud scopes before allowing access

Storage Schema Enhancements:
- Added flow_type, is_provisioning, requested_scopes to oauth_sessions
- Enhanced store_oauth_session to support Progressive Consent metadata
- Maintains backward compatibility with hybrid flow

This completes the Progressive Consent implementation, enabling:
- Explicit user consent for resource access
- Stateless server by default (no automatic provisioning)
- Clear separation between authentication and resource access
- Defense in depth with audience-specific tokens

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 08:14:23 +01:00

860 lines
30 KiB
Python

"""
OAuth 2.0 Login Routes for ADR-004 Progressive Consent Architecture
Implements OAuth endpoints that support both:
1. Hybrid Flow (backward compatible) - Single OAuth flow with server interception
2. Progressive Consent (ADR-004) - Dual OAuth flows with explicit provisioning
Progressive Consent Mode (when ENABLE_PROGRESSIVE_CONSENT=true):
- Flow 1: Client Authentication - MCP client authenticates directly to IdP
- Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
Hybrid Flow Mode (default, backward compatible):
1. MCP client initiates OAuth at /oauth/authorize
2. MCP server redirects to IdP (intercepts callback)
3. IdP redirects back to /oauth/callback (server gets master tokens)
4. Server generates MCP auth code and redirects to client
5. Client exchanges MCP code at /oauth/token using PKCE
"""
import hashlib
import logging
import os
import secrets
from urllib.parse import urlencode
from uuid import uuid4
import httpx
import jwt
from starlette.requests import Request
from starlette.responses import JSONResponse, RedirectResponse
from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage
logger = logging.getLogger(__name__)
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
"""
OAuth authorization endpoint with PKCE support.
Supports both Hybrid Flow (default) and Progressive Consent Flow 1.
In Progressive Consent mode (ENABLE_PROGRESSIVE_CONSENT=true):
- Flow 1: Client authenticates directly to IdP with its own client_id
- Server validates client_id is in ALLOWED_MCP_CLIENTS list
- Issues tokens with aud: "mcp-server" for MCP authentication only
In Hybrid Flow mode (default):
- Single OAuth flow where server intercepts and stores refresh token
Query parameters:
response_type: Must be "code"
client_id: MCP client identifier (required in Progressive mode)
redirect_uri: Client's localhost redirect URI (required)
scope: Requested scopes (optional)
state: CSRF protection state (required)
code_challenge: PKCE code challenge from client (required)
code_challenge_method: PKCE method, must be "S256" (required)
Returns:
302 redirect to IdP authorization endpoint
"""
# Check if Progressive Consent is enabled
enable_progressive = (
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
)
# Extract parameters
response_type = request.query_params.get("response_type")
client_id = request.query_params.get("client_id")
redirect_uri = request.query_params.get("redirect_uri")
state = request.query_params.get("state")
code_challenge = request.query_params.get("code_challenge")
code_challenge_method = request.query_params.get("code_challenge_method", "S256")
# Validate required parameters
if response_type != "code":
return JSONResponse(
{
"error": "unsupported_response_type",
"error_description": "Only 'code' response_type is supported",
},
status_code=400,
)
if not redirect_uri:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "redirect_uri is required",
},
status_code=400,
)
# Validate redirect_uri is localhost (RFC 8252 for native clients)
if not redirect_uri.startswith(("http://localhost:", "http://127.0.0.1:")):
return JSONResponse(
{
"error": "invalid_request",
"error_description": "redirect_uri must be localhost for native clients",
},
status_code=400,
)
if not state:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "state parameter is required for CSRF protection",
},
status_code=400,
)
if not code_challenge:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "code_challenge is required (PKCE)",
},
status_code=400,
)
if code_challenge_method != "S256":
return JSONResponse(
{
"error": "invalid_request",
"error_description": "code_challenge_method must be S256",
},
status_code=400,
)
# In Progressive Consent mode, validate client_id
if enable_progressive:
if not client_id:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "client_id is required in Progressive Consent mode",
},
status_code=400,
)
# Check if client_id is in allowed list
allowed_clients = os.getenv("ALLOWED_MCP_CLIENTS", "").split(",")
allowed_clients = [c.strip() for c in allowed_clients if c.strip()]
if allowed_clients and client_id not in allowed_clients:
logger.warning(f"Unauthorized client_id: {client_id}")
return JSONResponse(
{
"error": "unauthorized_client",
"error_description": f"Client {client_id} is not authorized",
},
status_code=401,
)
# Get OAuth context from app state
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth not configured on server",
},
status_code=500,
)
storage: RefreshTokenStorage = oauth_ctx["storage"]
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Generate session ID and MCP authorization code
session_id = str(uuid4())
mcp_authorization_code = f"mcp-code-{secrets.token_urlsafe(32)}"
logger.info(
f"Starting OAuth authorization flow - session={session_id[:8]}..., "
f"client_redirect={redirect_uri}"
)
# Store session with client details and PKCE challenge
flow_type = "flow1" if enable_progressive else "hybrid"
await storage.store_oauth_session(
session_id=session_id,
client_id=client_id,
client_redirect_uri=redirect_uri,
state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
mcp_authorization_code=mcp_authorization_code,
flow_type=flow_type,
ttl_seconds=600, # 10 minutes
)
# Build IdP authorization URL
mcp_server_url = oauth_config["mcp_server_url"]
if enable_progressive:
# Flow 1: Client authenticates directly to IdP
# Use client's redirect_uri for direct callback
callback_uri = redirect_uri
# Only request MCP authentication scopes
scopes = "openid profile email"
# Pass through client's state directly
idp_state = state
# Use client's own client_id (if IdP requires it)
idp_client_id = client_id
else:
# Hybrid Flow: Server intercepts callback
callback_uri = f"{mcp_server_url}/oauth/callback"
# Combine session_id and client state for IdP state parameter
idp_state = f"{session_id}:{state}"
# Build scopes - include both identity scopes and Nextcloud scopes
default_scopes = "openid profile email offline_access"
nextcloud_scopes = oauth_config.get("scopes", "")
scopes = f"{default_scopes} {nextcloud_scopes}".strip()
# Use server's client_id
idp_client_id = oauth_config["client_id"]
# Get authorization endpoint from OAuth client
if oauth_client:
# External IdP mode (Keycloak) - use oauth_client
auth_url = await oauth_client.get_authorization_url(
state=idp_state,
code_challenge="", # Server doesn't use PKCE with IdP
)
logger.info(f"Redirecting to external IdP: {auth_url.split('?')[0]}")
else:
# Integrated mode (Nextcloud OIDC) - build URL directly
discovery_url = oauth_config.get("discovery_url")
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth discovery URL not configured",
},
status_code=500,
)
# Fetch authorization endpoint from discovery
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
from urllib.parse import urlparse as parse_url
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
# Parse internal and authorization endpoint to compare hostnames
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
# Check if authorization endpoint uses internal hostname
if auth_parsed.hostname == internal_parsed.hostname:
# Replace internal hostname+port with public URL
# Keep the path from authorization_endpoint
public_parsed = parse_url(public_issuer)
authorization_endpoint = (
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
)
if auth_parsed.query:
authorization_endpoint += f"?{auth_parsed.query}"
logger.info(
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
)
idp_params = {
"client_id": idp_client_id,
"redirect_uri": callback_uri,
"response_type": "code",
"scope": scopes,
"state": idp_state,
"prompt": "consent", # Ensure refresh token
}
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
return RedirectResponse(auth_url, status_code=302)
async def oauth_callback(request: Request) -> RedirectResponse | JSONResponse:
"""
OAuth callback endpoint - IdP redirects here after user authentication.
This is the CRITICAL difference in the Hybrid Flow:
- The server receives the IdP authorization code
- Server exchanges it for master tokens (including refresh token)
- Server stores the refresh token securely
- Server generates MCP authorization code
- Server redirects client with MCP code (not IdP code!)
Query parameters:
code: Authorization code from IdP
state: State parameter (contains session_id:client_state)
error: Error code (if authorization failed)
error_description: Error description
Returns:
302 redirect to client's redirect_uri with MCP authorization code
"""
# Check for errors from IdP
error = request.query_params.get("error")
if error:
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"IdP authorization error: {error} - {error_description}")
return JSONResponse(
{
"error": error,
"error_description": error_description,
},
status_code=400,
)
# Extract IdP authorization code and state
idp_code = request.query_params.get("code")
idp_state = request.query_params.get("state")
if not idp_code or not idp_state:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "code and state parameters are required",
},
status_code=400,
)
# Parse state to extract session_id and client_state
try:
session_id, client_state = idp_state.split(":", 1)
except ValueError:
return JSONResponse(
{"error": "invalid_state", "error_description": "Invalid state format"},
status_code=400,
)
# Get OAuth context
oauth_ctx = request.app.state.oauth_context
storage: RefreshTokenStorage = oauth_ctx["storage"]
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Retrieve OAuth session
oauth_session = await storage.get_oauth_session(session_id)
if not oauth_session:
return JSONResponse(
{
"error": "invalid_session",
"error_description": "Session not found or expired",
},
status_code=400,
)
logger.info(
f"Processing OAuth callback - session={session_id[:8]}..., "
f"exchanging IdP code for tokens"
)
# STEP 1: Exchange IdP code for master tokens
# The server gets the master refresh token!
mcp_server_url = oauth_config["mcp_server_url"]
server_callback_uri = f"{mcp_server_url}/oauth/callback"
try:
if oauth_client:
# External IdP mode (Keycloak)
# Note: This requires code_verifier, but server doesn't use PKCE with IdP
# We'll need to modify KeycloakOAuthClient to support this pattern
token_data = await oauth_client.exchange_authorization_code(
code=idp_code,
code_verifier="", # Server doesn't use PKCE with IdP
)
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
token_endpoint = discovery["token_endpoint"]
# Exchange code for tokens
async with httpx.AsyncClient() as http_client:
response = await http_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": idp_code,
"redirect_uri": server_callback_uri,
"client_id": oauth_config["client_id"],
"client_secret": oauth_config["client_secret"],
},
)
response.raise_for_status()
token_data = response.json()
except Exception as e:
logger.error(f"Token exchange failed: {e}")
return JSONResponse(
{
"error": "server_error",
"error_description": f"Failed to exchange authorization code: {e}",
},
status_code=500,
)
access_token = token_data["access_token"]
refresh_token = token_data.get("refresh_token")
id_token = token_data.get("id_token")
# Decode ID token to get user info (without verification - just for userinfo)
try:
userinfo = jwt.decode(id_token, options={"verify_signature": False})
user_id = userinfo.get("sub")
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info(f"User authenticated: {username} (sub={user_id})")
except Exception as e:
logger.warning(f"Failed to decode ID token: {e}")
user_id = "unknown"
username = "unknown"
# STEP 2: Store master refresh token (if provided)
if refresh_token:
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
expires_at=None, # Refresh tokens typically don't have expiration
)
logger.info(f"Stored master refresh token for user {user_id}")
# STEP 3: Update session with tokens
await storage.update_oauth_session(
session_id=session_id,
user_id=user_id,
idp_access_token=access_token,
idp_refresh_token=refresh_token,
)
# STEP 4: Redirect to native client with MCP-generated code
mcp_code = oauth_session["mcp_authorization_code"]
client_redirect_uri = oauth_session["client_redirect_uri"]
redirect_params = {
"code": mcp_code, # MCP code, NOT IdP code!
"state": client_state, # Return original client state
}
redirect_url = f"{client_redirect_uri}?{urlencode(redirect_params)}"
logger.info(
f"OAuth callback complete - redirecting to client with MCP code: {mcp_code[:16]}..."
)
return RedirectResponse(redirect_url, status_code=302)
async def oauth_token(request: Request) -> JSONResponse:
"""
OAuth token endpoint - client exchanges MCP code for tokens.
The client sends the MCP-generated code (not IdP code) and proves
ownership via PKCE code_verifier.
Form parameters:
grant_type: Must be "authorization_code" or "refresh_token"
code: MCP authorization code (for authorization_code grant)
code_verifier: PKCE code verifier (for authorization_code grant)
redirect_uri: Must match the redirect_uri from /oauth/authorize
client_id: MCP client identifier (optional)
refresh_token: Refresh token (for refresh_token grant)
Returns:
JSON response with access_token and optional refresh_token
"""
# Parse form data
form = await request.form()
grant_type = form.get("grant_type")
if grant_type == "authorization_code":
# Authorization code grant
code = form.get("code")
code_verifier = form.get("code_verifier")
redirect_uri = form.get("redirect_uri")
if not code or not code_verifier or not redirect_uri:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "code, code_verifier, and redirect_uri are required",
},
status_code=400,
)
# Get OAuth context
oauth_ctx = request.app.state.oauth_context
storage: RefreshTokenStorage = oauth_ctx["storage"]
# Retrieve session by MCP authorization code
oauth_session = await storage.get_oauth_session_by_mcp_code(code)
if not oauth_session:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid authorization code",
},
status_code=400,
)
# Verify PKCE
code_challenge = oauth_session.get("code_challenge")
if code_challenge:
# Compute challenge from verifier
computed_challenge = hashlib.sha256(code_verifier.encode()).digest().hex()
# Convert to base64url format
import base64
computed_challenge = (
base64.urlsafe_b64encode(
hashlib.sha256(code_verifier.encode()).digest()
)
.decode()
.rstrip("=")
)
if computed_challenge != code_challenge:
logger.error("PKCE verification failed")
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "PKCE verification failed",
},
status_code=400,
)
# Verify redirect_uri matches
if redirect_uri != oauth_session["client_redirect_uri"]:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "redirect_uri mismatch",
},
status_code=400,
)
# Get stored IdP access token
idp_access_token = oauth_session.get("idp_access_token")
if not idp_access_token:
return JSONResponse(
{
"error": "server_error",
"error_description": "Access token not found in session",
},
status_code=500,
)
# Invalidate MCP authorization code (one-time use)
await storage.delete_oauth_session(oauth_session["session_id"])
logger.info(f"Token exchange successful - user={oauth_session.get('user_id')}")
# Return tokens to client
# CRITICAL: Client gets access token but NOT the master refresh token
# (unless we implement MCP session refresh tokens)
return JSONResponse(
{
"access_token": idp_access_token,
"token_type": "Bearer",
"expires_in": 3600, # Typical access token lifetime
# Note: We don't return the master refresh token!
# MCP client would need to re-authenticate when token expires
}
)
elif grant_type == "refresh_token":
# Refresh token grant (not implemented in ADR-004 initial version)
return JSONResponse(
{
"error": "unsupported_grant_type",
"error_description": "refresh_token grant not yet implemented",
},
status_code=400,
)
else:
return JSONResponse(
{
"error": "unsupported_grant_type",
"error_description": f"grant_type '{grant_type}' is not supported",
},
status_code=400,
)
async def oauth_authorize_nextcloud(
request: Request,
) -> RedirectResponse | JSONResponse:
"""
OAuth authorization endpoint for Flow 2: Resource Provisioning.
This endpoint is used by the provision_nextcloud_access MCP tool
to initiate delegated resource access to Nextcloud.
Query parameters:
state: Session state for tracking
Returns:
302 redirect to IdP authorization endpoint
"""
# Check if Progressive Consent is enabled
enable_progressive = (
os.getenv("ENABLE_PROGRESSIVE_CONSENT", "false").lower() == "true"
)
if not enable_progressive:
return JSONResponse(
{
"error": "not_enabled",
"error_description": "Progressive Consent mode is not enabled",
},
status_code=400,
)
state = request.query_params.get("state")
if not state:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "state parameter is required",
},
status_code=400,
)
# Get OAuth context
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth not configured on server",
},
status_code=500,
)
oauth_config = oauth_ctx["config"]
# Get MCP server's OAuth client credentials
mcp_server_client_id = os.getenv(
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
)
if not mcp_server_client_id:
return JSONResponse(
{
"error": "server_error",
"error_description": "MCP server OAuth client not configured",
},
status_code=500,
)
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
# Define resource access scopes
scopes = (
"openid profile email offline_access "
"notes:read notes:write "
"calendar:read calendar:write "
"contacts:read contacts:write "
"files:read files:write"
)
# Get authorization endpoint
discovery_url = oauth_config.get("discovery_url")
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth discovery URL not configured",
},
status_code=500,
)
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# Fix internal hostname for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
from urllib.parse import urlparse as parse_url
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
if auth_parsed.hostname == internal_parsed.hostname:
public_parsed = parse_url(public_issuer)
authorization_endpoint = (
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
)
# Build authorization URL
idp_params = {
"client_id": mcp_server_client_id,
"redirect_uri": callback_uri,
"response_type": "code",
"scope": scopes,
"state": state,
"prompt": "consent", # Force consent to show resource access
"access_type": "offline", # Request refresh token
}
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
logger.info("Flow 2: Redirecting to IdP for resource provisioning")
return RedirectResponse(auth_url, status_code=302)
async def oauth_callback_nextcloud(request: Request) -> JSONResponse:
"""
OAuth callback endpoint for Flow 2: Resource Provisioning.
The IdP redirects here after user grants delegated resource access.
Server stores the master refresh token for offline access.
Query parameters:
code: Authorization code from IdP
state: State parameter (session identifier)
error: Error code (if authorization failed)
Returns:
JSON response or HTML success page
"""
# Check for errors from IdP
error = request.query_params.get("error")
if error:
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"Flow 2 authorization error: {error} - {error_description}")
return JSONResponse(
{
"error": error,
"error_description": error_description,
},
status_code=400,
)
code = request.query_params.get("code")
state = request.query_params.get("state")
if not code or not state:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "code and state parameters are required",
},
status_code=400,
)
# Get OAuth context
oauth_ctx = request.app.state.oauth_context
storage: RefreshTokenStorage = oauth_ctx["storage"]
oauth_config = oauth_ctx["config"]
# Exchange code for tokens
mcp_server_client_id = os.getenv(
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
)
mcp_server_client_secret = os.getenv(
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
)
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback-nextcloud"
discovery_url = oauth_config.get("discovery_url")
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
token_endpoint = discovery["token_endpoint"]
# Exchange code for tokens
async with httpx.AsyncClient() as http_client:
response = await http_client.post(
token_endpoint,
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": callback_uri,
"client_id": mcp_server_client_id,
"client_secret": mcp_server_client_secret,
},
)
response.raise_for_status()
token_data = response.json()
refresh_token = token_data.get("refresh_token")
id_token = token_data.get("id_token")
# Decode ID token to get user info
try:
userinfo = jwt.decode(id_token, options={"verify_signature": False})
user_id = userinfo.get("sub")
username = userinfo.get("preferred_username") or userinfo.get("email")
logger.info(f"Flow 2: User {username} provisioned resource access")
except Exception as e:
logger.warning(f"Failed to decode ID token: {e}")
user_id = "unknown"
# Store master refresh token for Flow 2
if refresh_token:
# Parse granted scopes from token response
granted_scopes = (
token_data.get("scope", "").split() if token_data.get("scope") else None
)
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
flow_type="flow2",
token_audience="nextcloud",
provisioning_client_id=state, # Store which client initiated provisioning
scopes=granted_scopes,
expires_at=None, # Refresh tokens typically don't expire
)
logger.info(f"Stored Flow 2 master refresh token for user {user_id}")
# Return success HTML page
success_html = """
<!DOCTYPE html>
<html>
<head>
<title>Nextcloud Access Provisioned</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; }
.success { color: green; }
.info { margin-top: 20px; color: #666; }
</style>
</head>
<body>
<h1 class="success">✓ Nextcloud Access Provisioned</h1>
<p>The MCP server now has offline access to your Nextcloud resources.</p>
<p class="info">You can close this window and return to your MCP client.</p>
</body>
</html>
"""
from starlette.responses import HTMLResponse
return HTMLResponse(content=success_html, status_code=200)