""" OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture) Implements dual OAuth flows with optional offline access provisioning: Flow 1: Client Authentication - MCP client authenticates directly to IdP - Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.) - Token audience (aud): "mcp-server" - No server interception - IdP redirects directly to client - Client receives resource-scoped token for MCP session Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access - Triggered by user calling provision_nextcloud_access tool - Server requests: openid, profile, email scopes, offline_access - Separate login flow outside MCP session, results in browser login for user - Token audience (aud): "nextcloud", redirect/callback to mcp server - Server receives refresh token for offline access - Client never sees this token """ import hashlib import logging import os import secrets import time from base64 import urlsafe_b64encode from urllib.parse import urlencode import jwt from starlette.requests import Request from starlette.responses import JSONResponse, RedirectResponse from nextcloud_mcp_server.auth.client_registry import get_client_registry from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from ..http import nextcloud_httpx_client logger = logging.getLogger(__name__) async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse: """ OAuth authorization endpoint for Flow 1: Client Authentication. The client authenticates directly to the IdP with its own client_id. The server validates the client is authorized but does NOT intercept the callback. IdP redirects directly back to the client's redirect_uri. Query parameters: response_type: Must be "code" client_id: MCP client identifier (required) redirect_uri: Client's localhost redirect URI (required) scope: Requested scopes (optional, defaults to "openid profile email") 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 """ # 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, ) # Validate client_id (required for Flow 1) if not client_id: return JSONResponse( { "error": "invalid_request", "error_description": "client_id is required", }, status_code=400, ) # Validate client using registry registry = get_client_registry() is_valid, error_msg = registry.validate_client( client_id=client_id, redirect_uri=redirect_uri, scopes=request.query_params.get("scope", "").split() if request.query_params.get("scope") else None, ) if not is_valid: logger.warning(f"Client validation failed: {error_msg}") return JSONResponse( { "error": "unauthorized_client", "error_description": error_msg, }, 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, ) oauth_client = oauth_ctx["oauth_client"] oauth_config = oauth_ctx["config"] # Flow 1: Client authenticates directly to IdP WITHOUT server interception # CRITICAL: This is a direct pass-through to IdP # The IdP will redirect directly back to the client's callback # The MCP server does NOT see the IdP authorization code! logger.info( f"Starting Flow 1 - no server session needed, " f"client will handle IdP response directly at {redirect_uri}" ) # Use client's redirect_uri for DIRECT callback (bypasses server) callback_uri = redirect_uri # Request resource scopes for MCP tools access # The token will have aud: "mcp-server" claim # Build scopes from NEXTCLOUD_OIDC_SCOPES config default_scopes = "openid profile email" resource_scopes = oauth_config.get("scopes", "") scopes = f"{default_scopes} {resource_scopes}".strip() # Pass through client's state directly idp_state = state # Use client's own client_id (client must be pre-registered at IdP) idp_client_id = client_id logger.info("Flow 1: Direct client auth to IdP") logger.info(f" Client ID: {client_id}") logger.info(f" Client will receive IdP code directly at: {callback_uri}") logger.info(f" Scopes: {scopes} (resource access for MCP tools)") # 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 nextcloud_httpx_client() 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 "resource": f"{oauth_config['mcp_server_url']}/mcp", # MCP server audience } 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_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. Requires a separate login flow outside of the MCP session. Query parameters: state: Session state for tracking Returns: 302 redirect to IdP authorization endpoint """ 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" # Flow 2: Server only needs identity + offline access (no resource scopes) # Resource scopes are requested by client in Flow 1 scopes = "openid profile email offline_access" # Generate PKCE values (required by Nextcloud OIDC) code_verifier = secrets.token_urlsafe(32) digest = hashlib.sha256(code_verifier.encode()).digest() code_challenge = urlsafe_b64encode(digest).decode().rstrip("=") # Store code_verifier in session for retrieval during callback storage = oauth_ctx["storage"] await storage.store_oauth_session( session_id=state, client_id=mcp_server_client_id, client_redirect_uri=callback_uri, state=state, code_challenge=code_challenge, code_challenge_method="S256", mcp_authorization_code=code_verifier, # Store code_verifier here temporarily flow_type="flow2", ttl_seconds=600, # 10 minutes ) # 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 nextcloud_httpx_client() 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, "code_challenge": code_challenge, "code_challenge_method": "S256", "prompt": "consent", # Force consent to show resource access "access_type": "offline", # Request refresh token "resource": oauth_config["nextcloud_resource_uri"], # Nextcloud audience } 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): """ 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"] # Retrieve code_verifier from session storage (PKCE required by Nextcloud OIDC) code_verifier = "" oauth_session = await storage.get_oauth_session(state) if oauth_session: # code_verifier was stored in mcp_authorization_code field code_verifier = oauth_session.get("mcp_authorization_code", "") logger.info( f"Retrieved code_verifier for Flow 2 callback (state={state[:16]}...)" ) # 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" discovery_url = oauth_config.get("discovery_url") async with nextcloud_httpx_client() as http_client: response = await http_client.get(discovery_url) response.raise_for_status() discovery = response.json() token_endpoint = discovery["token_endpoint"] # Build token exchange params token_params = { "grant_type": "authorization_code", "code": code, "redirect_uri": callback_uri, "client_id": mcp_server_client_id, "client_secret": mcp_server_client_secret, } # Add code_verifier for PKCE (required by Nextcloud OIDC) if code_verifier: token_params["code_verifier"] = code_verifier # Exchange code for tokens async with nextcloud_httpx_client() as http_client: response = await http_client.post( token_endpoint, data=token_params, ) 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 logger.info("=" * 60) logger.info("oauth_callback_nextcloud: Extracting user_id from ID token") logger.info("=" * 60) 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(" ✓ ID token decode SUCCESSFUL") logger.info(f" Extracted user_id: {user_id}") logger.info(f" Username: {username}") logger.info(f" ID token payload keys: {list(userinfo.keys())}") logger.info(f"Flow 2: User {username} provisioned resource access") except Exception as e: logger.error(f" ✗ ID token decode FAILED: {type(e).__name__}: {e}") user_id = "unknown" logger.error(f" Using fallback user_id: {user_id}") # 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 ) # Calculate refresh token expiration from token response refresh_expires_in = token_data.get("refresh_expires_in") refresh_expires_at = None if refresh_expires_in: refresh_expires_at = int(time.time()) + refresh_expires_in logger.info(f" refresh_expires_in: {refresh_expires_in}s") logger.info(f" refresh_expires_at: {refresh_expires_at}") logger.info("Storing refresh token:") logger.info(f" user_id: {user_id}") logger.info(" flow_type: flow2") logger.info(" token_audience: nextcloud") logger.info(f" provisioning_client_id: {state[:16]}...") logger.info(f" scopes: {granted_scopes}") logger.info(f" expires_at: {refresh_expires_at}") 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=refresh_expires_at, ) logger.info(f"✓ Stored Flow 2 master refresh token for user {user_id}") logger.info("=" * 60) # Return success HTML page success_html = """ Nextcloud Access Provisioned

