Merge remote-tracking branch 'origin/master' into security/oauth-session-hardening-626

This commit is contained in:
Chris Coutinho
2026-05-02 18:29:53 +02:00
16 changed files with 757 additions and 67 deletions
+2
View File
@@ -21,6 +21,7 @@ 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.auth.scope_authorization import invalidate_scope_cache
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.config import get_settings
@@ -305,6 +306,7 @@ async def provision_app_password(request: Request) -> JSONResponse:
await storage.store_app_password_with_scopes(
username, app_password, scopes=scopes, username=nc_username
)
invalidate_scope_cache(username)
_record_rate_limit_attempt(path_user_id, success=True)
logger.info(f"Provisioned app password for user: {username}")
+2 -2
View File
@@ -743,7 +743,7 @@ async def setup_oauth_config():
# ADR-005: Unified Token Verifier with proper audience validation
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer_url = settings.nextcloud_public_issuer_url
client_issuer = public_issuer_url if public_issuer_url else issuer
# Get MCP server URL for audience validation
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
@@ -933,7 +933,7 @@ async def setup_oauth_config_for_multi_user_basic(
# Use public issuer URL for JWT validation if set (handles Docker internal/external URL mismatch)
# Tokens are issued with the public URL, but OIDC discovery returns internal URL
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer_url = settings.nextcloud_public_issuer_url
client_issuer = public_issuer_url if public_issuer_url else issuer
# Update settings with discovered values for UnifiedTokenVerifier
@@ -6,7 +6,6 @@ for accessing admin UI endpoints like /app.
import hashlib
import logging
import os
import secrets
import time
from base64 import urlsafe_b64encode
@@ -26,6 +25,7 @@ from nextcloud_mcp_server.auth.userinfo_routes import (
_get_userinfo_endpoint,
_query_idp_userinfo,
)
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
@@ -75,22 +75,21 @@ def _safe_next_url(raw: str | None, default: str) -> str:
def _should_use_secure_cookies() -> bool:
"""Determine if cookies should have secure flag.
"""Determine if cookies should have the Secure flag.
Checks COOKIE_SECURE env var first, then auto-detects from NEXTCLOUD_HOST.
Reads ``settings.cookie_secure`` first (set via the ``COOKIE_SECURE``
env var). Falls back to auto-detect from the ``nextcloud_host`` scheme
when unset.
Returns:
True if cookies should be secure (HTTPS), False otherwise
"""
# Explicit configuration takes precedence
explicit = os.getenv("COOKIE_SECURE", "").lower()
if explicit == "true":
return True
if explicit == "false":
return False
# Auto-detect from NEXTCLOUD_HOST protocol
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "")
settings = get_settings()
if settings.cookie_secure is not None:
# Dynaconf auto-coerces "true"/"false" → bool but "1"/"0" → int;
# bool() normalises both.
return bool(settings.cookie_secure)
nextcloud_host = settings.nextcloud_host or ""
return nextcloud_host.startswith("https://")
@@ -216,7 +215,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
scopes += " offline_access"
# Replace internal Docker hostname with public URL
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer = get_settings().nextcloud_public_issuer_url
if public_issuer:
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
+166 -34
View File
@@ -5,12 +5,23 @@ when the client supports it, or falling back to returning the URL in a message.
"""
import logging
from typing import Any
from mcp.server.fastmcp import Context
from pydantic import BaseModel, Field
from nextcloud_mcp_server.config import get_settings
logger = logging.getLogger(__name__)
# Path of the Astrolabe Nextcloud app's settings UI. The full URL is
# reconstructed at elicitation time from settings.nextcloud_public_issuer_url
# / settings.nextcloud_host so the user gets a browser-reachable link without
# needing a separate config knob. If the Astrolabe app is not installed this
# path will 404, and the user falls back to the nc_auth_provision_access tool
# path mentioned in the same message.
ASTROLABE_SETTINGS_PATH = "/index.php/apps/astrolabe/settings"
class LoginFlowConfirmation(BaseModel):
"""Schema for Login Flow v2 confirmation elicitation."""
@@ -21,6 +32,95 @@ class LoginFlowConfirmation(BaseModel):
)
class ProvisioningRequiredConfirmation(BaseModel):
"""Schema for the 'app password not provisioned' elicitation."""
acknowledged: bool = Field(
default=False,
description="Check this box after enabling Nextcloud access",
)
def _astrolabe_settings_url() -> str | None:
"""Construct the Astrolabe settings page URL from settings.
Prefers ``nextcloud_public_issuer_url`` (the browser-reachable public URL)
over ``nextcloud_host`` (which may be an internal hostname in Docker
deployments). Returns None if neither is set (or set to the empty
string), or if the configured base URL is missing an http:// or
https:// scheme — in the latter case the caller renders the tool-only
fallback message instead of a broken link.
"""
settings = get_settings()
base = (
settings.nextcloud_public_issuer_url or settings.nextcloud_host or ""
).strip()
if not base:
return None
if not base.startswith(("http://", "https://")):
# Bare hostname (e.g. "internal:8080") would silently produce a
# non-clickable URL. Surface the misconfiguration instead.
logger.warning(
"Cannot build Astrolabe settings URL: configured Nextcloud base URL "
"%r is missing an http:// or https:// scheme. Falling back to the "
"tool-only provisioning message.",
base,
)
return None
return f"{base.rstrip('/')}{ASTROLABE_SETTINGS_PATH}"
async def _run_elicit(
ctx: Context,
message: str,
schema: type[BaseModel],
*,
log_label: str,
) -> tuple[str, Any]:
"""Shared elicit-or-fallback flow used by all elicitation prompts.
Returns ``(outcome, result)`` where ``outcome`` is one of
``"accepted"`` / ``"declined"`` / ``"cancelled"`` / ``"message_only"``.
``result`` is the underlying ``ctx.elicit()`` return value when the
elicitation actually ran (any of the first three outcomes), else None.
Callers needing post-accept inspection (e.g. the data-acknowledged
warning in :func:`present_login_url`) read it from ``result``.
"""
if not hasattr(ctx, "elicit"):
logger.debug(
"Elicitation not available on context — message_only fallback (%s)",
log_label,
)
return "message_only", None
try:
result = await ctx.elicit(message=message, schema=schema)
except NotImplementedError:
logger.debug(
"Elicitation not supported by client — message_only fallback (%s)",
log_label,
)
return "message_only", None
except Exception as e:
logger.warning(
"Elicitation failed unexpectedly for %s (%s: %s), "
"falling back to message_only",
log_label,
type(e).__name__,
e,
)
return "message_only", None
if result.action == "accept":
logger.info("User acknowledged %s", log_label)
return "accepted", result
if result.action == "decline":
logger.info("User declined %s", log_label)
return "declined", result
logger.info("User cancelled %s", log_label)
return "cancelled", result
async def present_login_url(
ctx: Context,
login_url: str,
@@ -49,40 +149,72 @@ async def present_login_url(
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"
outcome, result = await _run_elicit(
ctx,
message,
LoginFlowConfirmation,
log_label="login flow completion",
)
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:
if (
outcome == "accepted"
and result is not None
and hasattr(result, "data")
and not result.data.acknowledged
):
# User clicked OK without ticking the box — login completion is still
# verified via the LFv2 poller, so we proceed but flag it.
logger.warning(
f"Elicitation failed unexpectedly ({type(e).__name__}: {e}), "
"falling back to message"
"User accepted login flow without checking the acknowledged box — "
"login completion will be verified via polling"
)
return "message_only"
return outcome
async def present_provisioning_required(ctx: Context) -> str:
"""Elicit a provisioning prompt when a tool is called without an app password.
Used by the ``@require_scopes`` decorator (Login Flow v2 path) to give
the user a clickable Astrolabe settings URL — or a fallback instruction
to call the ``nc_auth_provision_access`` MCP tool — instead of just
raising a plain ``ProvisioningRequiredError`` text message that an LLM
has to translate.
The Astrolabe settings URL is reconstructed from
``settings.nextcloud_public_issuer_url`` /
``settings.nextcloud_host``; if Astrolabe is not installed the link
404s and the user falls back to the tool path suggested in the same
message.
Returns:
Same string contract as :func:`present_login_url`:
``"accepted"`` / ``"declined"`` / ``"cancelled"`` / ``"message_only"``.
"""
settings_url = _astrolabe_settings_url()
if settings_url:
message = (
"Nextcloud access is not yet provisioned for this user.\n\n"
f"Open this URL to enable it via the Astrolabe app:\n\n{settings_url}\n\n"
"If the Astrolabe app is not installed, ask your MCP client to call "
"the `nc_auth_provision_access` tool instead — it will return a "
"Login Flow v2 URL you can open in your browser.\n\n"
"Then check the box below and retry the original request."
)
else:
message = (
"Nextcloud access is not yet provisioned for this user.\n\n"
"Ask your MCP client to call the `nc_auth_provision_access` tool — "
"it will return a Login Flow v2 URL you can open in your browser to "
"grant access.\n\n"
"Then check the box below and retry the original request."
)
outcome, _ = await _run_elicit(
ctx,
message,
ProvisioningRequiredConfirmation,
log_label="provisioning-required prompt",
)
return outcome
+2 -2
View File
@@ -363,7 +363,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
authorization_endpoint = discovery["authorization_endpoint"]
# Replace internal Docker hostname with public URL for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer = get_settings().nextcloud_public_issuer_url
if public_issuer:
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
@@ -510,7 +510,7 @@ async def oauth_authorize_nextcloud(
authorization_endpoint = discovery["authorization_endpoint"]
# Fix internal hostname for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
public_issuer = get_settings().nextcloud_public_issuer_url
if public_issuer:
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
@@ -15,7 +15,6 @@ Flow:
import html
import logging
import os
import secrets
import time
from urllib.parse import urlparse
@@ -26,6 +25,7 @@ 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
@@ -115,6 +115,7 @@ async def _poll_and_store(provision_id: str) -> None:
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"
@@ -251,7 +252,7 @@ async def provision_page(
# 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 = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_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("/"))
@@ -151,13 +151,60 @@ def require_scopes(*required_scopes: str):
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."
# No stored app password → require provisioning. Try to
# elicit a clickable Astrolabe / Login-Flow-v2 link so
# the user has somewhere to click; the elicit helper
# silently falls back when the client lacks support.
from nextcloud_mcp_server.auth.elicitation import ( # noqa: PLC0415
present_provisioning_required,
)
logger.warning(error_msg)
elicit_result = await present_provisioning_required(ctx)
# Always raise — the decorator can't safely re-check
# stored scopes mid-call (TTL cache, plus the LFv2
# poller may still be running). Only the message
# changes so an LLM that just acknowledged the
# elicitation isn't told to call the auth tool
# again (which would loop).
if elicit_result == "accepted":
# Note: stored-scope lookups are cached for
# _SCOPE_CACHE_TTL (5 min). All three provisioning
# paths invalidate the cache on completion: the
# in-tool poller in nc_auth_check_status
# (auth_tools.py), the Astrolabe web route
# (provision_routes.py), and the BasicAuth REST
# endpoint (api/passwords.py). However, if the
# LFv2 poller is still in-flight at acknowledge-
# time the next retry can still hit a not-yet-
# populated entry — hence the "wait a moment"
# qualifier below.
logger.warning(
"Access denied to %s: app password missing "
"after user accepted elicitation; advising retry",
func_name,
)
error_msg = (
f"Access denied to {func_name}: Nextcloud "
f"access was not provisioned at the time of "
f"this call. If you just completed "
f"provisioning, please retry the request — "
f"if it still fails, provisioning may still be "
f"completing; wait a moment and try again."
)
else:
logger.warning(
"Access denied to %s: app password missing; "
"advising nc_auth_provision_access "
"(elicit_result=%s)",
func_name,
elicit_result,
)
error_msg = (
f"Access denied to {func_name}: "
f"Nextcloud access not provisioned. "
f"Please call 'nc_auth_provision_access' first."
)
raise ProvisioningRequiredError(error_msg)
if stored_scopes == "all":
+1 -1
View File
@@ -474,7 +474,7 @@ async def user_info_html(request: Request) -> HTMLResponse:
# otherwise fall back to NEXTCLOUD_HOST from settings
settings = get_settings()
nextcloud_host_for_links = (
os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") or settings.nextcloud_host
settings.nextcloud_public_issuer_url or settings.nextcloud_host
)
# Build host info HTML (BasicAuth only)
+14
View File
@@ -31,6 +31,8 @@ _DEFAULTS: dict[str, Any] = {
"nextcloud_ca_bundle": None,
"nextcloud_mcp_server_url": None,
"nextcloud_resource_uri": None,
"nextcloud_public_issuer_url": None,
"cookie_secure": None,
# OAuth/OIDC
"oidc_discovery_url": None,
"nextcloud_oidc_client_id": None,
@@ -406,6 +408,16 @@ class Settings:
nextcloud_password: str | None = None
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
# Browser-reachable public URL for OAuth/Login-Flow-v2 redirects when
# NEXTCLOUD_HOST is an internal Docker hostname. Falls back to
# nextcloud_host when unset.
nextcloud_public_issuer_url: str | None = None
# Browser cookie Secure flag. None = auto-detect from nextcloud_host
# scheme (https → True, else False). Set COOKIE_SECURE=true/false to
# override.
cookie_secure: bool | None = None
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl: bool = True
nextcloud_ca_bundle: str | None = None
@@ -777,6 +789,8 @@ def get_settings() -> Settings:
"nextcloud_username": "NEXTCLOUD_USERNAME",
"nextcloud_password": "NEXTCLOUD_PASSWORD",
"nextcloud_app_password": "NEXTCLOUD_APP_PASSWORD",
"nextcloud_public_issuer_url": "NEXTCLOUD_PUBLIC_ISSUER_URL",
"cookie_secure": "COOKIE_SECURE",
# Nextcloud SSL/TLS settings
"nextcloud_verify_ssl": "NEXTCLOUD_VERIFY_SSL",
"nextcloud_ca_bundle": "NEXTCLOUD_CA_BUNDLE",