feat: add Docker Compose profiles and Login Flow v2 service
Add selective service startup via Docker Compose profiles so each MCP deployment mode runs independently. Also add the new mcp-login-flow service (port 8004) for Login Flow v2 authentication (ADR-022). Profile assignments: - single-user: mcp (port 8000) - multi-user-basic: mcp-multi-user-basic (port 8003) - oauth: mcp-oauth (port 8001) - keycloak: keycloak + mcp-keycloak (port 8002) - login-flow: mcp-login-flow (port 8004) Infrastructure services (db, redis, app, recipes) always start. Integration tests cover the full Login Flow v2 provisioning flow: OAuth → browser login → app password → Nextcloud API access for notes, calendar, contacts, files, deck, and cookbook operations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
"""
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""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.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.
|
||||
"""
|
||||
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": 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,
|
||||
)
|
||||
|
||||
# Re-store with updated scopes (password and username unchanged)
|
||||
await storage.store_app_password_with_scopes(
|
||||
user_id=username,
|
||||
app_password=existing["app_password"],
|
||||
scopes=scopes,
|
||||
username=existing.get("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: Request) -> JSONResponse:
|
||||
"""GET /api/v1/scopes - List all supported application-level scopes."""
|
||||
return JSONResponse(
|
||||
{
|
||||
"success": True,
|
||||
"scopes": ALL_SUPPORTED_SCOPES,
|
||||
}
|
||||
)
|
||||
@@ -288,10 +288,28 @@ 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)
|
||||
|
||||
if scopes is not None or nc_username is not None:
|
||||
# New path: store with scopes and username
|
||||
await storage.store_app_password_with_scopes(
|
||||
username, app_password, scopes=scopes, username=nc_username
|
||||
)
|
||||
else:
|
||||
# Legacy path: store without scopes
|
||||
await storage.store_app_password(username, app_password)
|
||||
|
||||
_record_rate_limit_attempt(path_user_id, success=True)
|
||||
logger.info(f"Provisioned app password for user: {username}")
|
||||
@@ -300,6 +318,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
|
||||
{
|
||||
"success": True,
|
||||
"message": f"App password stored for {username}",
|
||||
"scopes": scopes,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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 (
|
||||
@@ -123,6 +126,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 +1472,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
|
||||
@@ -2208,10 +2217,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"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""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."
|
||||
)
|
||||
|
||||
try:
|
||||
result = await ctx.elicit(
|
||||
message=message,
|
||||
schema=LoginFlowConfirmation,
|
||||
)
|
||||
|
||||
if result.action == "accept":
|
||||
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 Exception as e:
|
||||
# Elicitation not supported by this client - fall back to message
|
||||
logger.debug(f"Elicitation not available ({e}), returning URL in message")
|
||||
return "message_only"
|
||||
@@ -0,0 +1,145 @@
|
||||
"""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.
|
||||
|
||||
Posts to /index.php/login/v2 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", {})
|
||||
|
||||
result = LoginFlowInitResponse(
|
||||
login_url=data["login"],
|
||||
poll_endpoint=poll_data["endpoint"],
|
||||
poll_token=poll_data["token"],
|
||||
)
|
||||
|
||||
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.
|
||||
|
||||
Posts to the poll endpoint with the token. 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')}"
|
||||
)
|
||||
return LoginFlowPollResult(
|
||||
status="completed",
|
||||
server=data["server"],
|
||||
login_name=data["loginName"],
|
||||
app_password=data["appPassword"],
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Scope-based authorization for MCP tools."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
@@ -120,6 +121,16 @@ def require_scopes(*required_scopes: str):
|
||||
)
|
||||
|
||||
if access_token is None:
|
||||
# Check if single-user BasicAuth mode (env var app password)
|
||||
# If NEXTCLOUD_APP_PASSWORD or NEXTCLOUD_PASSWORD is set, bypass scope checks
|
||||
if os.getenv("NEXTCLOUD_APP_PASSWORD") or os.getenv(
|
||||
"NEXTCLOUD_PASSWORD"
|
||||
):
|
||||
logger.debug(
|
||||
f"No access token for {func_name} - allowing (env var app password)"
|
||||
)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
# Not in OAuth mode (BasicAuth or no auth)
|
||||
# In BasicAuth mode, all operations are allowed
|
||||
logger.debug(
|
||||
@@ -127,6 +138,53 @@ def require_scopes(*required_scopes: str):
|
||||
)
|
||||
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 _is_login_flow_mode():
|
||||
from nextcloud_mcp_server.server.oauth_tools 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 +474,46 @@ def discover_all_scopes(mcp) -> list[str]:
|
||||
|
||||
# Return sorted list of unique scopes
|
||||
return sorted(all_scopes)
|
||||
|
||||
|
||||
# ── Login Flow v2 helpers ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _is_login_flow_mode() -> bool:
|
||||
"""Check if server is configured for Login Flow v2 multi-user mode.
|
||||
|
||||
Login Flow v2 mode is active when:
|
||||
- ENABLE_LOGIN_FLOW=true is set, OR
|
||||
- Multi-user BasicAuth with offline access (uses stored app passwords)
|
||||
|
||||
Returns:
|
||||
True if Login Flow v2 enforcement should be active
|
||||
"""
|
||||
if os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
|
||||
"""Look up stored app password scopes for a user.
|
||||
|
||||
Returns:
|
||||
- list[str]: Specific scopes granted
|
||||
- "all": NULL scopes in DB (legacy = all allowed)
|
||||
- None: No stored app password (provisioning required)
|
||||
"""
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage # noqa: PLC0415
|
||||
|
||||
try:
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
data = await storage.get_app_password_with_scopes(user_id)
|
||||
if data is None:
|
||||
return None
|
||||
if data["scopes"] is None:
|
||||
return "all"
|
||||
return data["scopes"]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check stored scopes for {user_id}: {e}")
|
||||
return None
|
||||
|
||||
@@ -1477,6 +1477,339 @@ 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
|
||||
"""
|
||||
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."
|
||||
)
|
||||
|
||||
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 as e:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "select", duration, "error")
|
||||
logger.error(
|
||||
f"Failed to retrieve scoped app password for user {user_id}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
# ── 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}"
|
||||
)
|
||||
return None
|
||||
|
||||
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}")
|
||||
|
||||
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")
|
||||
|
||||
return count
|
||||
|
||||
except Exception:
|
||||
duration = time.time() - start_time
|
||||
record_db_operation("sqlite", "delete", duration, "error")
|
||||
raise
|
||||
|
||||
|
||||
async def generate_encryption_key() -> str:
|
||||
"""
|
||||
|
||||
@@ -181,6 +181,7 @@ class Settings:
|
||||
nextcloud_host: Optional[str] = None
|
||||
nextcloud_username: Optional[str] = None
|
||||
nextcloud_password: Optional[str] = None
|
||||
nextcloud_app_password: Optional[str] = None # Preferred over nextcloud_password
|
||||
|
||||
# Nextcloud SSL/TLS settings
|
||||
nextcloud_verify_ssl: bool = True
|
||||
@@ -204,6 +205,13 @@ 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
|
||||
login_flow_poll_interval: int = 2 # seconds between polls
|
||||
login_flow_poll_timeout: int = 300 # max seconds to wait for completion
|
||||
login_flow_cleanup_interval: int = 3600 # seconds between expired session cleanup
|
||||
app_password_max_age_days: int = 0 # 0 = no expiration
|
||||
|
||||
# Token exchange cache settings
|
||||
token_exchange_cache_ttl: int = 300 # seconds (5 minutes default)
|
||||
|
||||
@@ -260,6 +268,14 @@ class Settings:
|
||||
"""Validate configuration and set defaults."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Deprecation warning: NEXTCLOUD_PASSWORD without NEXTCLOUD_APP_PASSWORD
|
||||
if self.nextcloud_password and not self.nextcloud_app_password:
|
||||
logger.warning(
|
||||
"NEXTCLOUD_PASSWORD is deprecated for app password usage. "
|
||||
"Please use NEXTCLOUD_APP_PASSWORD instead. "
|
||||
"Support for NEXTCLOUD_PASSWORD as app password will be removed in v1.0.0."
|
||||
)
|
||||
|
||||
# Validate SSL/TLS configuration
|
||||
if not self.nextcloud_verify_ssl:
|
||||
logger.warning(
|
||||
@@ -523,6 +539,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 +561,14 @@ 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"),
|
||||
login_flow_poll_interval=int(os.getenv("LOGIN_FLOW_POLL_INTERVAL", "2")),
|
||||
login_flow_poll_timeout=int(os.getenv("LOGIN_FLOW_POLL_TIMEOUT", "300")),
|
||||
login_flow_cleanup_interval=int(
|
||||
os.getenv("LOGIN_FLOW_CLEANUP_INTERVAL", "3600")
|
||||
),
|
||||
app_password_max_age_days=int(os.getenv("APP_PASSWORD_MAX_AGE_DAYS", "0")),
|
||||
# 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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Helper functions for accessing context in MCP tools."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from httpx import BasicAuth
|
||||
from mcp.server.fastmcp import Context
|
||||
@@ -9,6 +10,7 @@ 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.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 _is_login_flow_mode():
|
||||
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,58 @@ def _get_client_from_basic_auth(ctx: Context) -> NextcloudClient:
|
||||
username=username,
|
||||
auth=BasicAuth(username, password),
|
||||
)
|
||||
|
||||
|
||||
def _is_login_flow_mode() -> bool:
|
||||
"""Check if Login Flow v2 multi-user mode is active."""
|
||||
return os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"
|
||||
|
||||
|
||||
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.storage import RefreshTokenStorage # noqa: PLC0415
|
||||
from nextcloud_mcp_server.server.oauth_tools 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 = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
|
||||
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"]),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
"""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', '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', 'updated', '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
|
||||
ALL_SUPPORTED_SCOPES = [
|
||||
"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",
|
||||
]
|
||||
@@ -0,0 +1,417 @@
|
||||
"""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
|
||||
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 require_scopes
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
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,
|
||||
)
|
||||
from nextcloud_mcp_server.server.oauth_tools import extract_user_id_from_token
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_storage() -> RefreshTokenStorage:
|
||||
"""Get initialized storage instance."""
|
||||
storage = RefreshTokenStorage.from_env()
|
||||
await storage.initialize()
|
||||
return storage
|
||||
|
||||
|
||||
def register_auth_tools(mcp) -> 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_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 ALL_SUPPORTED_SCOPES.copy()
|
||||
|
||||
# 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(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)
|
||||
|
||||
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="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."
|
||||
),
|
||||
annotations=ToolAnnotations(
|
||||
readOnlyHint=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_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
|
||||
session = await storage.get_login_flow_session(user_id)
|
||||
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
|
||||
assert poll_result.app_password is not None
|
||||
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,
|
||||
)
|
||||
|
||||
# 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 revokes the current app password and starts a new Login Flow "
|
||||
"with the combined scope set."
|
||||
),
|
||||
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_storage()
|
||||
|
||||
# Get current state
|
||||
existing = await storage.get_app_password_with_scopes(user_id)
|
||||
previous_scopes = existing["scopes"] if existing else None
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Delete existing app password from storage (user must revoke in NC Security settings)
|
||||
if existing:
|
||||
await storage.delete_app_password(user_id)
|
||||
|
||||
# Initiate new Login Flow v2
|
||||
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)
|
||||
|
||||
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,
|
||||
)
|
||||
Reference in New Issue
Block a user