Merge remote-tracking branch 'origin/master' into security/oauth-session-hardening-626
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user