fix(auth): address PR #758 round-3 final review

Seven findings from the latest review on #758, plus a regression test
catching the substance of the cache-stampede fix:

- verify_id_token: widen id_token annotation to str | None to match
  callers passing nc_token_response.get("id_token")
- extract_user_id_from_token: use JSON-RPC reserved error code -32001
  instead of -1
- _get_cached: per-URL anyio.Lock dict + meta-lock coalesces concurrent
  cache misses into a single IdP fetch (mirrors token_broker.py idiom)
- delete_browser_session: collapse SELECT+DELETE into atomic
  DELETE ... RETURNING user_id (SQLite >= 3.35)
- new test_origin_normalise.py: parametrized port/scheme/host equivalence
  cases for the CSRF Origin guard
- browser_oauth_routes: correct misleading "PR #758 finding 5" cross-
  references (finding 5 was Fernet-key hardening, not CSRF)
- ASProxySession.nonce: make required, drop spurious "legacy session"
  default; reword the in-flight `or None` comment to reflect that
  ASProxySession is purely in-memory
- new test_get_cached_coalesces_concurrent_misses: pins the
  cache-stampede protection — fires 10 concurrent _get_cached calls and
  asserts exactly one HTTP fetch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 23:14:07 +02:00
co-authored by Claude Opus 4.7
parent 9d0e7dcebe
commit 3a4fa8adc8
7 changed files with 152 additions and 30 deletions
@@ -54,7 +54,8 @@ def _origin_matches_self(request: Request, oauth_ctx: dict) -> bool:
"""Return True when Origin/Referer is missing or matches our own host.
Used to gate POST /oauth/logout against cross-origin form submissions
(PR #758 finding 5). Per OWASP CSRF cheat sheet, the policy is:
(PR #758 round-3 review hardening). 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 (scheme, hostname, port) tuple of the first present
@@ -640,9 +641,9 @@ async def oauth_logout(request: Request) -> RedirectResponse | JSONResponse:
5. Clears the cookie on the response.
Method is POST-only at the route layer to defeat passive CSRF (PR #758
finding 5). Origin / Referer headers are also validated against the
configured ``mcp_server_url`` when present, blocking same-method-but-
cross-origin form submissions.
round-3 review hardening). Origin / Referer headers are also validated
against the configured ``mcp_server_url`` when present, blocking
same-method-but-cross-origin form submissions.
Query parameters:
next: Optional URL to redirect to after logout (default: /oauth/login)
+5 -4
View File
@@ -92,7 +92,7 @@ class ASProxySession:
code_challenge: str
code_challenge_method: str
requested_scopes: str
nonce: str = ""
nonce: str
created_at: float = field(default_factory=time.time)
expires_at: float = field(default_factory=lambda: time.time() + 600)
@@ -959,9 +959,10 @@ async def _oauth_callback_as_proxy(
# verification done in oauth_callback_nextcloud.
#
# ``expected_nonce`` is the per-request nonce we forwarded to the IdP
# in oauth_authorize (PR #758 round-2 finding 2); falsy → skip nonce
# check for backward compatibility with sessions stored before the
# nonce field was added.
# in oauth_authorize (PR #758 round-2 finding 2). ASProxySession is
# in-memory only and ``nonce`` is now a required field, so for any
# session created via the current code path this is always set; the
# ``or None`` is defence-in-depth and a no-op in practice.
id_token = nc_token_response.get("id_token")
try:
await verify_id_token(
+8 -9
View File
@@ -1220,23 +1220,22 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# SELECT the row before DELETE so we can attribute the audit log
# entry to the right user (PR #758 round-3 nit 5).
# DELETE ... RETURNING (SQLite ≥ 3.35) reads ``user_id`` atomically
# with the delete itself, so the audit log can't race against a
# concurrent delete that empties the row between SELECT and DELETE
# (PR #758 round-3 review).
user_id: str | None = None
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM browser_sessions WHERE session_id = ?",
"DELETE FROM browser_sessions WHERE session_id = ? RETURNING user_id",
(session_id,),
) as cursor:
row = await cursor.fetchone()
user_id = row[0] if row else None
cursor = await db.execute(
"DELETE FROM browser_sessions WHERE session_id = ?", (session_id,)
)
await db.commit()
deleted = cursor.rowcount > 0
deleted = row is not None
if deleted:
user_id = row[0]
logger.debug("Deleted browser session %s", session_id[:8])
if user_id:
await self._audit_log(
+42 -13
View File
@@ -8,6 +8,7 @@ import logging
import time
from typing import Any
import anyio
import jwt
from jwt import PyJWKSet
from mcp.server.auth.middleware.auth_context import get_access_token
@@ -30,6 +31,23 @@ _discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_OIDC_CACHE_TTL = 300
# Per-URL fetch locks coalesce concurrent cache misses into a single HTTP
# request, preventing thundering-herd against the IdP at cache expiry
# (PR #758 round-3 review). Mirrors the lock-dict + meta-lock idiom from
# token_broker.py.
_fetch_locks: dict[str, anyio.Lock] = {}
_fetch_locks_lock = anyio.Lock()
async def _get_fetch_lock(url: str) -> anyio.Lock:
"""Return the per-URL lock used to serialise cache-miss fetches."""
async with _fetch_locks_lock:
lock = _fetch_locks.get(url)
if lock is None:
lock = anyio.Lock()
_fetch_locks[url] = lock
return lock
class IdTokenVerificationError(Exception):
"""Raised when an OIDC ID token fails signature or claim verification."""
@@ -48,19 +66,29 @@ async def _get_cached(
``/.well-known/openid-configuration`` path issues a 301), but JWKS
fetches deliberately stay strict — the URL came from the discovery
document we already trust, so a redirect there would be suspicious.
Concurrent callers seeing the same cache miss are coalesced via a
per-URL ``anyio.Lock``: only one fetch runs, the rest wait and read the
populated cache.
"""
now = time.time()
entry = cache.get(url)
if entry is not None:
expires_at, data = entry
if now < expires_at:
return data
async with nextcloud_httpx_client(follow_redirects=follow_redirects) as http_client:
response = await http_client.get(url)
response.raise_for_status()
data = response.json()
cache[url] = (now + _OIDC_CACHE_TTL, data)
return data
if entry is not None and time.time() < entry[0]:
return entry[1]
lock = await _get_fetch_lock(url)
async with lock:
# Re-check inside the lock — a concurrent waiter may have already
# populated the cache before we acquired it.
entry = cache.get(url)
if entry is not None and time.time() < entry[0]:
return entry[1]
async with nextcloud_httpx_client(
follow_redirects=follow_redirects
) as http_client:
response = await http_client.get(url)
response.raise_for_status()
data = response.json()
cache[url] = (time.time() + _OIDC_CACHE_TTL, data)
return data
async def get_oidc_discovery(discovery_url: str) -> dict[str, Any]:
@@ -78,7 +106,7 @@ async def get_oidc_discovery(discovery_url: str) -> dict[str, Any]:
async def verify_id_token(
id_token: str,
id_token: str | None,
*,
discovery_url: str,
expected_audience: str,
@@ -240,7 +268,8 @@ async def extract_user_id_from_token(_ctx: Context) -> str:
)
raise McpError(
ErrorData(
code=-1,
# JSON-RPC 2.0 reserves -32000..-32099 for application errors.
code=-32001,
message="Cannot determine user identity from access token",
)
)