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
+8 -3
View File
@@ -1354,13 +1354,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
storage = await get_shared_storage()
count = await storage.delete_expired_login_flow_sessions()
if count:
logger.info(f"Cleaned up {count} expired login flow sessions")
logger.info("Cleaned up %s expired login flow sessions", count)
# Browser session rows are otherwise only cleaned up lazily
# when a user revisits — PR #758 finding 6.
await storage.cleanup_expired_browser_sessions()
# Also clean up expired AS proxy codes/sessions
_cleanup_expired_proxy_codes()
# Clean up expired web provision sessions
_cleanup_expired_provision_sessions()
except Exception as e:
logger.warning(f"Login flow cleanup error: {e}")
logger.warning("Login flow cleanup error: %s", e)
await anyio.sleep(3600) # Every hour
@asynccontextmanager
@@ -2242,8 +2245,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
name="oauth_login_callback",
)
)
# POST-only: defends against passive CSRF (e.g. <img src="…/logout">)
# — see PR #758 finding 5.
routes.append(
Route("/oauth/logout", oauth_logout, methods=["GET"], name="oauth_logout")
Route("/oauth/logout", oauth_logout, methods=["POST"], name="oauth_logout")
)
logger.info(
"Browser OAuth routes enabled: /oauth/login, /oauth/login-callback (legacy), /oauth/logout"
+119 -42
View File
@@ -32,6 +32,48 @@ from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
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:
- If neither Origin nor Referer is set, allow (same-origin POST in
privacy-conscious browsers may strip both).
- Otherwise, the netloc of the first present header must equal the
netloc of the configured ``mcp_server_url``.
"""
cfg = oauth_ctx.get("config") or oauth_ctx
mcp_server_url = cfg.get("mcp_server_url")
if not mcp_server_url:
# Mis-configured deployment — fail open rather than break logout.
return True
expected = parse_url(mcp_server_url).netloc.lower()
raw = request.headers.get("origin") or request.headers.get("referer")
if not raw:
return True
return parse_url(raw).netloc.lower() == expected
def _safe_next_url(raw: str | None, default: str) -> str:
"""Return a path-only redirect target, falling back to *default*.
Blocks open-redirect abuse via the ``?next=`` query parameter on
``/oauth/login`` and ``/oauth/logout`` (and the round-tripped
``client_redirect_uri`` stored on the oauth_session). A safe target:
- starts with a single ``/`` (so it's a path on this server)
- does NOT start with ``//`` (which would be protocol-relative)
- has no whitespace or control characters that could trick browsers
Anything else returns *default*.
"""
if not raw or not raw.startswith("/") or raw.startswith("//"):
return default
if any(c.isspace() or ord(c) < 0x20 for c in raw):
return default
return raw
def _should_use_secure_cookies() -> bool:
"""Determine if cookies should have secure flag.
@@ -73,14 +115,17 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Debug: Log oauth_config contents
logger.info(f"oauth_login called - oauth_config keys: {oauth_config.keys()}")
logger.info(f"oauth_login called - client_id: {oauth_config.get('client_id')}")
logger.info(f"oauth_login called - oauth_client: {oauth_client is not None}")
# Demoted to DEBUG (PR #758 nit a) — these previously leaked the
# full set of config keys + the client_id at INFO on every login.
logger.debug("oauth_login called - oauth_config keys: %s", oauth_config.keys())
logger.debug("oauth_login called - client_id: %s", oauth_config.get("client_id"))
logger.debug("oauth_login called - oauth_client: %s", oauth_client is not None)
# Get redirect URL from query params (default to /app)
next_url = request.query_params.get("next", "/app")
logger.info(f"oauth_login - next_url: {next_url}")
# Get redirect URL from query params (default to /app). Validated at
# write-time so we never store an attacker-controlled absolute URL on
# the oauth_session row (issue #758 finding 3).
next_url = _safe_next_url(request.query_params.get("next"), "/app")
logger.debug("oauth_login - next_url: %s", next_url)
# Generate state for CSRF protection
state = secrets.token_urlsafe(32)
@@ -142,7 +187,7 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
}
auth_url = f"{oauth_client.authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to external IdP login: {auth_url.split('?')[0]}")
logger.debug("Redirecting to external IdP login: %s", auth_url.split("?")[0])
else:
# Integrated mode (Nextcloud OIDC)
discovery_url = oauth_config.get("discovery_url")
@@ -199,11 +244,10 @@ async def oauth_login(request: Request) -> RedirectResponse | JSONResponse:
"resource": nextcloud_resource_uri, # Request tokens for Nextcloud API access
}
# Debug: Log full parameters
logger.info(f"Building Nextcloud OIDC auth URL with params: {idp_params}")
logger.debug("Building Nextcloud OIDC auth URL with params: %s", idp_params)
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to Nextcloud OIDC login: {auth_url}")
logger.debug("Redirecting to Nextcloud OIDC login: %s", auth_url)
return RedirectResponse(auth_url, status_code=302)
@@ -228,8 +272,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"OAuth login error: {error} - {error_description}")
logger.error("OAuth login error: %s - %s", error, error_description)
login_url = str(request.url_for("oauth_login"))
# html_escape: error / error_description come from attacker-controlled
# query parameters and would otherwise reflect into the failure page.
return HTMLResponse(
f"""
<!DOCTYPE html>
@@ -237,9 +283,9 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<head><title>Login Failed</title></head>
<body>
<h1>Login Failed</h1>
<p>Error: {error}</p>
<p>{error_description}</p>
<p><a href="{login_url}">Try again</a></p>
<p>Error: {html_escape(error)}</p>
<p>{html_escape(error_description)}</p>
<p><a href="{html_escape(login_url)}">Try again</a></p>
</body>
</html>
""",
@@ -278,8 +324,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if oauth_session:
# code_verifier was stored in mcp_authorization_code field
code_verifier = oauth_session.get("mcp_authorization_code", "")
# next_url was stored in client_redirect_uri field
next_url = oauth_session.get("client_redirect_uri", "/app")
# next_url was stored in client_redirect_uri field — re-validate at
# read-time as defense-in-depth (issue #758 finding 3). The session
# row could have been written by an older code path or reused.
next_url = _safe_next_url(oauth_session.get("client_redirect_uri"), "/app")
# Clean up the temporary session
# Note: We don't have delete_oauth_session method, but it will expire after TTL
@@ -347,8 +395,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
e.response.text if hasattr(e.response, "text") else str(e.response.content)
)
logger.error(
f"Token exchange failed: HTTP {e.response.status_code} - {error_body}"
"Token exchange failed: HTTP %s - %s", e.response.status_code, error_body
)
# html_escape: error_body originates from the IdP and could contain
# markup that would be reflected into the failure page otherwise.
return HTMLResponse(
f"""
<!DOCTYPE html>
@@ -357,14 +407,14 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<body>
<h1>Login Failed</h1>
<p>Failed to exchange authorization code for tokens</p>
<p>HTTP {e.response.status_code}: {error_body}</p>
<p>HTTP {e.response.status_code}: {html_escape(error_body)}</p>
</body>
</html>
""",
status_code=500,
)
except Exception as e:
logger.error(f"Token exchange failed: {e}")
logger.error("Token exchange failed: %s", e)
return HTMLResponse(
f"""
<!DOCTYPE html>
@@ -373,7 +423,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
<body>
<h1>Login Failed</h1>
<p>Failed to exchange authorization code for tokens</p>
<p>Error: {e}</p>
<p>Error: {html_escape(str(e))}</p>
</body>
</html>
""",
@@ -383,9 +433,11 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
refresh_token = token_data.get("refresh_token")
id_token = token_data.get("id_token")
logger.info(f"Token exchange response keys: {token_data.keys()}")
logger.info(f"Refresh token present: {refresh_token is not None}")
logger.info(f"ID token present: {id_token is not None}")
# Demoted to DEBUG (PR #758 nit a) — these were previously logged at
# INFO on every login.
logger.debug("Token exchange response keys: %s", token_data.keys())
logger.debug("Refresh token present: %s", refresh_token is not None)
logger.debug("ID token present: %s", id_token is not None)
# Resolve the discovery URL + audience used for THIS auth request so
# we can verify the ID token signature + claims (issue #626 finding 1).
@@ -431,8 +483,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
refresh_expires_at = None
if refresh_expires_in:
refresh_expires_at = int(time.time()) + refresh_expires_in
logger.info(
f"Refresh token expires in {refresh_expires_in}s (at timestamp {refresh_expires_at})"
logger.debug(
"Refresh token expires in %ss (at timestamp %s)",
refresh_expires_in,
refresh_expires_at,
)
# Extract granted scopes
@@ -442,10 +496,13 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
# Store refresh token (for background jobs ONLY)
if refresh_token:
logger.info(f"Storing refresh token for user_id: {user_id}")
logger.info(f" State parameter (provisioning_client_id): {state[:16]}...")
logger.info(f" Granted scopes: {granted_scopes}")
logger.info(f" Expires at: {refresh_expires_at}")
logger.debug(
"Storing refresh token for user_id=%s state=%s... scopes=%s expires_at=%s",
user_id,
state[:16],
granted_scopes,
refresh_expires_at,
)
await storage.store_refresh_token(
user_id=user_id,
refresh_token=refresh_token,
@@ -454,9 +511,10 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
provisioning_client_id=state, # Store state for unified session lookup
scopes=granted_scopes,
)
logger.info(f"✓ Refresh token stored successfully for user_id: {user_id}")
logger.info(
f" Token can now be found via provisioning_client_id={state[:16]}..."
"Refresh token stored for user %s (lookup key: %s...)",
user_id,
state[:16],
)
else:
logger.warning("No refresh token in token response - cannot store session")
@@ -478,13 +536,13 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
if profile_data:
# Cache profile for browser UI (no token needed to display)
await storage.store_user_profile(user_id, profile_data)
logger.info(f"User profile cached for {user_id}")
logger.debug("User profile cached for %s", user_id)
else:
logger.warning(f"Failed to query userinfo endpoint for {user_id}")
logger.warning("Failed to query userinfo endpoint for %s", user_id)
else:
logger.warning("Could not determine userinfo endpoint")
except Exception as e:
logger.error(f"Error caching user profile: {e}")
logger.error("Error caching user profile: %s", e)
# Continue anyway - profile cache is optional for browser UI
# Create a server-side browser session: a random opaque session_id is
@@ -510,7 +568,7 @@ async def oauth_login_callback(request: Request) -> RedirectResponse | HTMLRespo
return response
async def oauth_logout(request: Request) -> RedirectResponse:
async def oauth_logout(request: Request) -> RedirectResponse | JSONResponse:
"""Browser OAuth logout — invalidate session and revoke refresh token.
Issue #626 finding 4: prior implementation only cleared the cookie,
@@ -523,13 +581,30 @@ async def oauth_logout(request: Request) -> RedirectResponse:
if it leaks.
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.
Query parameters:
next: Optional URL to redirect to after logout (default: /oauth/login)
"""
next_url = request.query_params.get("next", "/oauth/login")
next_url = _safe_next_url(request.query_params.get("next"), "/oauth/login")
session_id = request.cookies.get("mcp_session")
oauth_ctx = getattr(request.app.state, "oauth_context", None)
# CSRF check: when Origin or Referer is present, host must match the
# MCP server's own host. Per OWASP CSRF cheat sheet, we allow the
# request through when neither header is present (some user agents
# strip both for privacy on same-origin POST).
if oauth_ctx and not _origin_matches_self(request, oauth_ctx):
logger.warning(
"Logout blocked: cross-origin request from %s",
request.headers.get("origin") or request.headers.get("referer"),
)
return JSONResponse({"error": "forbidden"}, status_code=403)
storage = oauth_ctx.get("storage") if oauth_ctx else None
if session_id and storage and oauth_ctx:
@@ -563,8 +638,12 @@ async def _revoke_refresh_token_at_idp(oauth_ctx: dict, refresh_token: str) -> N
of deleting the local copy, and we don't want logout to error if the
IdP is unreachable or doesn't advertise a revocation endpoint.
"""
# Production oauth_context nests config under "config" (see app.py
# starlette_lifespan). A flat shape is also accepted for tests and
# historical callers.
cfg = oauth_ctx.get("config") or oauth_ctx
try:
discovery_url = oauth_ctx.get("discovery_url") or os.getenv(
discovery_url = cfg.get("discovery_url") or os.getenv(
"OIDC_DISCOVERY_URL",
f"{os.getenv('NEXTCLOUD_HOST', '')}/.well-known/openid-configuration",
)
@@ -580,10 +659,8 @@ async def _revoke_refresh_token_at_idp(oauth_ctx: dict, refresh_token: str) -> N
logger.debug("IdP advertises no revocation_endpoint; skipping")
return
client_id = oauth_ctx.get("client_id") or os.getenv("OIDC_CLIENT_ID")
client_secret = oauth_ctx.get("client_secret") or os.getenv(
"OIDC_CLIENT_SECRET"
)
client_id = cfg.get("client_id") or os.getenv("OIDC_CLIENT_ID")
client_secret = cfg.get("client_secret") or os.getenv("OIDC_CLIENT_SECRET")
if not (client_id and client_secret):
logger.debug("No OIDC client credentials available for revocation")
return
+53 -28
View File
@@ -31,7 +31,7 @@ import os
import socket
import time
from pathlib import Path
from typing import Any, Optional
from typing import Any
import aiosqlite
import anyio
@@ -205,11 +205,11 @@ class RefreshTokenStorage:
self,
user_id: str,
refresh_token: str,
expires_at: Optional[int] = None,
expires_at: int | None = None,
flow_type: str = "hybrid",
token_audience: str = "nextcloud",
provisioning_client_id: Optional[str] = None,
scopes: Optional[list[str]] = None,
provisioning_client_id: str | None = None,
scopes: list[str] | None = None,
) -> None:
"""
Store encrypted refresh token for user.
@@ -313,7 +313,7 @@ class RefreshTokenStorage:
logger.debug(f"Cached user profile for {user_id}")
async def get_user_profile(self, user_id: str) -> Optional[dict[str, Any]]:
async def get_user_profile(self, user_id: str) -> dict[str, Any] | None:
"""
Retrieve cached user profile data.
@@ -351,7 +351,7 @@ class RefreshTokenStorage:
return profile_data
async def get_refresh_token(self, user_id: str) -> Optional[dict]:
async def get_refresh_token(self, user_id: str) -> dict | None:
"""
Retrieve and decrypt refresh token for user.
@@ -444,7 +444,7 @@ class RefreshTokenStorage:
async def get_refresh_token_by_provisioning_client_id(
self, provisioning_client_id: str
) -> Optional[dict]:
) -> dict | None:
"""
Retrieve and decrypt refresh token by provisioning_client_id (state parameter).
@@ -617,8 +617,8 @@ class RefreshTokenStorage:
client_id_issued_at: int,
client_secret_expires_at: int,
redirect_uris: list[str],
registration_access_token: Optional[str] = None,
registration_client_uri: Optional[str] = None,
registration_access_token: str | None = None,
registration_client_uri: str | None = None,
) -> None:
"""
Store encrypted OAuth client credentials.
@@ -689,7 +689,7 @@ class RefreshTokenStorage:
auth_method="oauth",
)
async def get_oauth_client(self) -> Optional[dict]:
async def get_oauth_client(self) -> dict | None:
"""
Retrieve and decrypt OAuth client credentials.
@@ -827,9 +827,9 @@ class RefreshTokenStorage:
self,
event: str,
user_id: str,
resource_type: Optional[str] = None,
resource_id: Optional[str] = None,
auth_method: Optional[str] = None,
resource_type: str | None = None,
resource_id: str | None = None,
auth_method: str | None = None,
) -> None:
"""
Log operation to audit log.
@@ -866,8 +866,8 @@ class RefreshTokenStorage:
async def get_audit_logs(
self,
user_id: Optional[str] = None,
since: Optional[int] = None,
user_id: str | None = None,
since: int | None = None,
limit: int = 100,
) -> list[dict]:
"""
@@ -909,14 +909,14 @@ class RefreshTokenStorage:
self,
session_id: str,
client_redirect_uri: str,
state: Optional[str] = None,
code_challenge: Optional[str] = None,
code_challenge_method: Optional[str] = None,
mcp_authorization_code: Optional[str] = None,
client_id: Optional[str] = None,
state: str | None = None,
code_challenge: str | None = None,
code_challenge_method: str | None = None,
mcp_authorization_code: str | None = None,
client_id: str | None = None,
flow_type: str = "hybrid",
is_provisioning: bool = False,
requested_scopes: Optional[str] = None,
requested_scopes: str | None = None,
ttl_seconds: int = 600, # 10 minutes
) -> None:
"""
@@ -969,7 +969,7 @@ class RefreshTokenStorage:
logger.debug(f"Stored OAuth session {session_id} (expires in {ttl_seconds}s)")
async def get_oauth_session(self, session_id: str) -> Optional[dict]:
async def get_oauth_session(self, session_id: str) -> dict | None:
"""
Retrieve OAuth session by session ID.
@@ -1001,7 +1001,7 @@ class RefreshTokenStorage:
async def get_oauth_session_by_mcp_code(
self, mcp_authorization_code: str
) -> Optional[dict]:
) -> dict | None:
"""
Retrieve OAuth session by MCP authorization code.
@@ -1037,9 +1037,9 @@ class RefreshTokenStorage:
async def update_oauth_session(
self,
session_id: str,
user_id: Optional[str] = None,
idp_access_token: Optional[str] = None,
idp_refresh_token: Optional[str] = None,
user_id: str | None = None,
idp_access_token: str | None = None,
idp_refresh_token: str | None = None,
) -> bool:
"""
Update OAuth session with IdP token data.
@@ -1174,7 +1174,7 @@ class RefreshTokenStorage:
ttl_seconds,
)
async def get_browser_session_user(self, session_id: str) -> Optional[str]:
async def get_browser_session_user(self, session_id: str) -> str | None:
"""Look up the user_id bound to a browser session_id, or None.
Returns None when the session is unknown or expired. Expired rows
@@ -1217,6 +1217,31 @@ class RefreshTokenStorage:
logger.debug("Deleted browser session %s", session_id[:8])
return deleted
async def cleanup_expired_browser_sessions(self) -> int:
"""Remove expired ``browser_sessions`` rows.
Returns the number of rows deleted. Called by the periodic cleanup
task in ``app.py``. Without this users who never explicitly log out
leave session rows behind that only get deleted lazily on lookup
(PR #758 finding 6).
"""
if not self._initialized:
await self.initialize()
now = int(time.time())
async with aiosqlite.connect(self.db_path) as db:
cursor = await db.execute(
"DELETE FROM browser_sessions WHERE expires_at < ?", (now,)
)
await db.commit()
deleted = cursor.rowcount
if deleted > 0:
logger.info("Cleaned up %s expired browser session(s)", deleted)
return deleted
# ============================================================================
# Webhook Registration Tracking (both BasicAuth and OAuth modes)
# ============================================================================
@@ -1396,7 +1421,7 @@ class RefreshTokenStorage:
auth_method="app_password",
)
async def get_app_password(self, user_id: str) -> Optional[str]:
async def get_app_password(self, user_id: str) -> str | None:
"""
Retrieve and decrypt app password for a user.
@@ -285,14 +285,18 @@
<ul class="app-navigation__settings">
<li class="app-navigation-entry">
<div class="app-navigation-entry__wrapper">
<a href="{{ logout_url }}" class="app-navigation-entry-link">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Logout</span>
</a>
{# Logout is POST-only to defeat CSRF (PR #758 finding 5).
Style this <button> like the surrounding link entries. #}
<form method="post" action="{{ logout_url }}" class="app-navigation-entry-link" style="display:contents;">
<button type="submit" class="app-navigation-entry-link" style="background:none;border:0;padding:0;font:inherit;color:inherit;cursor:pointer;display:flex;align-items:center;width:100%;">
<span class="app-navigation-entry-icon">
<svg class="nav-icon" viewBox="0 0 24 24">
<path d="M16,17V14H9V10H16V7L21,12L16,17M14,2A2,2 0 0,1 16,4V6H14V4H5V20H14V18H16V20A2,2 0 0,1 14,22H5A2,2 0 0,1 3,20V4A2,2 0 0,1 5,2H14Z" />
</svg>
</span>
<span class="app-navigation-entry__name">Logout</span>
</button>
</form>
</div>
</li>
</ul>
+45 -14
View File
@@ -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={