0c9a9ea24d
This PR fixes multiple OAuth-related issues: ## Unified OAuth Callback - Consolidated `/oauth/callback-nextcloud` and `/oauth/login-callback` into single `/oauth/callback` endpoint - Flow type determined by session lookup via state parameter (no query params in redirect_uri) - Fixes redirect_uri validation issues with IdPs requiring exact match - Legacy endpoints kept as aliases for backwards compatibility ## PKCE Implementation - Implemented PKCE (RFC 7636) for Flow 2 (resource provisioning) - Generate code_verifier and code_challenge - Store code_verifier in session storage - Retrieve and use in token exchange - Fixed PKCE for browser login (integrated mode) - Previously only worked for external IdP (Keycloak) - Now works for both Nextcloud OIDC and external IdP ## Login Elicitation Fixes (ADR-006) - Fixed elicitation URL to route through MCP server endpoint - Changed from direct Nextcloud URL to `/oauth/authorize-nextcloud` - Ensures PKCE is properly handled by server - Fixed login detection after OAuth flow completes - Look up refresh token by state parameter instead of user_id - Works even when Flow 1 token not present - Added `get_refresh_token_by_provisioning_client_id()` method ## Session Authentication - Fixed `/user/page` redirect loop - Shared oauth_context with mounted browser_app - SessionAuthBackend can now validate sessions correctly ## Tests - Added comprehensive login elicitation test suite - Updated scope authorization test expectations - All 43 OAuth tests passing ## Files Changed - `app.py`: Shared oauth_context, unified callback route - `oauth_routes.py`: Unified callback, PKCE for Flow 2 - `browser_oauth_routes.py`: PKCE for integrated mode - `oauth_tools.py`: Fixed elicitation URL generation - `refresh_token_storage.py`: Added lookup by provisioning_client_id - `test_login_elicitation.py`: New test suite 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
625 lines
22 KiB
Python
625 lines
22 KiB
Python
"""
|
|
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
|
|
from base64 import urlsafe_b64encode
|
|
from urllib.parse import urlencode
|
|
|
|
import httpx
|
|
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.refresh_token_storage import RefreshTokenStorage
|
|
|
|
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 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
|
|
"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 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,
|
|
"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 httpx.AsyncClient() 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 httpx.AsyncClient() 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
|
|
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)
|
|
|
|
|
|
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,
|
|
)
|