fix(auth): address PR #758 follow-up review

Six findings from the latest claude-bot review on PR #758:

- JWKS cache had no kid-miss refresh path (Medium): on IdP key
  rotation every login failed for up to _OIDC_CACHE_TTL. Evict
  and refetch once before raising, per OIDC core §10.1.1.
- _should_use_secure_cookies fell back to nextcloud_host scheme,
  but the cookie is issued by the MCP server. Switch to
  settings.nextcloud_mcp_server_url so split-scheme deployments
  get the right Secure flag.
- _origin_matches_self compared raw netloc strings, which include
  the port. Browsers omit default ports per RFC 6454 §6.2; an
  mcp_server_url like :443 falsely 403'd every legitimate logout.
  Normalise (scheme, host, port) tuples with default ports stripped.
- delete_oauth_session exists in storage.py — drop the stale
  "we don't have this method" comment and call it eagerly so
  replays can't be processed and the table doesn't accumulate
  completed-but-not-yet-expired browser-login rows.
- extract_user_id_from_token's unused ctx param renamed to _ctx
  to signal "intentionally unused" at the signature level.
- provisioning_decorator instantiated RefreshTokenStorage per
  call. Switch to get_shared_storage() for the lock-protected
  process-wide singleton.

Plus pre-push self-review catch: lazy-logging on the unchanged
except arm in session_backend.py.

Adds 5 regression tests:
  - JWKS rotation: success on refetch
  - JWKS rotation: still-missing-kid surfaces original error
  - JWKS rotation: network error during refresh wrapped as
    IdTokenVerificationError
  - default-port CSRF: explicit :443 in config + portless Origin
  - default-port CSRF: portless config + explicit :443 in Origin
  - scheme-mismatch CSRF: same host, different scheme rejected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 18:59:34 +02:00
