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
+99 -15
View File
@@ -46,12 +46,20 @@ async def storage():
yield s
def _build_request(*, cookie: str | None, oauth_context: dict | None):
def _build_request(
*,
cookie: str | None,
oauth_context: dict | None,
headers: dict | None = None,
):
"""Build a minimal Starlette-style request stub for oauth_logout."""
request = MagicMock()
request.query_params = {}
request.cookies = {"mcp_session": cookie} if cookie else {}
request.app.state.oauth_context = oauth_context
# Headers default to empty so the CSRF check sees neither Origin nor
# Referer (allowed by policy — see _origin_matches_self).
request.headers = headers or {}
return request
@@ -69,7 +77,7 @@ async def test_logout_deletes_refresh_token_and_session(storage):
request = _build_request(
cookie="sid-1",
oauth_context={"storage": storage, "discovery_url": None},
oauth_context={"storage": storage, "config": {"discovery_url": None}},
)
with patch(
@@ -93,7 +101,10 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
revoke = AsyncMock()
request = _build_request(
cookie="sid-2",
oauth_context={"storage": storage, "discovery_url": "http://idp/.well-known"},
oauth_context={
"storage": storage,
"config": {"discovery_url": "http://idp/.well-known"},
},
)
with patch(
@@ -111,7 +122,8 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
async def test_logout_no_session_cookie_returns_302(storage):
"""Without a cookie, logout still 302s and doesn't touch storage."""
request = _build_request(
cookie=None, oauth_context={"storage": storage, "discovery_url": None}
cookie=None,
oauth_context={"storage": storage, "config": {"discovery_url": None}},
)
response = await oauth_logout(request)
assert response.status_code == 302
@@ -128,12 +140,78 @@ async def test_logout_swallows_storage_errors(storage):
request = _build_request(
cookie="sid-3",
oauth_context={"storage": broken_storage, "discovery_url": None},
oauth_context={
"storage": broken_storage,
"config": {"discovery_url": None},
},
)
response = await oauth_logout(request)
assert response.status_code == 302 # logout still succeeds
async def test_logout_blocks_cross_origin_post(storage):
"""POST from a foreign Origin must be rejected with 403 (PR #758 finding 5)."""
await storage.create_browser_session(session_id="sid-X", user_id="alice")
request = _build_request(
cookie="sid-X",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"origin": "https://evil.example.com"},
)
response = await oauth_logout(request)
assert response.status_code == 403
# Session row must NOT have been deleted.
assert await storage.get_browser_session_user("sid-X") == "alice"
async def test_logout_allows_same_origin_post(storage):
"""POST with matching Origin proceeds normally."""
await storage.create_browser_session(session_id="sid-Y", user_id="alice")
request = _build_request(
cookie="sid-Y",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"origin": "https://mcp.example.com"},
)
response = await oauth_logout(request)
assert response.status_code == 302
assert await storage.get_browser_session_user("sid-Y") is None
async def test_logout_allows_referer_when_origin_missing(storage):
"""Some browsers strip Origin on POST; Referer is the fallback signal."""
await storage.create_browser_session(session_id="sid-Z", user_id="alice")
request = _build_request(
cookie="sid-Z",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
headers={"referer": "https://mcp.example.com/app"},
)
response = await oauth_logout(request)
assert response.status_code == 302
async def test_logout_handles_session_with_no_refresh_token(storage):
"""Cookie + session row exist but refresh token already gone — logout is idempotent."""
await storage.create_browser_session(session_id="sid-4", user_id="dave")
@@ -141,7 +219,7 @@ async def test_logout_handles_session_with_no_refresh_token(storage):
revoke = AsyncMock()
request = _build_request(
cookie="sid-4",
oauth_context={"storage": storage, "discovery_url": None},
oauth_context={"storage": storage, "config": {"discovery_url": None}},
)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",
@@ -197,9 +275,11 @@ async def test_revoke_helper_posts_to_revocation_endpoint():
):
await _revoke_refresh_token_at_idp(
{
"discovery_url": discovery_url,
"client_id": "test-client",
"client_secret": "test-secret",
"config": {
"discovery_url": discovery_url,
"client_id": "test-client",
"client_secret": "test-secret",
}
},
"rt-secret",
)
@@ -232,9 +312,11 @@ async def test_revoke_helper_skips_when_no_revocation_endpoint():
# Returns None and does not raise
result = await _revoke_refresh_token_at_idp(
{
"discovery_url": discovery_url,
"client_id": "x",
"client_secret": "y",
"config": {
"discovery_url": discovery_url,
"client_id": "x",
"client_secret": "y",
}
},
"rt",
)
@@ -259,9 +341,11 @@ async def test_revoke_helper_silent_on_idp_error():
):
result = await _revoke_refresh_token_at_idp(
{
"discovery_url": "http://x/.well-known",
"client_id": "x",
"client_secret": "y",
"config": {
"discovery_url": "http://x/.well-known",
"client_id": "x",
"client_secret": "y",
}
},
"rt",
)