Merge pull request #589 from cbcoutinho/feat/docker-compose-profiles-login-flow

feat: Docker Compose profiles and Login Flow v2 integration tests
This commit is contained in:
Chris Coutinho
2026-03-03 09:41:48 +01:00
committed by GitHub
58 changed files with 5251 additions and 389 deletions
@@ -0,0 +1,95 @@
"""Add scopes and login flow sessions for Login Flow v2
This migration adds support for:
1. Scoped app passwords (scopes column + username column on app_passwords)
2. Login Flow v2 session tracking (login_flow_sessions table)
Nullable scopes preserves backward compat: NULL = legacy app password = all scopes allowed.
Revision ID: 003
Revises: 002
Create Date: 2026-02-27 12:00:00.000000
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "003"
down_revision = "002"
branch_labels = None
depends_on = None
def upgrade() -> None:
"""Add scopes/username to app_passwords and create login_flow_sessions."""
# Add scopes column (nullable JSON array, NULL = all scopes allowed)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN scopes TEXT
"""
)
# Add username column (Nextcloud loginName from Login Flow v2)
op.execute(
"""
ALTER TABLE app_passwords ADD COLUMN username TEXT
"""
)
# Login Flow v2 session tracking
op.execute(
"""
CREATE TABLE IF NOT EXISTS login_flow_sessions (
user_id TEXT PRIMARY KEY,
encrypted_poll_token BLOB NOT NULL,
poll_endpoint TEXT NOT NULL,
requested_scopes TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
)
"""
)
# Index for efficient cleanup of expired sessions
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_login_flow_sessions_expires
ON login_flow_sessions(expires_at)
"""
)
def downgrade() -> None:
"""Drop login_flow_sessions and remove added columns."""
op.execute("DROP INDEX IF EXISTS idx_login_flow_sessions_expires")
op.execute("DROP TABLE IF EXISTS login_flow_sessions")
# SQLite doesn't support DROP COLUMN before 3.35.0
# Recreate app_passwords without the new columns
op.execute(
"""
CREATE TABLE app_passwords_backup (
user_id TEXT PRIMARY KEY,
encrypted_password BLOB NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
op.execute(
"""
INSERT INTO app_passwords_backup (user_id, encrypted_password, created_at, updated_at)
SELECT user_id, encrypted_password, created_at, updated_at FROM app_passwords
"""
)
op.execute("DROP TABLE app_passwords")
op.execute("ALTER TABLE app_passwords_backup RENAME TO app_passwords")
op.execute(
"""
CREATE INDEX IF NOT EXISTS idx_app_passwords_updated
ON app_passwords(updated_at)
"""
)
+10
View File
@@ -11,6 +11,12 @@ This package is organized into modules by domain:
- visualization.py: Search and PDF visualization endpoints
"""
from nextcloud_mcp_server.api.access import (
get_user_access,
list_supported_scopes,
update_user_scopes,
)
# Re-export all public functions for backward compatibility
from nextcloud_mcp_server.api.management import (
__version__,
@@ -44,6 +50,10 @@ from nextcloud_mcp_server.api.webhooks import (
)
__all__ = [
# Access endpoints (from access.py)
"get_user_access",
"update_user_scopes",
"list_supported_scopes",
# Version
"__version__",
# Shared helpers (from management.py)
+173
View File
@@ -0,0 +1,173 @@
"""Access and scope management API endpoints.
Provides REST API endpoints for querying and managing user access status
and application-level scopes for Login Flow v2 mode.
"""
import logging
from starlette.requests import Request
from starlette.responses import JSONResponse
from nextcloud_mcp_server.api.management import _sanitize_error_for_client
from nextcloud_mcp_server.api.passwords import (
_extract_basic_auth,
_get_app_password_storage,
)
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
logger = logging.getLogger(__name__)
async def get_user_access(request: Request) -> JSONResponse:
"""GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes.
Returns the user's current provisioning status, granted scopes, and metadata.
Requires BasicAuth with the user's credentials.
"""
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
storage = await _get_app_password_storage(request)
data = await storage.get_app_password_with_scopes(username)
if data is None:
return JSONResponse(
{
"success": True,
"user_id": username,
"provisioned": False,
"scopes": None,
"username": None,
}
)
return JSONResponse(
{
"success": True,
"user_id": username,
"provisioned": True,
"scopes": data["scopes"],
"username": data.get("username"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "get_user_access")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def update_user_scopes(request: Request) -> JSONResponse:
"""PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes.
Accepts JSON body with:
- scopes: list[str] - New scope set to apply
This only updates the stored scopes, not the app password itself.
The app password remains valid; scope enforcement is application-level.
Security note: This endpoint allows direct scope modification without
re-authenticating via Login Flow. The caller must authenticate with
valid BasicAuth credentials (user_id + app_password), which serves
as the authorization check.
"""
path_user_id = request.path_params.get("user_id")
if not path_user_id:
return JSONResponse(
{"success": False, "error": "Missing user_id in path"},
status_code=400,
)
username, _, error_response = _extract_basic_auth(request, path_user_id)
if error_response is not None:
return error_response
try:
body = await request.json()
except Exception:
return JSONResponse(
{"success": False, "error": "Invalid JSON body"},
status_code=400,
)
scopes = body.get("scopes")
if scopes is None or not isinstance(scopes, list):
return JSONResponse(
{"success": False, "error": "scopes must be a list of strings"},
status_code=400,
)
# Validate scopes
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
return JSONResponse(
{
"success": False,
"error": f"Invalid scopes: {', '.join(invalid)}",
"valid_scopes": sorted(ALL_SUPPORTED_SCOPES),
},
status_code=400,
)
try:
storage = await _get_app_password_storage(request)
existing = await storage.get_app_password_with_scopes(username)
if existing is None:
return JSONResponse(
{
"success": False,
"error": "No app password provisioned for this user",
},
status_code=404,
)
# Update scopes only (no decrypt/re-encrypt of the password)
await storage.update_app_password_scopes(
user_id=username,
scopes=scopes,
)
# Invalidate scope cache so subsequent tool calls see updated scopes
invalidate_scope_cache(username)
return JSONResponse(
{
"success": True,
"user_id": username,
"scopes": scopes,
"message": "Scopes updated successfully",
}
)
except Exception as e:
error_msg = _sanitize_error_for_client(e, "update_user_scopes")
return JSONResponse(
{"success": False, "error": error_msg},
status_code=500,
)
async def list_supported_scopes(_: Request) -> JSONResponse:
"""GET /api/v1/scopes - List all supported application-level scopes."""
return JSONResponse(
{
"success": True,
"scopes": sorted(ALL_SUPPORTED_SCOPES),
}
)
+15 -1
View File
@@ -288,10 +288,23 @@ async def provision_app_password(request: Request) -> JSONResponse:
status_code=500,
)
# Parse optional scopes and username from request body
scopes = None
nc_username = None
try:
body = await request.json()
scopes = body.get("scopes") # list[str] | None
nc_username = body.get("username") # Nextcloud loginName
except Exception:
pass # No JSON body = legacy call without scopes
# Store the validated app password
try:
storage = await _get_app_password_storage(request)
await storage.store_app_password(username, app_password)
await storage.store_app_password_with_scopes(
username, app_password, scopes=scopes, username=nc_username
)
_record_rate_limit_attempt(path_user_id, success=True)
logger.info(f"Provisioned app password for user: {username}")
@@ -300,6 +313,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
{
"success": True,
"message": f"App password stored for {username}",
"scopes": scopes,
}
)
+98 -19
View File
@@ -40,12 +40,15 @@ from nextcloud_mcp_server.api import (
get_installed_apps,
get_pdf_preview,
get_server_status,
get_user_access,
get_user_session,
get_vector_sync_status,
list_supported_scopes,
list_webhooks,
provision_app_password,
revoke_user_access,
unified_search,
update_user_scopes,
vector_search,
)
from nextcloud_mcp_server.auth import (
@@ -63,13 +66,16 @@ from nextcloud_mcp_server.auth.browser_oauth_routes import (
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_as_metadata,
oauth_authorize,
oauth_authorize_nextcloud,
oauth_callback,
oauth_callback_nextcloud,
oauth_register_proxy,
oauth_token_endpoint,
)
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage, get_shared_storage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.auth.userinfo_routes import (
@@ -123,6 +129,7 @@ from nextcloud_mcp_server.server import (
configure_tables_tools,
configure_webdav_tools,
)
from nextcloud_mcp_server.server.auth_tools import register_auth_tools
from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
from nextcloud_mcp_server.vector import processor_task, scanner_task
from nextcloud_mcp_server.vector.oauth_sync import (
@@ -1468,6 +1475,11 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"Skipping provisioning tools registration (offline access not enabled)"
)
# Register Login Flow v2 auth tools (ADR-022)
if settings.enable_login_flow:
logger.info("Registering Login Flow v2 auth tools")
register_auth_tools(mcp)
# Override list_tools to filter based on user's token scopes (OAuth mode only)
if oauth_enabled:
original_list_tools = mcp._tool_manager.list_tools
@@ -1519,6 +1531,43 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
mcp_app = mcp.streamable_http_app()
async def _login_flow_cleanup_loop() -> None:
"""Periodically clean up expired Login Flow v2 sessions and proxy codes."""
from nextcloud_mcp_server.auth.oauth_routes import ( # noqa: PLC0415
_cleanup_expired_proxy_codes,
)
while True:
try:
storage = await get_shared_storage()
count = await storage.delete_expired_login_flow_sessions()
if count:
logger.info(f"Cleaned up {count} expired login flow sessions")
# Also clean up expired AS proxy codes/sessions
_cleanup_expired_proxy_codes()
except Exception as e:
logger.warning(f"Login flow cleanup error: {e}")
await anyio.sleep(3600) # Every hour
@asynccontextmanager
async def _maybe_login_flow_cleanup():
"""Start Login Flow cleanup task if enabled."""
if settings.enable_login_flow:
async with anyio.create_task_group() as tg:
tg.start_soon(_login_flow_cleanup_loop)
yield
tg.cancel_scope.cancel()
else:
yield
@asynccontextmanager
async def _mcp_session_with_login_flow():
"""Start MCP session manager with optional Login Flow cleanup."""
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
await stack.enter_async_context(_maybe_login_flow_cleanup())
yield
@asynccontextmanager
async def starlette_lifespan(app: Starlette):
# Set OAuth context for OAuth login routes (ADR-004)
@@ -1752,8 +1801,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
# Run MCP session manager and yield
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
async with _mcp_session_with_login_flow():
try:
yield
finally:
@@ -1935,8 +1983,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
# Run MCP session manager and yield
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
async with _mcp_session_with_login_flow():
try:
yield
finally:
@@ -1955,8 +2002,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"To enable, set NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET."
)
# Just run MCP session manager without vector sync
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
async with _mcp_session_with_login_flow():
yield
else:
@@ -1976,8 +2022,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
logger.warning(
"Vector sync enabled but TOKEN_ENCRYPTION_KEY not set"
)
async with AsyncExitStack() as stack:
await stack.enter_async_context(mcp.session_manager.run())
async with _mcp_session_with_login_flow():
yield
# Health check endpoints for Kubernetes probes
@@ -2208,10 +2253,27 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
routes.append(
Route("/api/v1/webhooks/{webhook_id}", delete_webhook, methods=["DELETE"])
)
# Access and scope management endpoints (ADR-022)
routes.append(
Route(
"/api/v1/users/{user_id}/access",
get_user_access,
methods=["GET"],
)
)
routes.append(
Route(
"/api/v1/users/{user_id}/scopes",
update_user_scopes,
methods=["PATCH"],
)
)
routes.append(Route("/api/v1/scopes", list_supported_scopes, methods=["GET"]))
logger.info(
"Management API endpoints enabled: /api/v1/status, /api/v1/vector-sync/status, "
"/api/v1/users/{user_id}/session, /api/v1/users/{user_id}/revoke, "
"/api/v1/users/{user_id}/app-password, "
"/api/v1/users/{user_id}/app-password, /api/v1/users/{user_id}/access, "
"/api/v1/users/{user_id}/scopes, /api/v1/scopes, "
"/api/v1/vector-viz/search, /api/v1/search, /api/v1/apps, "
"/api/v1/webhooks, /api/v1/pdf-preview"
)
@@ -2264,14 +2326,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
This is used as the audience in access tokens via the resource parameter (RFC 8707).
The introspection controller matches this URL to the MCP server's client via resource_url field.
"""
# Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if not public_issuer_url:
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
ADR-023: authorization_servers points to the MCP server itself (AS proxy)
so that clients authenticate through the proxy and tokens have correct audience.
"""
# RFC 9728 requires resource to be a URL (not a client ID)
# Use the MCP server's public URL
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
@@ -2283,11 +2341,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# This provides a single source of truth based on @require_scopes decorators
supported_scopes = discover_all_scopes(mcp)
# ADR-023: Point authorization_servers to the MCP server itself.
# The MCP server acts as an OAuth AS proxy, forwarding to Nextcloud
# with its own client_id so tokens have the correct audience.
return JSONResponse(
{
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
"scopes_supported": supported_scopes,
"authorization_servers": [public_issuer_url],
"authorization_servers": [mcp_server_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
}
@@ -2344,7 +2405,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
if oauth_enabled:
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# ADR-023: AS proxy endpoints — MCP server acts as its own OAuth AS
routes.append(Route("/oauth/token", oauth_token_endpoint, methods=["POST"]))
routes.append(Route("/oauth/register", oauth_register_proxy, methods=["POST"]))
routes.append(
Route(
"/.well-known/oauth-authorization-server",
oauth_as_metadata,
methods=["GET"],
)
)
logger.info(
"OAuth AS proxy routes enabled: /oauth/authorize, /oauth/token, "
"/oauth/register, /.well-known/oauth-authorization-server (ADR-023)"
)
# Add browser OAuth login routes for Management API access
# Available in OAuth modes AND multi-user BasicAuth with offline access
@@ -2453,6 +2528,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
)
# Store supported scopes on app.state for AS metadata endpoint (ADR-023)
if oauth_enabled:
app.state.supported_scopes = discover_all_scopes(mcp)
# Add debugging middleware to log Authorization headers and client capabilities
@app.middleware("http")
async def log_auth_headers(request, call_next):
@@ -83,6 +83,7 @@ async def register_client(
scopes: str = "openid profile email",
token_type: str | None = "Bearer",
resource_url: str | None = None,
max_retries: int = 3,
) -> ClientInfo:
"""
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
@@ -98,6 +99,7 @@ async def register_client(
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
Set to None to omit this field (required for Keycloak and other standard providers).
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
max_retries: Maximum number of retries for 429 responses (default: 3)
Returns:
ClientInfo with registration details
@@ -135,57 +137,91 @@ async def register_client(
logger.debug(f"Registration endpoint: {registration_endpoint}")
async with nextcloud_httpx_client(timeout=30.0) as client:
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
for attempt in range(max_retries):
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
else:
logger.warning("RFC 7592 fields missing - client deletion may not work")
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get("registration_access_token"),
registration_client_uri=client_info.get("registration_client_uri"),
)
if response.status_code == 429:
# Rate limited - retry with exponential backoff
if attempt < max_retries - 1:
retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(retry_after, 2**attempt)
logger.warning(
f"Rate limited (429) registering client, "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(f"Invalid response from registration endpoint: missing {e}")
raise ValueError(f"Invalid registration response: missing {e}")
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
)
else:
logger.warning(
"RFC 7592 fields missing - client deletion may not work"
)
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get(
"registration_access_token"
),
registration_client_uri=client_info.get("registration_client_uri"),
)
except httpx.HTTPStatusError as e:
logger.error(
f"Failed to register client: HTTP {e.response.status_code}"
)
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(
f"Invalid response from registration endpoint: missing {e}"
)
raise ValueError(f"Invalid registration response: missing {e}")
# Should not reach here, but raise if we do
raise httpx.HTTPStatusError(
"Registration failed after retries",
request=httpx.Request("POST", registration_endpoint),
response=httpx.Response(429),
)
async def delete_client(
+25 -2
View File
@@ -142,8 +142,8 @@ class ClientRegistry:
if not self._validate_redirect_uri(client, redirect_uri):
return False, f"Invalid redirect_uri for client {client_id}"
# Validate scopes if provided
if scopes:
# Validate scopes if provided (wildcard "*" allows all scopes)
if scopes and "*" not in client.allowed_scopes:
invalid_scopes = set(scopes) - set(client.allowed_scopes)
if invalid_scopes:
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
@@ -202,6 +202,29 @@ class ClientRegistry:
# In production, would persist to database
return True
def register_proxy_client(
self, client_id: str, redirect_uris: list[str], name: str = ""
) -> None:
"""Register a client discovered via DCR proxy.
When the MCP server acts as an OAuth AS proxy, clients register via
the proxy's /oauth/register endpoint. This method stores the client
locally so /oauth/authorize can validate it.
Args:
client_id: Client identifier from Nextcloud DCR response
redirect_uris: Allowed redirect URIs
name: Optional human-readable name
"""
self._clients[client_id] = MCPClientInfo(
client_id=client_id,
name=name or f"DCR-{client_id[:8]}",
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["*"], # Nextcloud enforces actual scopes
is_public=True,
)
logger.info(f"Registered proxy client: {client_id}")
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
"""
Get client information.
+88
View File
@@ -0,0 +1,88 @@
"""MCP elicitation helpers for Login Flow v2.
Provides a unified way to present login URLs to users, using MCP elicitation
when the client supports it, or falling back to returning the URL in a message.
"""
import logging
from mcp.server.fastmcp import Context
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
class LoginFlowConfirmation(BaseModel):
"""Schema for Login Flow v2 confirmation elicitation."""
acknowledged: bool = Field(
default=False,
description="Check this box after completing login at the provided URL",
)
async def present_login_url(
ctx: Context,
login_url: str,
message: str | None = None,
) -> str:
"""Present a login URL to the user via MCP elicitation or message.
Tries MCP elicitation first (ctx.elicit) for interactive clients.
Falls back to returning the URL as a plain message.
Args:
ctx: MCP context
login_url: URL the user should open in their browser
message: Optional custom message (defaults to standard Login Flow prompt)
Returns:
"accepted" if user acknowledged via elicitation,
"declined" if user declined,
"message_only" if elicitation not supported (URL returned in message)
"""
if message is None:
message = (
f"Please log in to Nextcloud to grant access:\n\n"
f"{login_url}\n\n"
f"Open this URL in your browser, log in, and grant the requested permissions. "
f"Then check the box below and click OK."
)
if not hasattr(ctx, "elicit"):
logger.debug(
"Elicitation not available (no elicit method), returning URL in message"
)
return "message_only"
try:
result = await ctx.elicit(
message=message,
schema=LoginFlowConfirmation,
)
if result.action == "accept":
if hasattr(result, "data") and not result.data.acknowledged: # type: ignore[union-attr]
logger.warning(
"User accepted login flow without checking the acknowledged box — "
"login completion will be verified via polling"
)
logger.info("User acknowledged login flow completion")
return "accepted"
elif result.action == "decline":
logger.info("User declined login flow")
return "declined"
else:
logger.info("User cancelled login flow")
return "cancelled"
except NotImplementedError:
# Elicitation not supported by this client/SDK - fall back to message
logger.debug("Elicitation not available, returning URL in message")
return "message_only"
except Exception as e:
logger.warning(
f"Elicitation failed unexpectedly ({type(e).__name__}: {e}), "
"falling back to message"
)
return "message_only"
+157
View File
@@ -0,0 +1,157 @@
"""Nextcloud Login Flow v2 HTTP client.
Implements the Nextcloud Login Flow v2 protocol for obtaining app passwords.
See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/LoginFlow/index.html#login-flow-v2
The flow has two steps:
1. Initiate: POST /index.php/login/v2 → returns login URL + poll endpoint/token
2. Poll: POST to poll endpoint with token → returns server URL, loginName, appPassword
"""
import logging
import ssl
from pydantic import BaseModel, Field
from nextcloud_mcp_server.http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
class LoginFlowInitResponse(BaseModel):
"""Response from initiating Login Flow v2."""
login_url: str = Field(description="URL to present to the user for browser login")
poll_endpoint: str = Field(description="URL to poll for flow completion")
poll_token: str = Field(description="Token to use when polling")
class LoginFlowPollResult(BaseModel):
"""Result of polling Login Flow v2."""
status: str = Field(description="Flow status: 'pending', 'completed', or 'expired'")
server: str | None = Field(None, description="Nextcloud server URL (on completion)")
login_name: str | None = Field(
None, description="Nextcloud login name (on completion)"
)
app_password: str | None = Field(
None, description="Generated app password (on completion)"
)
class LoginFlowV2Client:
"""HTTP client for Nextcloud Login Flow v2.
This client handles the two-step Login Flow v2 process:
1. Initiate a flow to get a login URL for the user
2. Poll for completion to receive the app password
Args:
nextcloud_host: Base URL of the Nextcloud instance
verify_ssl: SSL verification setting (True, False, or SSLContext)
"""
def __init__(
self,
nextcloud_host: str,
verify_ssl: bool | ssl.SSLContext = True,
):
self.nextcloud_host = nextcloud_host.rstrip("/")
self.verify_ssl = verify_ssl
async def initiate(
self, user_agent: str = "nextcloud-mcp-server"
) -> LoginFlowInitResponse:
"""Initiate Login Flow v2 by sending an HTTP POST to the Nextcloud instance.
Makes an outbound HTTP request to POST /index.php/login/v2 on the
configured Nextcloud server to start a new login flow.
Args:
user_agent: User-Agent string for the app password name
Returns:
LoginFlowInitResponse with login URL and poll credentials
Raises:
httpx.HTTPStatusError: If the Nextcloud server returns an error
"""
url = f"{self.nextcloud_host}/index.php/login/v2"
async with nextcloud_httpx_client(
verify=self.verify_ssl, timeout=15.0
) as client:
response = await client.post(
url,
headers={"User-Agent": user_agent},
)
response.raise_for_status()
data = response.json()
poll_data = data.get("poll", {})
try:
result = LoginFlowInitResponse(
login_url=data["login"],
poll_endpoint=poll_data["endpoint"],
poll_token=poll_data["token"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 initiate response from Nextcloud (missing key: {e})"
) from e
logger.info(f"Login Flow v2 initiated: login_url={result.login_url[:60]}...")
return result
async def poll(self, poll_endpoint: str, poll_token: str) -> LoginFlowPollResult:
"""Poll for Login Flow v2 completion by sending an HTTP POST to the Nextcloud instance.
Makes an outbound HTTP request to the poll endpoint provided by the
initiate response. Nextcloud returns:
- 200 with credentials when the user completes login
- 404 when still pending
- Other errors for expired/invalid flows
Args:
poll_endpoint: URL to poll (from initiate response)
poll_token: Token for polling (from initiate response)
Returns:
LoginFlowPollResult with status and optional credentials
"""
async with nextcloud_httpx_client(
verify=self.verify_ssl, timeout=10.0
) as client:
response = await client.post(
poll_endpoint,
data={"token": poll_token},
)
if response.status_code == 200:
data = response.json()
logger.info(
f"Login Flow v2 completed: server={data.get('server')}, "
f"loginName={data.get('loginName')}"
)
try:
return LoginFlowPollResult(
status="completed",
server=data["server"],
login_name=data["loginName"],
app_password=data["appPassword"],
)
except KeyError as e:
raise ValueError(
f"Malformed Login Flow v2 poll response from Nextcloud (missing key: {e})"
) from e
if response.status_code == 404:
logger.debug("Login Flow v2 still pending")
return LoginFlowPollResult(status="pending")
# Any other status indicates the flow has expired or is invalid
logger.warning(
f"Login Flow v2 poll returned unexpected status: {response.status_code}"
)
return LoginFlowPollResult(status="expired")
+781 -104
View File
@@ -1,13 +1,13 @@
"""
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture)
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture) and ADR-023 (AS Proxy)
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 1: Client Authentication (AS Proxy mode, ADR-023)
- MCP server acts as its own OAuth Authorization Server
- Proxies DCR, authorization, and token endpoints to Nextcloud
- Uses MCP server's own client_id so tokens have correct audience
- Client exchanges proxy authorization code for Nextcloud token
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
- Triggered by user calling provision_nextcloud_access tool
@@ -25,6 +25,8 @@ import os
import secrets
import time
from base64 import urlsafe_b64encode
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
@@ -41,13 +43,113 @@ from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# In-memory proxy code store for AS proxy flow (ADR-023)
# Proxy codes are ephemeral (60s TTL), single-instance, so in-memory is fine.
# ---------------------------------------------------------------------------
@dataclass
class ProxyCodeEntry:
"""Stores state for a proxy authorization code issued by the AS proxy.
Proxy codes have a 60-second TTL as a security mitigation: they are
single-use, ephemeral codes that bridge the AS proxy callback and the
client's token exchange. The short window limits replay risk.
"""
client_id: str
client_redirect_uri: str
client_state: str
code_challenge: str
code_challenge_method: str
nc_token_response: dict[str, Any] # Full JSON token response from Nextcloud
created_at: float = field(default_factory=time.time)
expires_at: float = field(default_factory=lambda: time.time() + 60)
@property
def is_expired(self) -> bool:
return time.time() > self.expires_at
# Server-side state for AS proxy authorize → callback mapping
@dataclass
class ASProxySession:
"""Stores state between /oauth/authorize and the Nextcloud callback.
Sessions have a 600-second (10 minute) TTL to allow time for the user
to complete the browser-based authorization flow.
"""
client_id: str
client_redirect_uri: str
client_state: str
code_challenge: str
code_challenge_method: str
requested_scopes: str
created_at: float = field(default_factory=time.time)
expires_at: float = field(default_factory=lambda: time.time() + 600)
@property
def is_expired(self) -> bool:
return time.time() > self.expires_at
# In-memory stores (single-instance, ephemeral)
_proxy_codes: dict[str, ProxyCodeEntry] = {}
_as_proxy_sessions: dict[str, ASProxySession] = {}
# OIDC discovery document cache (URL → (expires_at, data))
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_DISCOVERY_CACHE_TTL = 300 # 5 minutes
# DCR rate limiting (IP → [timestamps])
_dcr_rate_limit: dict[str, list[float]] = {}
_DCR_RATE_LIMIT_MAX = 10 # max requests
_DCR_RATE_LIMIT_WINDOW = 60 # per 60 seconds
async def _get_cached_discovery(url: str) -> dict[str, Any]:
"""Fetch OIDC discovery document with caching (5-minute TTL)."""
now = time.time()
if url in _discovery_cache:
expires_at, data = _discovery_cache[url]
if now < expires_at:
return data
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(url)
response.raise_for_status()
data = response.json()
_discovery_cache[url] = (now + _DISCOVERY_CACHE_TTL, data)
return data
def _cleanup_expired_proxy_codes() -> None:
"""Remove expired proxy codes and sessions."""
now = time.time()
expired_codes = [k for k, v in _proxy_codes.items() if now > v.expires_at]
for k in expired_codes:
del _proxy_codes[k]
expired_sessions = [k for k, v in _as_proxy_sessions.items() if now > v.expires_at]
for k in expired_sessions:
del _as_proxy_sessions[k]
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
"""
OAuth authorization endpoint for Flow 1: Client Authentication.
OAuth authorization endpoint — AS Proxy intermediary (ADR-023).
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.
The MCP server acts as its own OAuth Authorization Server, proxying
the authorization to Nextcloud. This ensures tokens have the correct
audience (MCP server's client_id) instead of the MCP client's client_id.
Flow:
1. Client sends authorize request with its own client_id + PKCE
2. Server stores client params, generates server-side state
3. Server redirects to Nextcloud with MCP server's own client_id
4. Nextcloud callback returns to /oauth/callback (flow_type=as_proxy)
5. Server exchanges code, generates proxy_code for client
6. Client exchanges proxy_code at /oauth/token
Query parameters:
response_type: Must be "code"
@@ -59,8 +161,11 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
code_challenge_method: PKCE method, must be "S256" (required)
Returns:
302 redirect to IdP authorization endpoint
302 redirect to Nextcloud authorization endpoint
"""
# Clean up expired entries periodically
_cleanup_expired_proxy_codes()
# Extract parameters
response_type = request.query_params.get("response_type")
client_id = request.query_params.get("client_id")
@@ -125,7 +230,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
status_code=400,
)
# Validate client_id (required for Flow 1)
# Validate client_id (required)
if not client_id:
return JSONResponse(
{
@@ -166,102 +271,89 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
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!
# AS Proxy: Store client's params and redirect to Nextcloud with MCP server's credentials
# PKCE is validated locally when the client exchanges the proxy_code at /oauth/token.
# We do NOT forward PKCE to Nextcloud — the MCP server is a confidential client.
server_state = secrets.token_urlsafe(32)
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
requested_scope = request.query_params.get("scope", "")
default_scopes = "openid profile email"
resource_scopes = oauth_config.get("scopes", "")
scopes = f"{default_scopes} {resource_scopes}".strip()
if requested_scope:
# Merge client-requested scopes with server defaults
all_scopes = set(scopes.split()) | set(requested_scope.split())
scopes = " ".join(sorted(all_scopes))
# Pass through client's state directly
idp_state = state
# Store session for callback
_as_proxy_sessions[server_state] = ASProxySession(
client_id=client_id,
client_redirect_uri=redirect_uri,
client_state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
requested_scopes=scopes,
)
# Use client's own client_id (client must be pre-registered at IdP)
idp_client_id = client_id
# Use MCP server's own client_id with Nextcloud
mcp_server_client_id = os.getenv(
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
)
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback"
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)")
logger.info("AS Proxy: Intermediary authorization flow")
logger.info(f" Client: {client_id}")
logger.info(f" MCP server client_id: {mcp_server_client_id}")
logger.info(f" Server callback: {callback_uri}")
logger.info(f" Scopes: {scopes}")
# 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
# Discover Nextcloud 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,
)
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,
discovery = await _get_cached_discovery(discovery_url)
authorization_endpoint = discovery["authorization_endpoint"]
# Replace internal Docker hostname with public URL for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
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}"
)
if auth_parsed.query:
authorization_endpoint += f"?{auth_parsed.query}"
logger.info(
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
)
# 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"]
# Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client)
idp_params = {
"client_id": mcp_server_client_id,
"redirect_uri": callback_uri,
"response_type": "code",
"scope": scopes,
"state": server_state,
"prompt": "consent",
"resource": f"{mcp_server_url}/mcp", # MCP server audience
}
# 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)
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]}")
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)
@@ -355,11 +447,8 @@ async def oauth_authorize_nextcloud(
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"]
discovery = await _get_cached_discovery(discovery_url)
authorization_endpoint = discovery["authorization_endpoint"]
# Fix internal hostname for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
@@ -461,11 +550,17 @@ async def oauth_callback_nextcloud(request: Request):
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"]
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OIDC discovery URL not configured",
},
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Build token exchange params
token_params = {
@@ -599,6 +694,11 @@ async def oauth_callback(request: Request):
status_code=400,
)
# Check AS proxy sessions first (in-memory, ADR-023)
if state in _as_proxy_sessions:
logger.info("Routing to AS proxy callback (ADR-023)")
return await _oauth_callback_as_proxy(request, state)
# Lookup OAuth session to determine flow type
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
@@ -641,3 +741,580 @@ async def oauth_callback(request: Request):
},
status_code=400,
)
# ---------------------------------------------------------------------------
# AS Proxy endpoints (ADR-023)
# ---------------------------------------------------------------------------
async def _oauth_callback_as_proxy(
request: Request, server_state: str
) -> RedirectResponse | JSONResponse:
"""
Handle Nextcloud callback for the AS proxy flow.
Exchanges the Nextcloud auth code for tokens server-side, generates a
proxy authorization code, and redirects back to the client.
"""
# Check for errors from Nextcloud
error = request.query_params.get("error")
if error:
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"AS proxy callback error: {error} - {error_description}")
# Retrieve session to redirect back to client with error
session = _as_proxy_sessions.pop(server_state, None)
if session:
params = urlencode(
{
"error": error,
"error_description": error_description,
"state": session.client_state,
}
)
return RedirectResponse(
f"{session.client_redirect_uri}?{params}", status_code=302
)
return JSONResponse(
{"error": error, "error_description": error_description},
status_code=400,
)
code = request.query_params.get("code")
if not code:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "code parameter is required",
},
status_code=400,
)
# Retrieve and consume the session (one-time use)
session = _as_proxy_sessions.pop(server_state, None)
if not session:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Unknown or expired server state",
},
status_code=400,
)
if session.is_expired:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Authorization session expired",
},
status_code=400,
)
# Get OAuth context
oauth_ctx = request.app.state.oauth_context
oauth_config = oauth_ctx["config"]
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")
)
if not mcp_server_client_id or not mcp_server_client_secret:
return JSONResponse(
{
"error": "server_error",
"error_description": "MCP server OAuth credentials not configured",
},
status_code=500,
)
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback"
# Discover token endpoint
discovery_url = oauth_config.get("discovery_url")
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OIDC discovery URL not configured",
},
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Exchange auth code with Nextcloud (server-side, confidential client, no PKCE)
token_params = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": callback_uri,
"client_id": mcp_server_client_id,
"client_secret": mcp_server_client_secret,
}
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(token_endpoint, data=token_params)
if response.status_code != 200:
logger.error(
f"AS proxy token exchange failed: {response.status_code} {response.text}"
)
params = urlencode(
{
"error": "server_error",
"error_description": "Failed to exchange authorization code",
"state": session.client_state,
}
)
return RedirectResponse(
f"{session.client_redirect_uri}?{params}", status_code=302
)
nc_token_response = response.json()
logger.info(
"AS proxy: Successfully exchanged code for Nextcloud token "
f"(token_type={nc_token_response.get('token_type')})"
)
# Generate a proxy authorization code for the client
proxy_code = secrets.token_urlsafe(32)
_proxy_codes[proxy_code] = ProxyCodeEntry(
client_id=session.client_id,
client_redirect_uri=session.client_redirect_uri,
client_state=session.client_state,
code_challenge=session.code_challenge,
code_challenge_method=session.code_challenge_method,
nc_token_response=nc_token_response,
)
# Redirect back to client with proxy_code and client's original state
redirect_params = urlencode({"code": proxy_code, "state": session.client_state})
redirect_url = f"{session.client_redirect_uri}?{redirect_params}"
logger.info(
f"AS proxy: Redirecting to client with proxy_code (client_id={session.client_id})"
)
return RedirectResponse(redirect_url, status_code=302)
def _verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
"""Verify PKCE S256 code_verifier against stored code_challenge.
Per RFC 7636 Section 4.6:
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
"""
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed_challenge = urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return secrets.compare_digest(computed_challenge, code_challenge)
async def oauth_token_endpoint(request: Request) -> JSONResponse:
"""
OAuth token endpoint for AS proxy (ADR-023).
Handles:
- grant_type=authorization_code: Exchange proxy_code for Nextcloud token
- grant_type=refresh_token: Proxy refresh request to Nextcloud
Form parameters:
grant_type: "authorization_code" or "refresh_token"
code: Proxy authorization code (for authorization_code grant)
redirect_uri: Must match the original redirect_uri
code_verifier: PKCE verifier (for authorization_code grant)
client_id: Client identifier
client_secret: Client secret (optional for public clients)
refresh_token: Refresh token (for refresh_token grant)
"""
# Parse form body
form = await request.form()
grant_type = form.get("grant_type")
if grant_type == "authorization_code":
return await _token_authorization_code(request, form)
elif grant_type == "refresh_token":
return await _token_refresh(request, form)
else:
return JSONResponse(
{
"error": "unsupported_grant_type",
"error_description": f"Unsupported grant_type: {grant_type}",
},
status_code=400,
)
async def _token_authorization_code(request: Request, form) -> JSONResponse:
"""Handle authorization_code grant type at the token endpoint."""
code = form.get("code")
redirect_uri = form.get("redirect_uri")
code_verifier = form.get("code_verifier")
client_id = form.get("client_id")
logger.debug(
"AS proxy token: received code=%s client_id=%s redirect_uri=%s "
"code_verifier=%s",
code[:8] + "..." if code else None,
client_id,
redirect_uri,
"present" if code_verifier else "missing",
)
if not code:
logger.warning("AS proxy token: Missing 'code' parameter")
return JSONResponse(
{"error": "invalid_request", "error_description": "code is required"},
status_code=400,
)
# Look up and consume proxy code (one-time use)
entry = _proxy_codes.pop(code, None)
if not entry:
logger.warning(
"AS proxy token: Invalid or expired code (active_codes=%d)",
len(_proxy_codes),
)
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid or expired authorization code",
},
status_code=400,
)
if entry.is_expired:
age = time.time() - entry.created_at
logger.warning("AS proxy token: Proxy code expired (age=%.1fs, TTL=60s)", age)
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Authorization code has expired",
},
status_code=400,
)
# Validate client_id (required per RFC 6749 Section 4.1.3)
if not client_id:
logger.warning("AS proxy token: Missing 'client_id' parameter")
return JSONResponse(
{
"error": "invalid_request",
"error_description": "client_id is required",
},
status_code=400,
)
if client_id != entry.client_id:
logger.warning(
"AS proxy token: client_id mismatch (got=%s, expected=%s)",
client_id,
entry.client_id,
)
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "client_id mismatch",
},
status_code=400,
)
# Validate redirect_uri (required per RFC 6749 Section 4.1.3)
if not redirect_uri:
logger.warning("AS proxy token: Missing 'redirect_uri' parameter")
return JSONResponse(
{
"error": "invalid_request",
"error_description": "redirect_uri is required",
},
status_code=400,
)
if redirect_uri != entry.client_redirect_uri:
logger.warning(
"AS proxy token: redirect_uri mismatch (got=%s, expected=%s)",
redirect_uri,
entry.client_redirect_uri,
)
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "redirect_uri mismatch",
},
status_code=400,
)
# Verify PKCE (always required — oauth_authorize mandates code_challenge)
if not entry.code_challenge:
logger.error("AS proxy token: code_challenge missing from stored entry")
return JSONResponse(
{
"error": "server_error",
"error_description": "Internal state error: missing PKCE challenge",
},
status_code=500,
)
if not code_verifier:
logger.warning("AS proxy token: Missing 'code_verifier' (PKCE required)")
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "code_verifier is required (PKCE)",
},
status_code=400,
)
if not _verify_pkce_s256(code_verifier, entry.code_challenge):
logger.warning(f"PKCE verification failed for client {entry.client_id}")
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "PKCE verification failed",
},
status_code=400,
)
logger.info(
f"AS proxy token: Returning Nextcloud token for client {entry.client_id}"
)
# Return the stored Nextcloud token response directly
return JSONResponse(entry.nc_token_response)
async def _token_refresh(request: Request, form) -> JSONResponse:
"""Handle refresh_token grant type by proxying to Nextcloud."""
refresh_token = form.get("refresh_token")
if not refresh_token:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "refresh_token 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"]
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")
)
if not mcp_server_client_id or not mcp_server_client_secret:
return JSONResponse(
{
"error": "server_error",
"error_description": "MCP server OAuth credentials not configured",
},
status_code=500,
)
mcp_server_url = oauth_config["mcp_server_url"]
# Discover token endpoint
discovery_url = oauth_config.get("discovery_url")
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OIDC discovery URL not configured",
},
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Proxy refresh request to Nextcloud
token_params = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": mcp_server_client_id,
"client_secret": mcp_server_client_secret,
"resource": f"{mcp_server_url}/mcp",
}
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(token_endpoint, data=token_params)
if response.status_code != 200:
logger.error(
f"AS proxy token refresh failed: {response.status_code} {response.text}"
)
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Token refresh failed",
},
status_code=response.status_code,
)
return JSONResponse(response.json())
async def oauth_register_proxy(request: Request) -> JSONResponse:
"""
DCR proxy endpoint for AS proxy (ADR-023).
Proxies Dynamic Client Registration requests to Nextcloud's OIDC endpoint
and registers the resulting client in the local ClientRegistry.
This allows MCP clients to register via the MCP server (their AS) rather
than directly with Nextcloud (which would produce tokens with wrong audience).
"""
# Parse JSON body
try:
body = await request.json()
except Exception:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Request body must be valid JSON",
},
status_code=400,
)
# Get OAuth context for Nextcloud endpoint
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"]
nextcloud_host = oauth_config["nextcloud_host"]
# Rate limit DCR requests per client IP
client_ip = request.client.host if request.client else "unknown"
now = time.time()
timestamps = _dcr_rate_limit.get(client_ip, [])
# Remove timestamps outside the window
timestamps = [t for t in timestamps if now - t < _DCR_RATE_LIMIT_WINDOW]
if len(timestamps) >= _DCR_RATE_LIMIT_MAX:
logger.warning(f"DCR rate limit exceeded for {client_ip}")
return JSONResponse(
{
"error": "too_many_requests",
"error_description": "Rate limit exceeded for client registration",
},
status_code=429,
headers={"Retry-After": str(_DCR_RATE_LIMIT_WINDOW)},
)
timestamps.append(now)
_dcr_rate_limit[client_ip] = timestamps
# Discover registration endpoint from OIDC discovery (prefer over hardcoded path)
discovery_url = oauth_config.get("discovery_url")
if discovery_url:
try:
discovery = await _get_cached_discovery(discovery_url)
registration_endpoint = discovery.get(
"registration_endpoint", f"{nextcloud_host}/apps/oidc/register"
)
except Exception:
logger.warning(
"Failed to fetch OIDC discovery for DCR endpoint, using fallback"
)
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
else:
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
registration_endpoint,
json=body,
headers={"Content-Type": "application/json"},
)
if response.status_code not in (200, 201):
logger.error(
f"DCR proxy: Nextcloud registration failed: {response.status_code} {response.text}"
)
return JSONResponse(
response.json()
if response.headers.get("content-type", "").startswith("application/json")
else {
"error": "server_error",
"error_description": f"Upstream registration failed: {response.status_code}",
},
status_code=response.status_code,
)
nc_response = response.json()
new_client_id = nc_response.get("client_id")
if new_client_id:
# Register in local ClientRegistry so /oauth/authorize accepts it
redirect_uris = nc_response.get("redirect_uris", [])
client_name = nc_response.get("client_name", "")
registry = get_client_registry()
registry.register_proxy_client(
client_id=new_client_id,
redirect_uris=redirect_uris,
name=client_name,
)
logger.info(f"DCR proxy: Registered client {new_client_id} in local registry")
return JSONResponse(nc_response, status_code=response.status_code)
async def oauth_as_metadata(request: Request) -> JSONResponse:
"""
RFC 8414 OAuth Authorization Server Metadata endpoint (ADR-023).
Advertises the MCP server as its own OAuth Authorization Server so that
MCP clients (e.g., Claude Code) authenticate through the proxy rather
than directly with Nextcloud.
"""
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Dynamically discover scopes from registered tools if available
scopes_supported = ["openid", "profile", "email"]
app_scopes = getattr(request.app.state, "supported_scopes", None)
if app_scopes:
scopes_supported = app_scopes
return JSONResponse(
{
"issuer": mcp_server_url,
"authorization_endpoint": f"{mcp_server_url}/oauth/authorize",
"token_endpoint": f"{mcp_server_url}/oauth/token",
"registration_endpoint": f"{mcp_server_url}/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic",
"none",
],
"scopes_supported": scopes_supported,
}
)
@@ -1,6 +1,7 @@
"""Scope-based authorization for MCP tools."""
import logging
import time
from functools import wraps
from typing import Any, Callable
@@ -9,10 +10,18 @@ from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.utilities.context_injection import find_context_parameter
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
# Scopes that only assert identity (OIDC standard claims).
# Tools requiring *only* these scopes (e.g. auth provisioning tools) must
# bypass the Login Flow v2 "is the user provisioned?" check — otherwise the
# very tools that *create* app passwords would be blocked for unprovisioned
# users, creating a circular dependency.
IDENTITY_ONLY_SCOPES: frozenset[str] = frozenset({"openid", "profile", "email"})
class ScopeAuthorizationError(Exception):
"""Raised when a request lacks required scopes."""
@@ -120,13 +129,61 @@ def require_scopes(*required_scopes: str):
)
if access_token is None:
# Not in OAuth mode (BasicAuth or no auth)
# In BasicAuth mode, all operations are allowed
# No OAuth token — BasicAuth mode bypasses scope checks
logger.debug(
f"No access token present for {func_name} - allowing (BasicAuth mode)"
f"No access token for {func_name} - allowing (BasicAuth mode)"
)
return await func(*args, **kwargs)
# ── Login Flow v2: Check stored app password scopes ──
# In Login Flow v2 multi-user mode, OAuth tokens provide MCP session
# identity only. Nextcloud API access uses stored app passwords.
# Check if the user has a stored app password with appropriate scopes.
if get_settings().enable_login_flow and not set(required_scopes).issubset(
IDENTITY_ONLY_SCOPES
):
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
extract_user_id_from_token,
)
user_id = await extract_user_id_from_token(ctx)
if user_id and user_id != "default_user":
stored_scopes = await _get_stored_scopes(user_id)
if stored_scopes is None:
# No stored app password → require provisioning
error_msg = (
f"Access denied to {func_name}: "
f"Nextcloud access not provisioned. "
f"Please call 'nc_auth_provision_access' first."
)
logger.warning(error_msg)
raise ProvisioningRequiredError(error_msg)
if stored_scopes == "all":
# NULL scopes in DB = legacy app password = all allowed
logger.debug(
f"Stored app password scope check passed for {func_name}: all scopes"
)
return await func(*args, **kwargs)
# Check stored scopes against required
stored_set = set(stored_scopes)
missing = set(required_scopes) - stored_set
if missing:
error_msg = (
f"Access denied to {func_name}: "
f"Missing scopes: {', '.join(sorted(missing))}. "
f"Call 'nc_auth_update_scopes' to add permissions."
)
logger.warning(error_msg)
raise InsufficientScopeError(list(missing), error_msg)
logger.debug(
f"Stored app password scope check passed for {func_name}"
)
return await func(*args, **kwargs)
# Extract scopes from access token
token_scopes = set(access_token.scopes or [])
required_scopes_set = set(required_scopes)
@@ -416,3 +473,47 @@ def discover_all_scopes(mcp) -> list[str]:
# Return sorted list of unique scopes
return sorted(all_scopes)
# ── Login Flow v2 helpers ────────────────────────────────────────────────
# Scope cache: user_id → (expires_at, scopes)
_scope_cache: dict[str, tuple[float, list[str] | str | None]] = {}
_SCOPE_CACHE_TTL = 300 # 5 minutes
def invalidate_scope_cache(user_id: str) -> None:
"""Remove cached scopes for a user (call when scopes are updated)."""
_scope_cache.pop(user_id, None)
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
"""Look up stored app password scopes for a user (with TTL cache).
Returns:
- list[str]: Specific scopes granted
- "all": NULL scopes in DB (legacy = all allowed)
- None: No stored app password (provisioning required)
Raises:
Storage/infrastructure exceptions propagate to the caller
(require_scopes decorator) for proper MCP error responses.
"""
now = time.time()
if user_id in _scope_cache:
expires_at, cached = _scope_cache[user_id]
if now < expires_at:
return cached
storage = await get_shared_storage()
data = await storage.get_app_password_with_scopes(user_id)
if data is None:
result = None
elif data["scopes"] is None:
result = "all"
else:
result = data["scopes"]
_scope_cache[user_id] = (now + _SCOPE_CACHE_TTL, result)
return result
+414
View File
@@ -1477,6 +1477,420 @@ class RefreshTokenStorage:
return removed
# ── Login Flow v2: Scoped App Passwords ──────────────────────────────
async def store_app_password_with_scopes(
self,
user_id: str,
app_password: str,
scopes: list[str] | None = None,
username: str | None = None,
) -> None:
"""Store encrypted app password with optional scopes and Nextcloud username.
Args:
user_id: MCP user ID (identity from OAuth token or session)
app_password: Nextcloud app password to encrypt and store
scopes: List of granted scopes (None = all scopes allowed)
username: Nextcloud loginName from Login Flow v2 response
Raises:
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
# Defense-in-depth: validate scopes at storage layer
if scopes is not None:
from nextcloud_mcp_server.models.auth import ( # noqa: PLC0415
ALL_SUPPORTED_SCOPES,
)
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
raise ValueError(f"Invalid scopes: {invalid}")
encrypted_password = self.cipher.encrypt(app_password.encode())
scopes_json = json.dumps(scopes) if scopes is not None else None
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO app_passwords
(user_id, encrypted_password, created_at, updated_at, scopes, username)
VALUES (
?,
?,
COALESCE((SELECT created_at FROM app_passwords WHERE user_id = ?), ?),
?,
?,
?
)
""",
(
user_id,
encrypted_password,
user_id,
now,
now,
scopes_json,
username,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(
f"Stored scoped app password for user {user_id} "
f"(scopes={'all' if scopes is None else len(scopes)}, "
f"username={username or 'N/A'})"
)
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
await self._audit_log(
event="store_app_password_with_scopes",
user_id=user_id,
auth_method="app_password",
)
async def get_app_password_with_scopes(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve app password with scopes and metadata.
Args:
user_id: MCP user ID
Returns:
Dict with keys: app_password, scopes, username, created_at, updated_at
or None if not found
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for app password retrieval."
)
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT encrypted_password, scopes, username, created_at, updated_at
FROM app_passwords WHERE user_id = ?
""",
(user_id,),
) as cursor:
row = await cursor.fetchone()
if not row:
logger.debug(f"No app password found for user {user_id}")
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_password, scopes_json, username, created_at, updated_at = row
decrypted_password = self.cipher.decrypt(encrypted_password).decode()
scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return {
"app_password": decrypted_password,
"scopes": scopes,
"username": username,
"created_at": created_at,
"updated_at": updated_at,
}
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
raise
async def update_app_password_scopes(self, user_id: str, scopes: list[str]) -> bool:
"""Update only the scopes for an existing app password (no decrypt/re-encrypt).
Args:
user_id: MCP user ID
scopes: New scope list
Returns:
True if a row was updated, False if user not found
"""
if not self._initialized:
await self.initialize()
scopes_json = json.dumps(scopes)
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"UPDATE app_passwords SET scopes = ?, updated_at = ? WHERE user_id = ?",
(scopes_json, now, user_id),
)
await db.commit()
updated = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "update", duration, "success")
if updated:
await self._audit_log(
event="update_app_password_scopes",
user_id=user_id,
auth_method="app_password",
)
return updated
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "update", duration, "error")
raise
# ── Login Flow v2: Session Tracking ──────────────────────────────────
async def store_login_flow_session(
self,
user_id: str,
poll_token: str,
poll_endpoint: str,
requested_scopes: list[str] | None = None,
expires_at: int | None = None,
) -> None:
"""Store a Login Flow v2 polling session.
Args:
user_id: MCP user ID
poll_token: Token for polling (will be encrypted)
poll_endpoint: URL to poll for completion
requested_scopes: Scopes requested in this flow
expires_at: Expiration timestamp (defaults to 20 minutes from now)
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session storage."
)
encrypted_token = self.cipher.encrypt(poll_token.encode())
scopes_json = json.dumps(requested_scopes) if requested_scopes else None
now = int(time.time())
if expires_at is None:
expires_at = now + 1200 # 20 minutes default
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
INSERT OR REPLACE INTO login_flow_sessions
(user_id, encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
user_id,
encrypted_token,
poll_endpoint,
scopes_json,
now,
expires_at,
),
)
await db.commit()
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "success")
logger.info(f"Stored login flow session for user {user_id}")
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "insert", duration, "error")
raise
async def get_login_flow_session(self, user_id: str) -> dict[str, Any] | None:
"""Retrieve a pending Login Flow v2 session.
Returns None if session doesn't exist or has expired.
Args:
user_id: MCP user ID
Returns:
Dict with keys: poll_token, poll_endpoint, requested_scopes, created_at, expires_at
or None if not found/expired
"""
if not self._initialized:
await self.initialize()
if not self.cipher:
raise RuntimeError(
"Encryption key not configured. "
"Set TOKEN_ENCRYPTION_KEY for login flow session retrieval."
)
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"""
SELECT encrypted_poll_token, poll_endpoint, requested_scopes,
created_at, expires_at
FROM login_flow_sessions
WHERE user_id = ? AND expires_at > ?
""",
(user_id, now),
) as cursor:
row = await cursor.fetchone()
if not row:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return None
encrypted_token, poll_endpoint, scopes_json, created_at, expires_at = row
poll_token = self.cipher.decrypt(encrypted_token).decode()
requested_scopes = json.loads(scopes_json) if scopes_json else None
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "success")
return {
"poll_token": poll_token,
"poll_endpoint": poll_endpoint,
"requested_scopes": requested_scopes,
"created_at": created_at,
"expires_at": expires_at,
}
except Exception as e:
duration = time.time() - start_time
record_db_operation("sqlite", "select", duration, "error")
logger.error(
f"Failed to retrieve login flow session for user {user_id}: {e}"
)
raise
async def delete_login_flow_session(self, user_id: str) -> bool:
"""Delete a Login Flow v2 session.
Args:
user_id: MCP user ID
Returns:
True if session was deleted, False if not found
"""
if not self._initialized:
await self.initialize()
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE user_id = ?",
(user_id,),
)
await db.commit()
deleted = cursor.rowcount > 0
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if deleted:
logger.info(f"Deleted login flow session for user {user_id}")
await self._audit_log(
event="delete_login_flow_session",
user_id=user_id,
auth_method="login_flow",
)
return deleted
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
async def delete_expired_login_flow_sessions(self) -> int:
"""Delete all expired Login Flow v2 sessions.
Returns:
Number of sessions deleted
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
start_time = time.time()
try:
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM login_flow_sessions WHERE expires_at <= ?",
(now,),
)
await db.commit()
count = cursor.rowcount
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "success")
if count > 0:
logger.info(f"Cleaned up {count} expired login flow sessions")
await self._audit_log(
event="delete_expired_login_flow_sessions",
user_id="system",
auth_method="login_flow",
)
return count
except Exception:
duration = time.time() - start_time
record_db_operation("sqlite", "delete", duration, "error")
raise
_shared_instance: RefreshTokenStorage | None = None
_shared_lock: anyio.Lock = anyio.Lock()
async def get_shared_storage() -> RefreshTokenStorage:
"""Get the process-wide RefreshTokenStorage singleton (lock-protected).
All modules that need storage should use this function instead of
creating their own lazy singletons. The lock ensures thread-safe
initialization on concurrent first-access.
"""
global _shared_instance
async with _shared_lock:
if _shared_instance is None:
_shared_instance = RefreshTokenStorage.from_env()
await _shared_instance.initialize()
return _shared_instance
async def generate_encryption_key() -> str:
"""
+85
View File
@@ -0,0 +1,85 @@
"""Token utility functions for extracting user identity from MCP access tokens.
Extracted from server/oauth_tools.py to break circular import dependencies
between server/ and auth/ layers.
"""
import logging
import os
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def extract_user_id_from_token(ctx: Context) -> str:
"""Extract user_id from the MCP access token (Flow 1).
Handles both JWT and opaque tokens:
- JWT: Decode and extract 'sub' claim
- Opaque: Call userinfo endpoint to get 'sub'
Args:
ctx: MCP context with access token
Returns:
user_id extracted from token, or "default_user" as fallback
"""
# Use MCP SDK's get_access_token() which uses contextvars
access_token: AccessToken | None = get_access_token()
if not access_token or not access_token.token:
logger.warning(" ✗ No access token found via get_access_token()")
return "default_user"
token = access_token.token
is_jwt = "." in token and token.count(".") >= 2
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
# Try JWT decode first
if is_jwt:
try:
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown")
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
return user_id
except Exception as e:
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
# Opaque token - call userinfo endpoint
logger.info(" Opaque token detected, calling userinfo endpoint...")
try:
# Get userinfo endpoint from OIDC discovery
oidc_discovery_uri = os.getenv(
"OIDC_DISCOVERY_URI",
"http://localhost:8080/.well-known/openid-configuration",
)
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri)
discovery_response.raise_for_status()
discovery = discovery_response.json()
userinfo_endpoint = discovery.get("userinfo_endpoint")
if userinfo_endpoint:
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
if userinfo:
user_id = userinfo.get("sub", "unknown")
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
return user_id
else:
logger.error(" ✗ Userinfo query failed")
else:
logger.error(" ✗ No userinfo_endpoint available")
except Exception as e:
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
# Fallback
logger.warning(" Using fallback user_id: default_user")
return "default_user"
+31 -24
View File
@@ -5,7 +5,7 @@ import socket
import ssl
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
from typing import Any
class DeploymentMode(Enum):
@@ -169,31 +169,32 @@ class Settings:
# Optional: If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery
deployment_mode: Optional[str] = None
deployment_mode: str | None = None
# OAuth/OIDC settings
oidc_discovery_url: Optional[str] = None
oidc_client_id: Optional[str] = None
oidc_client_secret: Optional[str] = None
oidc_issuer: Optional[str] = None
oidc_discovery_url: str | None = None
oidc_client_id: str | None = None
oidc_client_secret: str | None = None
oidc_issuer: str | None = None
# Nextcloud settings
nextcloud_host: Optional[str] = None
nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None
nextcloud_host: str | None = None
nextcloud_username: str | None = None
nextcloud_password: str | None = None
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl: bool = True
nextcloud_ca_bundle: Optional[str] = None
nextcloud_ca_bundle: str | None = None
# ADR-005: Token Audience Validation (required for OAuth mode)
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
# Token verification endpoints
jwks_uri: Optional[str] = None
introspection_uri: Optional[str] = None
userinfo_uri: Optional[str] = None
jwks_uri: str | None = None
introspection_uri: str | None = None
userinfo_uri: str | None = None
# Progressive Consent settings (always enabled - no flag needed)
enable_token_exchange: bool = False
@@ -204,6 +205,9 @@ class Settings:
# and passes them through to Nextcloud APIs (no storage, stateless)
enable_multi_user_basic_auth: bool = False
# Login Flow v2 settings (ADR-022)
enable_login_flow: bool = False
# Token exchange cache settings
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
@@ -214,8 +218,8 @@ class Settings:
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
# Used for webhook tracking (all modes) and OAuth token storage.
# Defaults to /tmp/tokens.db
token_encryption_key: Optional[str] = None
token_storage_db: Optional[str] = None
token_encryption_key: str | None = None
token_storage_db: str | None = None
# Vector sync settings (ADR-007)
vector_sync_enabled: bool = False
@@ -225,19 +229,19 @@ class Settings:
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
# Qdrant settings (mutually exclusive modes)
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
qdrant_api_key: Optional[str] = None
qdrant_url: str | None = None # Network mode: http://qdrant:6333
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
qdrant_api_key: str | None = None
qdrant_collection: str = "nextcloud_content"
# Ollama settings (for embeddings)
ollama_base_url: Optional[str] = None
ollama_base_url: str | None = None
ollama_embedding_model: str = "nomic-embed-text"
ollama_verify_ssl: bool = True
# OpenAI settings (for embeddings)
openai_api_key: Optional[str] = None
openai_base_url: Optional[str] = None
openai_api_key: str | None = None
openai_base_url: str | None = None
openai_embedding_model: str = "text-embedding-3-small"
# Document chunking settings (for vector embeddings)
@@ -247,7 +251,7 @@ class Settings:
# Observability settings
metrics_enabled: bool = True
metrics_port: int = 9090
otel_exporter_otlp_endpoint: Optional[str] = None
otel_exporter_otlp_endpoint: str | None = None
otel_exporter_verify_ssl: bool = False
otel_service_name: str = "nextcloud-mcp-server"
otel_traces_sampler: str = "always_on"
@@ -523,6 +527,7 @@ def get_settings() -> Settings:
nextcloud_host=os.getenv("NEXTCLOUD_HOST"),
nextcloud_username=os.getenv("NEXTCLOUD_USERNAME"),
nextcloud_password=os.getenv("NEXTCLOUD_PASSWORD"),
nextcloud_app_password=os.getenv("NEXTCLOUD_APP_PASSWORD"),
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl=(
os.getenv("NEXTCLOUD_VERIFY_SSL", "true").lower() == "true"
@@ -544,6 +549,8 @@ def get_settings() -> Settings:
enable_multi_user_basic_auth=(
os.getenv("ENABLE_MULTI_USER_BASIC_AUTH", "false").lower() == "true"
),
# Login Flow v2 settings (ADR-022)
enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"),
# Token exchange cache settings
token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")),
# Token and webhook storage settings (encryption key optional for webhook-only usage)
+55
View File
@@ -9,6 +9,8 @@ from nextcloud_mcp_server.auth.context_helper import (
get_client_from_context,
get_session_client_from_context,
)
from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
@@ -78,6 +80,11 @@ async def get_client(ctx: Context) -> NextcloudClient:
lifespan_ctx = ctx.request_context.lifespan_context
# Login Flow v2 multi-user mode: app password is REQUIRED for NC API access
# OAuth token is only used for MCP session identity, not NC API calls
if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_login_flow:
return await _get_client_from_login_flow(ctx, lifespan_ctx.nextcloud_host)
# BasicAuth mode - use shared client (no token exchange)
if hasattr(lifespan_ctx, "client"):
return lifespan_ctx.client
@@ -245,3 +252,51 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
username=username,
auth=BasicAuth(username, password),
)
async def _get_client_from_login_flow(
ctx: Context, nextcloud_host: str
) -> NextcloudClient:
"""Create NextcloudClient from stored Login Flow v2 app password.
In Login Flow v2 mode, the OAuth token only provides MCP session identity.
Nextcloud API calls always use the stored app password obtained via Login Flow v2.
Args:
ctx: MCP context (used to extract user identity)
nextcloud_host: Nextcloud instance URL
Returns:
NextcloudClient with stored app password credentials
Raises:
ProvisioningRequiredError: If no stored app password exists
"""
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
extract_user_id_from_token,
)
user_id = await extract_user_id_from_token(ctx)
if not user_id or user_id == "default_user":
raise ProvisioningRequiredError(
"Cannot determine user identity from MCP token."
)
storage = await get_shared_storage()
app_data = await storage.get_app_password_with_scopes(user_id)
if not app_data:
raise ProvisioningRequiredError(
"Nextcloud access not provisioned. "
"Call nc_auth_provision_access to complete Login Flow."
)
username = app_data.get("username") or user_id
logger.debug(f"Creating Login Flow v2 client for {nextcloud_host} as {username}")
return NextcloudClient(
base_url=nextcloud_host,
username=username,
auth=BasicAuth(username, app_data["app_password"]),
)
+78
View File
@@ -0,0 +1,78 @@
"""Pydantic response models for Login Flow v2 auth tools."""
from pydantic import Field
from nextcloud_mcp_server.models.base import BaseResponse
class ProvisionAccessResponse(BaseResponse):
"""Response from nc_auth_provision_access tool."""
status: str = Field(
description="Provisioning status: 'login_required', 'already_provisioned', 'declined', 'cancelled', 'error'"
)
login_url: str | None = Field(
None, description="URL to open in browser for Nextcloud login"
)
message: str = Field(description="Human-readable status message")
user_id: str | None = Field(None, description="MCP user ID")
requested_scopes: list[str] | None = Field(
None, description="Scopes requested in this provisioning flow"
)
class ProvisionStatusResponse(BaseResponse):
"""Response from nc_auth_check_status tool."""
status: str = Field(
description="Status: 'provisioned', 'pending', 'not_initiated', 'error'"
)
message: str = Field(description="Human-readable status message")
user_id: str | None = Field(None, description="MCP user ID")
scopes: list[str] | None = Field(
None, description="Granted scopes (None = all scopes)"
)
username: str | None = Field(None, description="Nextcloud username (loginName)")
class UpdateScopesResponse(BaseResponse):
"""Response from nc_auth_update_scopes tool."""
status: str = Field(
description="Status: 'login_required', 'unchanged', 'declined', 'cancelled', 'error'"
)
login_url: str | None = Field(
None, description="URL for re-provisioning with new scopes"
)
message: str = Field(description="Human-readable status message")
previous_scopes: list[str] | None = Field(
None, description="Previously granted scopes"
)
new_scopes: list[str] | None = Field(None, description="Updated scope set")
# All supported application-level scopes (frozenset for O(1) membership tests)
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
{
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"files:read",
"files:write",
"tables:read",
"tables:write",
"deck:read",
"deck:write",
"cookbook:read",
"cookbook:write",
"sharing:read",
"sharing:write",
"news:read",
"news:write",
}
)
+493
View File
@@ -0,0 +1,493 @@
"""MCP tools for Login Flow v2 authentication (ADR-022).
Provides tools for users to provision Nextcloud access via Login Flow v2,
check provisioning status, and update granted scopes.
These tools work alongside (not replacing) the existing OAuth provisioning
tools during the migration period.
"""
import logging
from mcp.server.fastmcp import Context, FastMCP
from mcp.types import ToolAnnotations
from nextcloud_mcp_server.auth.elicitation import present_login_url
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
from nextcloud_mcp_server.auth.scope_authorization import (
invalidate_scope_cache,
require_scopes,
)
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
from nextcloud_mcp_server.models.auth import (
ALL_SUPPORTED_SCOPES,
ProvisionAccessResponse,
ProvisionStatusResponse,
UpdateScopesResponse,
)
logger = logging.getLogger(__name__)
def register_auth_tools(mcp: FastMCP) -> None:
"""Register Login Flow v2 auth tools with the MCP server."""
@mcp.tool(
name="nc_auth_provision_access",
title="Provision Nextcloud Access",
description=(
"Start Nextcloud Login Flow v2 to obtain an app password. "
"This is required before using any Nextcloud tools. "
"You will be given a URL to open in your browser to log in."
),
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True,
),
)
@require_scopes("openid")
async def nc_auth_provision_access(
ctx: Context,
scopes: list[str] | None = None,
) -> ProvisionAccessResponse:
"""Provision Nextcloud access via Login Flow v2.
Args:
ctx: MCP context
scopes: Requested application scopes (e.g. ["notes:read", "calendar:write"]).
If not specified, all available scopes are requested.
Returns:
ProvisionAccessResponse with login URL or status
"""
user_id = await extract_user_id_from_token(ctx)
if user_id == "default_user":
return ProvisionAccessResponse(
status="error",
message="Could not determine user identity from MCP token.",
success=False,
)
storage = await get_shared_storage()
# Check if already provisioned
existing = await storage.get_app_password_with_scopes(user_id)
if existing:
return ProvisionAccessResponse(
status="already_provisioned",
message=(
f"Nextcloud access already provisioned for {user_id}. "
f"Scopes: {existing['scopes'] or 'all'}. "
f"Use nc_auth_update_scopes to modify permissions."
),
user_id=user_id,
requested_scopes=existing["scopes"],
)
# Determine scopes
requested_scopes = scopes if scopes else list(ALL_SUPPORTED_SCOPES)
# Validate requested scopes
invalid_scopes = [s for s in requested_scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid_scopes:
return ProvisionAccessResponse(
status="error",
message=f"Invalid scopes: {', '.join(invalid_scopes)}. "
f"Valid scopes: {', '.join(sorted(ALL_SUPPORTED_SCOPES))}",
success=False,
)
# Initiate Login Flow v2
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
return ProvisionAccessResponse(
status="error",
message="NEXTCLOUD_HOST not configured on the server.",
success=False,
)
try:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
)
init_response = await flow_client.initiate()
except Exception as e:
logger.error(f"Failed to initiate Login Flow v2: {e}")
return ProvisionAccessResponse(
status="error",
message=f"Failed to start login flow: {e}",
success=False,
)
# Store the polling session
await storage.store_login_flow_session(
user_id=user_id,
poll_token=init_response.poll_token,
poll_endpoint=init_response.poll_endpoint,
requested_scopes=requested_scopes,
)
# Present login URL to user via elicitation
elicitation_result = await present_login_url(ctx, init_response.login_url)
if elicitation_result == "declined":
await storage.delete_login_flow_session(user_id)
return ProvisionAccessResponse(
status="declined",
message="Login flow declined. Call nc_auth_provision_access again to retry.",
user_id=user_id,
success=False,
)
if elicitation_result == "cancelled":
await storage.delete_login_flow_session(user_id)
return ProvisionAccessResponse(
status="cancelled",
message="Login flow cancelled. Call nc_auth_provision_access again to retry.",
user_id=user_id,
success=False,
)
message = (
f"Please open this URL in your browser to log in to Nextcloud:\n\n"
f"{init_response.login_url}\n\n"
f"After logging in, call nc_auth_check_status to complete provisioning."
)
if elicitation_result == "accepted":
message = (
"Login acknowledged. Call nc_auth_check_status to verify "
"and complete provisioning."
)
return ProvisionAccessResponse(
status="pending",
login_url=init_response.login_url,
message=message,
user_id=user_id,
requested_scopes=requested_scopes,
)
return ProvisionAccessResponse(
status="login_required",
login_url=init_response.login_url,
message=message,
user_id=user_id,
requested_scopes=requested_scopes,
)
@mcp.tool(
name="nc_auth_check_status",
title="Check Nextcloud Access Status",
description=(
"Check if Nextcloud access has been provisioned. "
"If a Login Flow is pending, this will poll for completion. "
"Recommended polling interval: 5 seconds."
),
annotations=ToolAnnotations(
readOnlyHint=True,
idempotentHint=True,
openWorldHint=True,
),
)
@require_scopes("openid")
async def nc_auth_check_status(
ctx: Context,
) -> ProvisionStatusResponse:
"""Check provisioning status and poll pending Login Flows.
Returns:
ProvisionStatusResponse with current status
"""
user_id = await extract_user_id_from_token(ctx)
if user_id == "default_user":
return ProvisionStatusResponse(
status="error",
message="Could not determine user identity from MCP token.",
success=False,
)
storage = await get_shared_storage()
# Check for existing app password
existing = await storage.get_app_password_with_scopes(user_id)
if existing:
return ProvisionStatusResponse(
status="provisioned",
message=f"Nextcloud access is provisioned for {existing.get('username') or user_id}.",
user_id=user_id,
scopes=existing["scopes"],
username=existing.get("username"),
)
# Check for pending login flow session
try:
session = await storage.get_login_flow_session(user_id)
except Exception as e:
logger.error(f"Failed to check login flow session for {user_id}: {e}")
return ProvisionStatusResponse(
status="error",
message=f"Failed to check login flow session: {e}",
user_id=user_id,
success=False,
)
if not session:
return ProvisionStatusResponse(
status="not_initiated",
message=(
"No provisioning in progress. "
"Call nc_auth_provision_access to start."
),
user_id=user_id,
)
# Poll the Login Flow
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
return ProvisionStatusResponse(
status="error",
message="NEXTCLOUD_HOST not configured.",
success=False,
)
try:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
)
poll_result = await flow_client.poll(
poll_endpoint=session["poll_endpoint"],
poll_token=session["poll_token"],
)
except Exception as e:
logger.error(f"Failed to poll Login Flow v2: {e}")
return ProvisionStatusResponse(
status="error",
message=f"Failed to check login status: {e}",
success=False,
)
if poll_result.status == "completed":
# Store the app password with scopes
if poll_result.app_password is None:
return ProvisionStatusResponse(
status="error",
message="Login Flow completed but no app password was returned.",
success=False,
)
await storage.store_app_password_with_scopes(
user_id=user_id,
app_password=poll_result.app_password,
scopes=session.get("requested_scopes"),
username=poll_result.login_name,
)
invalidate_scope_cache(user_id)
# Clean up the flow session
await storage.delete_login_flow_session(user_id)
return ProvisionStatusResponse(
status="provisioned",
message=f"Nextcloud access provisioned successfully as {poll_result.login_name}.",
user_id=user_id,
scopes=session.get("requested_scopes"),
username=poll_result.login_name,
)
if poll_result.status == "expired":
# Clean up expired session
await storage.delete_login_flow_session(user_id)
return ProvisionStatusResponse(
status="not_initiated",
message=(
"Login flow expired. "
"Call nc_auth_provision_access to start a new one."
),
user_id=user_id,
)
# Still pending
return ProvisionStatusResponse(
status="pending",
message=(
"Login flow is still pending. "
"Please complete the login in your browser, then call this tool again."
),
user_id=user_id,
)
@mcp.tool(
name="nc_auth_update_scopes",
title="Update Nextcloud Access Scopes",
description=(
"Update the scopes for your Nextcloud access. "
"This starts a new Login Flow with the combined scope set. "
"The current app password remains valid until the new one is obtained."
),
annotations=ToolAnnotations(
idempotentHint=False,
openWorldHint=True,
),
)
@require_scopes("openid")
async def nc_auth_update_scopes(
ctx: Context,
add_scopes: list[str] | None = None,
remove_scopes: list[str] | None = None,
) -> UpdateScopesResponse:
"""Update granted scopes by re-provisioning with merged scope set.
Args:
ctx: MCP context
add_scopes: Scopes to add to the current set
remove_scopes: Scopes to remove from the current set
Returns:
UpdateScopesResponse with new login URL or status
"""
user_id = await extract_user_id_from_token(ctx)
if user_id == "default_user":
return UpdateScopesResponse(
status="error",
message="Could not determine user identity from MCP token.",
success=False,
)
if not add_scopes and not remove_scopes:
return UpdateScopesResponse(
status="error",
message="Provide add_scopes and/or remove_scopes to update.",
success=False,
)
storage = await get_shared_storage()
# Get current state - require existing provisioning
existing = await storage.get_app_password_with_scopes(user_id)
if existing is None:
return UpdateScopesResponse(
status="error",
message="Not provisioned. Call nc_auth_provision_access first.",
success=False,
)
previous_scopes = existing["scopes"]
# Compute new scope set
current_set = (
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
)
if add_scopes:
invalid = [s for s in add_scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
return UpdateScopesResponse(
status="error",
message=f"Invalid scopes: {', '.join(invalid)}",
success=False,
)
current_set.update(add_scopes)
if remove_scopes:
current_set -= set(remove_scopes)
new_scopes = sorted(current_set)
if not new_scopes:
return UpdateScopesResponse(
status="error",
message="Cannot remove all scopes. At least one scope must remain.",
success=False,
)
# No-op detection: skip Login Flow if scopes are unchanged
previous_scopes_set = (
set(previous_scopes) if previous_scopes else set(ALL_SUPPORTED_SCOPES)
)
if set(new_scopes) == previous_scopes_set:
return UpdateScopesResponse(
status="unchanged",
message="Requested scopes match current scopes. No changes needed.",
previous_scopes=previous_scopes,
new_scopes=new_scopes,
)
# Initiate new Login Flow v2
# Note: existing app password stays valid until the new flow completes.
# store_app_password_with_scopes() does an upsert, so the old password
# is replaced atomically when nc_auth_check_status stores the new one.
settings = get_settings()
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
return UpdateScopesResponse(
status="error",
message="NEXTCLOUD_HOST not configured.",
success=False,
)
try:
flow_client = LoginFlowV2Client(
nextcloud_host=nextcloud_host,
verify_ssl=get_nextcloud_ssl_verify(),
)
init_response = await flow_client.initiate()
except Exception as e:
logger.error(f"Failed to initiate Login Flow v2 for scope update: {e}")
return UpdateScopesResponse(
status="error",
message=f"Failed to start re-provisioning flow: {e}",
success=False,
)
# Store new flow session
await storage.store_login_flow_session(
user_id=user_id,
poll_token=init_response.poll_token,
poll_endpoint=init_response.poll_endpoint,
requested_scopes=new_scopes,
)
# Present login URL
elicitation_result = await present_login_url(ctx, init_response.login_url)
if elicitation_result == "declined":
await storage.delete_login_flow_session(user_id)
return UpdateScopesResponse(
status="declined",
message="Scope update declined. Call nc_auth_update_scopes again to retry.",
previous_scopes=previous_scopes if previous_scopes else None,
new_scopes=new_scopes,
success=False,
)
if elicitation_result == "cancelled":
await storage.delete_login_flow_session(user_id)
return UpdateScopesResponse(
status="cancelled",
message="Scope update cancelled. Call nc_auth_update_scopes again to retry.",
previous_scopes=previous_scopes if previous_scopes else None,
new_scopes=new_scopes,
success=False,
)
message = (
f"Scope update requires re-authentication.\n\n"
f"Please open this URL to log in:\n{init_response.login_url}\n\n"
f"After logging in, call nc_auth_check_status to complete."
)
if elicitation_result == "accepted":
message = (
"Login acknowledged for scope update. "
"Call nc_auth_check_status to verify and complete."
)
return UpdateScopesResponse(
status="login_required",
login_url=init_response.login_url,
message=message,
previous_scopes=previous_scopes if previous_scopes else None,
new_scopes=new_scopes,
)
+5 -72
View File
@@ -12,9 +12,6 @@ from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlencode
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
@@ -23,80 +20,16 @@ from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
# Re-export for backward compatibility — canonical location is auth.token_utils
from nextcloud_mcp_server.auth.token_utils import (
extract_user_id_from_token as extract_user_id_from_token, # noqa: PLC0414
)
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def extract_user_id_from_token(ctx: Context) -> str:
"""Extract user_id from the MCP access token (Flow 1).
Handles both JWT and opaque tokens:
- JWT: Decode and extract 'sub' claim
- Opaque: Call userinfo endpoint to get 'sub'
Args:
ctx: MCP context with access token
Returns:
user_id extracted from token, or "default_user" as fallback
"""
# Use MCP SDK's get_access_token() which uses contextvars
access_token: AccessToken | None = get_access_token()
if not access_token or not access_token.token:
logger.warning(" ✗ No access token found via get_access_token()")
return "default_user"
token = access_token.token
is_jwt = "." in token and token.count(".") >= 2
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
# Try JWT decode first
if is_jwt:
try:
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown")
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
return user_id
except Exception as e:
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
# Opaque token - call userinfo endpoint
logger.info(" Opaque token detected, calling userinfo endpoint...")
try:
# Get userinfo endpoint from OIDC discovery
oidc_discovery_uri = os.getenv(
"OIDC_DISCOVERY_URI",
"http://localhost:8080/.well-known/openid-configuration",
)
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri)
discovery_response.raise_for_status()
discovery = discovery_response.json()
userinfo_endpoint = discovery.get("userinfo_endpoint")
if userinfo_endpoint:
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
if userinfo:
user_id = userinfo.get("sub", "unknown")
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
return user_id
else:
logger.error(" ✗ Userinfo query failed")
else:
logger.error(" ✗ No userinfo_endpoint available")
except Exception as e:
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
# Fallback
logger.warning(" Using fallback user_id: default_user")
return "default_user"
class ProvisioningStatus(BaseModel):
"""Status of Nextcloud provisioning for a user."""