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"