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:
Chris Coutinho
2026-02-27 20:33:54 +01:00
co-authored by Claude Opus 4.6
parent 5796e2ba54
commit 8b5c2395b5
21 changed files with 3156 additions and 5 deletions
+71
View File
@@ -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"
+145
View File
@@ -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
+333
View File
@@ -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:
"""