Sweep all 1676 G004 violations across 112 files, converting
`logger.<level>(f"…{x}…")` to `logger.<level>("…%s…", x)`.
Why: ruff rule G004 was added to pyproject.toml to enforce lazy
%-style logging — defers formatting until the log level is enabled
and lets structured log tooling match the unformatted template.
Conversion preserves rendered output byte-for-byte:
- `{x}` → `%s` + `x`
- `{x!r}` / `{x!s}` / `{x!a}` → `%r` / `%s` / `%a`
- Format specs (`{x:.2f}`, `{x:>10}`) → `%s` + `format(x, 'spec')`
(printf-style specs aren't 1:1 with Python format specs, so we
delegate to `format()` to keep identical output)
- Literal `%` → `%%`
- Concatenated f-strings (`f"a {x} " "b"`) flattened
- Trailing kwargs (`exc_info=True`) preserved
Verified:
- `uv run ruff check --select G004` → 0 violations
- `uv run ty check -- nextcloud_mcp_server` → passes
- `uv run pytest tests/unit/` → 1010 passed
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
351 lines
13 KiB
Python
351 lines
13 KiB
Python
"""Web-based Login Flow v2 provisioning routes.
|
|
|
|
Provides browser endpoints for provisioning Nextcloud app passwords via
|
|
Login Flow v2. Used by Astrolabe's "Enable Semantic Search" flow to
|
|
chain OAuth (bearer token) with Login Flow v2 (app password) in a single
|
|
user interaction.
|
|
|
|
Flow:
|
|
1. GET /app/provision?redirect_uri=... → Initiates LFv2, redirects to NC login
|
|
2. User clicks "Grant access" on Nextcloud's login page
|
|
3. MCP server background task polls and stores app password
|
|
4. GET /app/provision/status?id=... → Returns completion status (JSON)
|
|
5. User returns to Astrolabe settings (via redirect_uri or navigation)
|
|
"""
|
|
|
|
import html
|
|
import logging
|
|
import secrets
|
|
import time
|
|
from urllib.parse import urlparse
|
|
|
|
import anyio
|
|
from starlette.requests import Request
|
|
from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|
|
|
from nextcloud_mcp_server.api.management import validate_token_and_get_user
|
|
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client, rewrite_url_origin
|
|
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
|
|
from nextcloud_mcp_server.auth.storage import get_shared_storage
|
|
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# In-memory store for web provision sessions (short-lived, no persistence needed).
|
|
# Maps provision_id → session data.
|
|
# NOTE: This does not work with multi-process deployments (e.g. uvicorn --workers N).
|
|
# Login Flow v2 mode assumes a single worker process.
|
|
_provision_sessions: dict[str, dict] = {}
|
|
|
|
# Session TTL: 20 minutes (matches Nextcloud's Login Flow v2 timeout)
|
|
_SESSION_TTL = 1200
|
|
|
|
|
|
def _cleanup_expired_sessions() -> None:
|
|
"""Remove expired provision sessions."""
|
|
now = time.time()
|
|
expired = [k for k, v in _provision_sessions.items() if v["expires_at"] < now]
|
|
for k in expired:
|
|
del _provision_sessions[k]
|
|
|
|
|
|
def _validate_redirect_uri(redirect_uri: str) -> bool:
|
|
"""Validate that redirect_uri is a reasonable URL (not javascript: etc)."""
|
|
try:
|
|
parsed = urlparse(redirect_uri)
|
|
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
async def _poll_and_store(provision_id: str) -> None:
|
|
"""Background task: poll Login Flow v2 and store app password on completion."""
|
|
session = _provision_sessions.get(provision_id)
|
|
if not session:
|
|
return
|
|
|
|
settings = get_settings()
|
|
nextcloud_host = settings.nextcloud_host
|
|
if not nextcloud_host:
|
|
if provision_id in _provision_sessions:
|
|
session["status"] = "error"
|
|
return
|
|
|
|
flow_client = LoginFlowV2Client(
|
|
nextcloud_host=nextcloud_host,
|
|
verify_ssl=get_nextcloud_ssl_verify(),
|
|
)
|
|
|
|
poll_endpoint = session["poll_endpoint"]
|
|
poll_token = session["poll_token"]
|
|
user_id = session.get("user_id")
|
|
|
|
# Poll every 2 seconds for up to 20 minutes
|
|
max_attempts = 600
|
|
for _ in range(max_attempts):
|
|
if provision_id not in _provision_sessions:
|
|
return # Session was cleaned up
|
|
|
|
try:
|
|
result = await flow_client.poll(poll_endpoint, poll_token)
|
|
except Exception as e:
|
|
logger.warning(
|
|
"Login Flow v2 poll error for provision %s: %s", provision_id, e
|
|
)
|
|
await anyio.sleep(2)
|
|
continue
|
|
|
|
if result.status == "completed":
|
|
# Store the app password
|
|
storage = await get_shared_storage()
|
|
effective_user_id = user_id or result.login_name or "unknown"
|
|
if not result.app_password:
|
|
# Re-fetch session to avoid writing to orphaned dict if
|
|
# _cleanup_expired_sessions removed it while we were polling
|
|
session = _provision_sessions.get(provision_id)
|
|
if session:
|
|
session["status"] = "error"
|
|
logger.error(
|
|
"Login Flow v2 completed but no app_password (provision_id=%s)",
|
|
provision_id,
|
|
)
|
|
return
|
|
await storage.store_app_password_with_scopes(
|
|
user_id=effective_user_id,
|
|
app_password=result.app_password,
|
|
scopes=None, # All scopes
|
|
username=result.login_name,
|
|
)
|
|
invalidate_scope_cache(effective_user_id)
|
|
session = _provision_sessions.get(provision_id)
|
|
if session:
|
|
session["status"] = "completed"
|
|
session["username"] = result.login_name
|
|
logger.info(
|
|
"Login Flow v2 web provision completed for user %s (provision_id=%s)",
|
|
effective_user_id,
|
|
provision_id,
|
|
)
|
|
return
|
|
|
|
if result.status == "expired":
|
|
session = _provision_sessions.get(provision_id)
|
|
if session:
|
|
session["status"] = "expired"
|
|
logger.warning(
|
|
"Login Flow v2 web provision expired (provision_id=%s)", provision_id
|
|
)
|
|
return
|
|
|
|
await anyio.sleep(2)
|
|
|
|
# Timed out
|
|
session = _provision_sessions.get(provision_id)
|
|
if session:
|
|
session["status"] = "expired"
|
|
logger.warning(
|
|
"Login Flow v2 web provision timed out (provision_id=%s)", provision_id
|
|
)
|
|
|
|
|
|
async def provision_page(
|
|
request: Request,
|
|
) -> RedirectResponse | HTMLResponse | JSONResponse:
|
|
"""Initiate Login Flow v2 and redirect to Nextcloud's login page.
|
|
|
|
GET /app/provision?redirect_uri=...
|
|
|
|
Requires a valid Nextcloud OIDC bearer token (Authorization header).
|
|
The authenticated user identity is extracted from the token — the
|
|
``user_id`` query parameter is ignored if present.
|
|
|
|
Initiates Login Flow v2, starts background polling, and redirects the
|
|
browser to Nextcloud's login/grant page. After the user grants access,
|
|
the background task stores the app password. The user then navigates
|
|
back to the redirect_uri (Astrolabe settings).
|
|
"""
|
|
# Authenticate: require a valid Nextcloud OIDC bearer token
|
|
try:
|
|
user_id, _token_data = await validate_token_and_get_user(request)
|
|
except (ValueError, KeyError, AttributeError) as e:
|
|
logger.warning("Provision request rejected: %s", e)
|
|
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
|
|
|
_cleanup_expired_sessions()
|
|
|
|
redirect_uri = request.query_params.get("redirect_uri", "")
|
|
|
|
if not redirect_uri or not _validate_redirect_uri(redirect_uri):
|
|
return HTMLResponse(
|
|
content=_render_error("Missing or invalid redirect_uri parameter."),
|
|
status_code=400,
|
|
)
|
|
|
|
if urlparse(redirect_uri).scheme == "http":
|
|
logger.warning("Provision redirect_uri uses insecure HTTP: %s", redirect_uri)
|
|
|
|
# Check if user already has an app password — skip straight to redirect
|
|
if user_id:
|
|
storage = await get_shared_storage()
|
|
existing = await storage.get_app_password_with_scopes(user_id)
|
|
if existing:
|
|
logger.info("User %s already has app password, skipping provision", user_id)
|
|
return RedirectResponse(redirect_uri)
|
|
|
|
# Initiate Login Flow v2
|
|
settings = get_settings()
|
|
nextcloud_host = settings.nextcloud_host
|
|
if not nextcloud_host:
|
|
return HTMLResponse(
|
|
content=_render_error("Nextcloud host not configured on server."),
|
|
status_code=500,
|
|
)
|
|
|
|
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("Failed to initiate Login Flow v2 for web provision: %s", e)
|
|
return HTMLResponse(
|
|
content=_render_error(
|
|
"Failed to start login flow. Please try again later."
|
|
),
|
|
status_code=502,
|
|
)
|
|
|
|
# Create provision session
|
|
provision_id = secrets.token_urlsafe(32)
|
|
_provision_sessions[provision_id] = {
|
|
"status": "pending",
|
|
"login_url": init_response.login_url,
|
|
"poll_endpoint": init_response.poll_endpoint,
|
|
"poll_token": init_response.poll_token,
|
|
"redirect_uri": redirect_uri,
|
|
"user_id": user_id,
|
|
"created_at": time.time(),
|
|
"expires_at": time.time() + _SESSION_TTL,
|
|
}
|
|
|
|
# Start background polling task (uses task group from app lifespan)
|
|
poll_tg = getattr(request.app.state, "poll_task_group", None)
|
|
if poll_tg is None:
|
|
logger.error("No poll task group available; cannot start background polling")
|
|
_provision_sessions.pop(provision_id, None)
|
|
return HTMLResponse(
|
|
content=_render_error(
|
|
"Server configuration error: background polling unavailable."
|
|
),
|
|
status_code=500,
|
|
)
|
|
poll_tg.start_soon(_poll_and_store, provision_id)
|
|
|
|
logger.info(
|
|
"Login Flow v2 web provision initiated (provision_id=%s, user_id=%s), redirecting to NC login",
|
|
provision_id,
|
|
user_id or "unknown",
|
|
)
|
|
|
|
# Redirect to Nextcloud's Login Flow v2 login page.
|
|
# The login_url may use the internal Docker hostname (http://app/...).
|
|
# Replace with the public Nextcloud URL for the browser.
|
|
# Note: poll_endpoint is rewritten to NEXTCLOUD_HOST (server-side, in
|
|
# LoginFlowV2Client) while login_url is rewritten to the public issuer
|
|
# URL here because the browser needs a publicly-reachable address.
|
|
login_url = init_response.login_url
|
|
public_issuer = settings.nextcloud_public_issuer_url or ""
|
|
if public_issuer and nextcloud_host:
|
|
login_url = rewrite_url_origin(login_url, public_issuer.rstrip("/"))
|
|
|
|
return RedirectResponse(login_url)
|
|
|
|
|
|
async def provision_status(request: Request) -> JSONResponse:
|
|
"""Check provision session status.
|
|
|
|
GET /app/provision/status?id=...
|
|
|
|
Requires a valid Nextcloud OIDC bearer token (Authorization header).
|
|
|
|
Returns JSON with status field:
|
|
- ``"pending"`` — flow in progress, poll again
|
|
- ``"completed"`` — app password stored, includes ``"username"``
|
|
- ``"expired"`` — flow timed out or was rejected by Nextcloud
|
|
- ``"error"`` — flow completed but server-side error (e.g. missing app password)
|
|
- ``"not_found"`` — unknown or already-consumed session (404)
|
|
"""
|
|
# Authenticate: require a valid Nextcloud OIDC bearer token
|
|
try:
|
|
_user_id, _token_data = await validate_token_and_get_user(request)
|
|
except (ValueError, KeyError, AttributeError) as e:
|
|
logger.warning("Provision status request rejected: %s", e)
|
|
return JSONResponse({"error": "Authentication required"}, status_code=401)
|
|
|
|
provision_id = request.query_params.get("id", "")
|
|
|
|
session = _provision_sessions.get(provision_id)
|
|
if not session:
|
|
return JSONResponse(
|
|
{
|
|
"status": "not_found",
|
|
"message": "Provision session not found or expired",
|
|
},
|
|
status_code=404,
|
|
)
|
|
|
|
# Detect sessions that outlived their TTL (e.g. no new provision
|
|
# requests triggered _cleanup_expired_sessions)
|
|
if session["expires_at"] < time.time():
|
|
_provision_sessions.pop(provision_id, None)
|
|
return JSONResponse({"status": "expired"}, status_code=404)
|
|
|
|
response: dict = {"status": session["status"]}
|
|
if session["status"] == "completed":
|
|
response["username"] = session.get("username")
|
|
# Clean up completed session after status is read
|
|
_provision_sessions.pop(provision_id, None)
|
|
|
|
return JSONResponse(response)
|
|
|
|
|
|
# ── HTML rendering helpers ────────────────────────────────────────────────
|
|
|
|
|
|
def _render_error(message: str) -> str:
|
|
"""Render an error page."""
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Error - Astrolabe</title>
|
|
<style>
|
|
body {{
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: #f5f5f5;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
min-height: 100vh;
|
|
}}
|
|
.card {{
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
padding: 2.5rem;
|
|
max-width: 480px;
|
|
text-align: center;
|
|
}}
|
|
.error {{ color: #c62828; }}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="card">
|
|
<h1 class="error">Provisioning Error</h1>
|
|
<p>{html.escape(message)}</p>
|
|
</div>
|
|
</body>
|
|
</html>"""
|