✓ Nextcloud Access Provisioned

The MCP server now has offline access to your Nextcloud resources.

You can close this window and return to your MCP client.

""" from starlette.responses import HTMLResponse return HTMLResponse(content=success_html, status_code=200) async def oauth_callback(request: Request): """ Unified OAuth callback endpoint supporting multiple flows. This endpoint consolidates all OAuth callback handling into a single URL. The flow type is determined by looking up the OAuth session using the state parameter. This simplifies IdP configuration by requiring only one callback URL to be registered: /oauth/callback Query parameters: code: Authorization code from IdP state: CSRF protection state (also used to lookup flow type) error: Error code (if authorization failed) Returns: Response from the appropriate flow handler """ # Get state parameter to lookup OAuth session state = request.query_params.get("state") if not state: logger.warning("Unified callback called without state parameter") return JSONResponse( { "error": "invalid_request", "error_description": "state parameter is required", }, status_code=400, ) # Lookup OAuth session to determine flow type oauth_ctx = request.app.state.oauth_context if not oauth_ctx: logger.error("OAuth context not available") return JSONResponse( { "error": "server_error", "error_description": "OAuth not configured on server", }, status_code=500, ) storage = oauth_ctx["storage"] oauth_session = await storage.get_oauth_session(state) # Determine flow type from session, default to "browser" for backwards compatibility flow_type = ( oauth_session.get("flow_type", "browser") if oauth_session else "browser" ) logger.info(f"Unified callback: flow_type={flow_type} (from session lookup)") if flow_type == "flow2": # Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access logger.info("Routing to Flow 2 (resource provisioning)") return await oauth_callback_nextcloud(request) elif flow_type == "browser": # Browser UI Login - establish browser session for /user/page access logger.info("Routing to browser login flow") from nextcloud_mcp_server.auth.browser_oauth_routes import ( oauth_login_callback, ) return await oauth_login_callback(request) else: # Unknown flow type logger.warning(f"Unknown flow_type in OAuth session: {flow_type}") return JSONResponse( { "error": "invalid_request", "error_description": f"Unknown flow type: {flow_type}", }, status_code=400, )