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
+50
View File
@@ -12,6 +12,7 @@ import time
from base64 import urlsafe_b64encode
from unittest.mock import patch
import anyio
import httpx
import jwt
import pytest
@@ -32,9 +33,11 @@ def _clear_oidc_caches():
"""Reset the discovery+JWKS caches so tests don't share fetched data."""
token_utils._discovery_cache.clear()
token_utils._jwks_cache.clear()
token_utils._fetch_locks.clear()
yield
token_utils._discovery_cache.clear()
token_utils._jwks_cache.clear()
token_utils._fetch_locks.clear()
# Generated once per process — RSA keypair generation is slow.
@@ -478,3 +481,50 @@ async def test_verify_id_token_caches_discovery_and_jwks():
assert fetches.get(DISCOVERY_URL) == 1, "discovery fetched more than once"
assert fetches.get(JWKS_URI) == 1, "JWKS fetched more than once"
async def test_get_cached_coalesces_concurrent_misses():
"""Concurrent cache misses must collapse into a single HTTP fetch.
PR #758 round-3 review: without the per-URL lock in ``_get_cached``,
N simultaneous callers at cache expiry would each fire their own
request to the IdP, potentially tripping rate limits. The async
handler yields with ``anyio.sleep(0.01)`` so all 10 callers reach
the cache-miss branch concurrently — without coalescing the count
would be 10.
"""
fetch_count = {"n": 0}
async def slow_handler(request: httpx.Request) -> httpx.Response:
fetch_count["n"] += 1
# Yield so concurrent waiters all reach the lock acquisition
# while the first holder is still mid-fetch.
await anyio.sleep(0.01)
return _idp_handler(request)
transport = httpx.MockTransport(slow_handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
results: list[dict] = []
async def fetch_once():
results.append(await token_utils._get_cached(token_utils._jwks_cache, JWKS_URI))
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
async with anyio.create_task_group() as tg:
for _ in range(10):
tg.start_soon(fetch_once)
assert fetch_count["n"] == 1, (
f"expected exactly one fetch via lock coalescing, got {fetch_count['n']}"
)
assert len(results) == 10
assert all(r == results[0] for r in results), (
"concurrent callers received divergent cached data"
)
@@ -218,6 +218,7 @@ async def test_as_proxy_rejects_invalid_id_token():
code_challenge="challenge",
code_challenge_method="S256",
requested_scopes="openid",
nonce="nonce-rejected",
)
_proxy_codes.clear()
+41
View File
@@ -0,0 +1,41 @@
"""Tests for ``_normalise_origin`` port + scheme + host normalisation.
The CSRF guard on POST /oauth/logout (PR #758 round-3 review hardening)
compares ``Origin`` / ``Referer`` against the configured ``mcp_server_url``
via ``_normalise_origin``. RFC 6454 §6.2 says browsers omit default ports
(80 for http, 443 for https) from Origin headers, so the function strips
those before comparison. These tests pin that behaviour so it can't
silently regress.
"""
import pytest
from nextcloud_mcp_server.auth.browser_oauth_routes import _normalise_origin
pytestmark = pytest.mark.unit
@pytest.mark.parametrize(
"left, right, equal",
[
# Default ports are stripped — these MUST compare equal.
("https://example.com", "https://example.com:443", True),
("https://example.com:443", "https://example.com", True),
("http://example.com", "http://example.com:80", True),
("http://example.com:80", "http://example.com", True),
# Non-default ports are preserved.
("https://example.com:8443", "https://example.com", False),
("http://example.com:8080", "http://example.com", False),
("https://example.com:8443", "https://example.com:443", False),
# Cross-scheme defaults don't collapse (https:443 != http:80 even
# though both ports get stripped, because the scheme differs).
("https://example.com", "http://example.com", False),
("https://example.com:443", "http://example.com:80", False),
# Hostname matters and is case-insensitive.
("https://example.com", "https://other.com", False),
("https://example.com", "https://EXAMPLE.COM", True),
("https://Example.Com:443", "https://example.com", True),
],
)
def test_normalise_origin_equivalence(left: str, right: str, equal: bool):
assert (_normalise_origin(left) == _normalise_origin(right)) is equal