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:
co-authored by
Claude Opus 4.7
parent
9d0e7dcebe
commit
3a4fa8adc8
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user