co-authored by Claude Opus 4.7
parent af25c281bf
commit 2d340a5a6b
6 changed files with 324 additions and 27 deletions
@@ -32,6 +32,23 @@ from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
def _normalise_origin(raw: str) -> tuple[str, str, int | None]:
"""Return (scheme, hostname, port) with default HTTP/HTTPS ports stripped.
Browsers omit default ports in Origin headers (RFC 6454 §6.2), so a
raw netloc string comparison falsely rejects requests whenever
``mcp_server_url`` is configured with an explicit ``:443`` / ``:80``
(or vice versa).
"""
parsed = parse_url(raw)
scheme = parsed.scheme.lower()
hostname = (parsed.hostname or "").lower()
port = parsed.port
if (scheme == "https" and port == 443) or (scheme == "http" and port == 80):
port = None
return (scheme, hostname, port)
def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
"""Return True when Origin/Referer is missing or matches our own host.
@@ -39,8 +56,11 @@ def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
(PR #758 finding 5). Per OWASP CSRF cheat sheet, the policy is:
- If neither Origin nor Referer is set, allow (same-origin POST in
privacy-conscious browsers may strip both).
- Otherwise, the netloc of the first present header must equal the
netloc of the configured ``mcp_server_url``.
- Otherwise, the (scheme, hostname, port) tuple of the first present
header must equal the same tuple of the configured
``mcp_server_url``. Default ports (80/443) are normalised away
before comparison so RFC-6454-compliant browsers — which omit
default ports in Origin — aren't rejected.
"""
cfg = oauth_ctx.get("config") or oauth_ctx
mcp_server_url = cfg.get("mcp_server_url")
@@ -48,11 +68,11 @@ def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
# Mis-configured deployment — fail open rather than break logout.
return True
expected = parse_url(mcp_server_url).netloc.lower()
expected = _normalise_origin(mcp_server_url)
raw = request.headers.get("origin") or request.headers.get("referer")
if not raw:
return True
return parse_url(raw).netloc.lower() == expected
return _normalise_origin(raw) == expected
def _safe_next_url(raw: str | None, default: str) -> str:
@@ -78,19 +98,19 @@ def _should_use_secure_cookies() -> bool:
"""Determine if cookies should have the Secure flag.
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
env var). Falls back to auto-detecting from the MCP server's own URL
scheme — the cookie is issued by THIS server, so the Secure flag must
reflect THIS server's transport, not Nextcloud's. (Split-scheme
deployments — HTTPS Nextcloud + plain-HTTP MCP sidecar, or vice
versa — would otherwise get the wrong answer.)
"""
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://")
mcp_server_url = settings.nextcloud_mcp_server_url or ""
return mcp_server_url.startswith("https://")
async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
@@ -327,8 +347,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
# read-time as defense-in-depth (issue #758 finding 3). The session
# row could have been written by an older code path or reused.
next_url = _safe_next_url(oauth_session.get("client_redirect_uri"), "/app")
# Clean up the temporary session
# Note: We don't have delete_oauth_session method, but it will expire after TTL
# One-time-use session: delete eagerly so a replayed callback can't
# be processed and so the oauth_sessions table doesn't accumulate
# completed-but-not-yet-expired browser-login rows.
await storage.delete_oauth_session(state)
# Exchange authorization code for tokens
mcp_server_url = oauth_config["mcp_server_url"]
@@ -14,7 +14,7 @@ from mcp.server.fastmcp import Context
from mcp.shared.exceptions import McpError
from mcp.types import ErrorData
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.storage import get_shared_storage
logger = logging.getLogger(__name__)
@@ -80,9 +80,9 @@ def require_provisioning(func: Callable) -> Callable:
)
)
# Check provisioning status
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Check provisioning status — share the process-wide singleton
# rather than initialising a new sqlite handle per tool call.
storage = await get_shared_storage()
refresh_data = await storage.get_refresh_token(user_id)
@@ -149,9 +149,8 @@ def require_provisioning_or_suggest(func: Callable) -> Callable:
user_id = access_token.resource if access_token else None
if user_id:
# Check provisioning status
storage = RefreshTokenStorage.from_env()
await storage.initialize()
# Check provisioning status using the shared singleton.
storage = await get_shared_storage()
refresh_data = await storage.get_refresh_token(user_id)
+1 -1
View File
@@ -94,5 +94,5 @@ class SessionAuthBackend(AuthenticationBackend):
return AuthCredentials(["authenticated"]), SimpleUser(user_id)
except Exception as e:
logger.warning(f"Session validation error: {e}")
logger.warning("Session validation error: %s", e)
return None
+23 -6
View File
@@ -115,10 +115,24 @@ async def verify_id_token(
raise IdTokenVerificationError("ID token header missing 'kid'")
try:
signing_key = jwks[kid]
except KeyError as e:
raise IdTokenVerificationError(
f"No JWKS key matches ID token kid {kid!r}"
) from e
except KeyError:
# Cache miss may indicate IdP key rotation. Refresh JWKS once
# before giving up, per OIDC core §10.1.1: when an unrecognised
# `kid` arrives the relying party should refetch the JWKS rather
# than waiting for cache TTL to elapse.
_jwks_cache.pop(jwks_uri, None)
try:
jwks_data = await _get_cached(_jwks_cache, jwks_uri)
jwks = PyJWKSet.from_dict(jwks_data)
signing_key = jwks[kid]
except KeyError as e:
raise IdTokenVerificationError(
f"No JWKS key matches ID token kid {kid!r}"
) from e
except Exception as e:
raise IdTokenVerificationError(
f"Failed to refresh JWKS after kid miss: {e}"
) from e
# PyJWT verifies the JWT with the algorithm declared in its header,
# cross-checked against this allowlist (so an attacker can't downgrade
@@ -158,7 +172,7 @@ async def verify_id_token(
return payload
async def extract_user_id_from_token(ctx: Context) -> str:
async def extract_user_id_from_token(_ctx: Context) -> str:
"""Extract user_id from the verified MCP access token.
Reads the `sub` claim from `AccessToken.resource`, which is populated by
@@ -168,7 +182,10 @@ async def extract_user_id_from_token(ctx: Context) -> str:
identity claim.
Args:
ctx: MCP context with access token (unused — kept for the public API)
_ctx: MCP context with access token. Intentionally unused — kept on
the public signature so call sites can pass the FastMCP Context
they already hold without rewriting; identity is read from the
verifier-populated AccessToken via get_access_token().
Returns:
user_id from the verified token, or "default_user" when no token is