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:
co-authored by
Claude Opus 4.7
parent
15dbb26349
commit
931ee602eb
@@ -5,6 +5,7 @@ between server/ and auth/ layers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
@@ -18,10 +19,37 @@ from ..http import nextcloud_httpx_client
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# OIDC discovery + JWKS caches keyed by URL → (expires_at, data). Mirrors the
|
||||
# pattern in oauth_routes._get_cached_discovery so that ID-token verification
|
||||
# during the OAuth callback doesn't make two extra round-trips per login (PR
|
||||
# #758 finding 4). 5-minute TTL matches oauth_routes.
|
||||
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
_jwks_cache: dict[str, tuple[float, dict[str, Any]]] = {}
|
||||
_OIDC_CACHE_TTL = 300
|
||||
|
||||
|
||||
class IdTokenVerificationError(Exception):
|
||||
"""Raised when an OIDC ID token fails signature or claim verification."""
|
||||
|
||||
|
||||
async def _get_cached(
|
||||
cache: dict[str, tuple[float, dict[str, Any]]], url: str
|
||||
) -> dict[str, Any]:
|
||||
"""Return cached JSON response for *url* or fetch + cache on miss/expiry."""
|
||||
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() 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
|
||||
|
||||
|
||||
async def verify_id_token(
|
||||
id_token: str,
|
||||
*,
|
||||
@@ -62,21 +90,16 @@ async def verify_id_token(
|
||||
raise IdTokenVerificationError("ID token missing from token response")
|
||||
|
||||
try:
|
||||
async with nextcloud_httpx_client() as http_client:
|
||||
discovery_response = await http_client.get(discovery_url)
|
||||
discovery_response.raise_for_status()
|
||||
discovery = discovery_response.json()
|
||||
discovery = await _get_cached(_discovery_cache, discovery_url)
|
||||
|
||||
issuer = discovery.get("issuer")
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
if not issuer or not jwks_uri:
|
||||
raise IdTokenVerificationError(
|
||||
"OIDC discovery response missing issuer or jwks_uri"
|
||||
)
|
||||
issuer = discovery.get("issuer")
|
||||
jwks_uri = discovery.get("jwks_uri")
|
||||
if not issuer or not jwks_uri:
|
||||
raise IdTokenVerificationError(
|
||||
"OIDC discovery response missing issuer or jwks_uri"
|
||||
)
|
||||
|
||||
jwks_response = await http_client.get(jwks_uri)
|
||||
jwks_response.raise_for_status()
|
||||
jwks_data = jwks_response.json()
|
||||
jwks_data = await _get_cached(_jwks_cache, jwks_uri)
|
||||
except IdTokenVerificationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -97,10 +120,18 @@ async def verify_id_token(
|
||||
f"No JWKS key matches ID token kid {kid!r}"
|
||||
) from e
|
||||
|
||||
# PyJWT verifies the JWT with the algorithm declared in its header,
|
||||
# cross-checked against this allowlist (so an attacker can't downgrade
|
||||
# to ``none`` or HMAC). The allowlist covers the OIDC algorithms
|
||||
# most cloud IdPs ship by default:
|
||||
# - RS256: Nextcloud user_oidc, Keycloak default, Auth0, Google.
|
||||
# - PS256: Azure AD on newer keys.
|
||||
# - ES256: some Keycloak realms, AWS Cognito user pools.
|
||||
# Symmetric (HSxxx) and ``none`` are intentionally absent.
|
||||
payload: dict[str, Any] = jwt.decode(
|
||||
id_token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
algorithms=["RS256", "PS256", "ES256"],
|
||||
audience=expected_audience,
|
||||
issuer=issuer,
|
||||
options={
|
||||
|
||||
Reference in New Issue
Block a user