fix(auth): address PR #758 review — XSS, CSRF, open redirect, JWKS cache

Addresses all 9 findings from the review on PR #758:

Blocking:
- _revoke_refresh_token_at_idp now reads config from oauth_ctx["config"]
  (the production-shaped nested dict). Previously read flat keys, causing
  IdP revocation to silently no-op in production. Test fixtures rebuilt
  to the realistic nested shape so the bug can't regress unnoticed.
- HTML error responses in oauth_login_callback now wrap IdP-controlled
  error_body, str(e), and the attacker-controlled error/error_description
  query params in html_escape. New test_browser_oauth_xss.py pins this.

Important:
- New _safe_next_url helper validates the ?next= query param at write
  time (oauth_login), in oauth_logout, and on read from the session row
  in oauth_login_callback. Blocks https://, // (protocol-relative), and
  CRLF/whitespace injection.
- verify_id_token now caches discovery + JWKS (5-min TTL) using the
  same pattern as oauth_routes._get_cached_discovery. New caching
  regression test pins to one fetch per URL across multiple calls.
- /oauth/logout is now POST-only at the route layer (defeats passive
  CSRF via <img src>). oauth_logout also validates Origin/Referer
  against the configured mcp_server_url. Logout UI in user_info.html
  converted from <a href> to <form method="post">.
- New storage.cleanup_expired_browser_sessions() called from the hourly
  cleanup loop in app.py — previously these rows accumulated for users
  who never explicitly logged out.

Nits:
- Demoted INFO logs that leaked oauth_config.keys() / client_id /
  token-storage state to DEBUG. Operator-relevant outcome lines
  (login successful, refresh token stored, logged out) stay INFO.
- verify_id_token algorithms widened to RS256, PS256, ES256 — covers
  Azure AD (PS256) and Cognito/some Keycloak realms (ES256). Symmetric
  and "none" remain off the allowlist.
- Migrated all Optional[X] usages in auth/storage.py to X | None per
  CLAUDE.md.

Breaking change: GET /oauth/logout now returns 405. The in-tree logout
UI was migrated to a POST form; any external bookmark or curl-based
caller that relied on GET will need to switch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 18:26:39 +02:00
co-authored by Claude Opus 4.7
parent 15dbb26349
commit 931ee602eb
10 changed files with 581 additions and 110 deletions
+56
View File
@@ -18,6 +18,7 @@ import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from nextcloud_mcp_server.auth import token_utils
from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError,
verify_id_token,
@@ -26,6 +27,16 @@ from nextcloud_mcp_server.auth.token_utils import (
pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
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()
yield
token_utils._discovery_cache.clear()
token_utils._jwks_cache.clear()
# Generated once per process — RSA keypair generation is slow.
_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
_PRIVATE_PEM = _KEY.private_bytes(
@@ -231,3 +242,48 @@ async def test_verify_id_token_missing_token_rejected():
await verify_id_token(
"", discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
async def test_verify_id_token_caches_discovery_and_jwks():
"""Discovery + JWKS must be cached: two verifications, one fetch each.
Pins the fix for PR #758 finding 4 — every login previously made two
extra HTTP round-trips to the IdP for the same metadata.
"""
fetches: dict[str, int] = {}
def counting_handler(request: httpx.Request) -> httpx.Response:
url = str(request.url)
fetches[url] = fetches.get(url, 0) + 1
return _idp_handler(request)
transport = httpx.MockTransport(counting_handler)
def fake_client(**kwargs):
kwargs["transport"] = transport
return httpx.AsyncClient(**kwargs)
now = int(time.time())
token = _sign(
{
"iss": ISSUER,
"aud": "test-client",
"sub": "alice",
"iat": now,
"exp": now + 60,
}
)
with patch(
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
side_effect=fake_client,
):
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
await verify_id_token(
token, discovery_url=DISCOVERY_URL, expected_audience="test-client"
)
assert fetches.get(DISCOVERY_URL) == 1, "discovery fetched more than once"
assert fetches.get(JWKS_URI) == 1, "JWKS fetched more than once"