fix(auth): address PR #758 round-3 review

- Flow 2 (oauth_authorize_nextcloud) now generates a nonce, stores it on
  the oauth_session row, forwards it to the IdP, and verifies it via
  expected_nonce in oauth_callback_nextcloud — closes the last replay-
  protection gap (round-3 finding 1).
- _origin_matches_self fails closed when mcp_server_url is missing
  instead of allowing the logout, and the diagnostic log is promoted
  from warning to error so the misconfiguration is monitorable
  (round-3 finding 2). New regression test pins the new behaviour.
- The five user_id-accepting helpers in oauth_tools.py (get_provisioning_status,
  provision_nextcloud_access, revoke_nextcloud_access, check_provisioning_status,
  check_logged_in) are renamed with leading underscores to make the
  trust boundary structural rather than documentary
  (round-3 finding 3).
- create_browser_session and delete_browser_session now emit audit_log
  rows so session establishment / teardown match the pattern used by
  the rest of the security-relevant storage operations
  (round-3 nit 5). delete_browser_session selects user_id before delete
  so the audit row is attributable.
- oauth_login_callback no longer reflects raw IdP-error text or
  exception strings into the HTML failure page; users see a generic
  "internal error occurred" message + a correlation ID, with the
  detail logged server-side keyed by the same ID (round-3 nit 6).
  The XSS regression test is updated to pin the stricter contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-02 22:40:06 +02:00
co-authored by Claude Opus 4.7
parent c33d52ea91
commit 9d0e7dcebe
6 changed files with 163 additions and 49 deletions
+14 -3
View File
@@ -70,8 +70,14 @@ async def test_callback_escapes_error_query_params(storage):
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in body
async def test_callback_escapes_idp_http_error_body(storage):
"""IdP-returned HTTPError body must be HTML-escaped before reflection."""
async def test_callback_does_not_reflect_idp_http_error_body(storage):
"""IdP-returned HTTPError body must not appear in the user-visible HTML.
Updated for PR #758 round-3 nit 6: the callback now logs the IdP
response server-side and shows the user only a generic message + a
correlation ID, eliminating reflection of attacker-controllable text
into the error page entirely.
"""
discovery = {"token_endpoint": "http://idp.example/token"}
def handler(request: httpx.Request) -> httpx.Response:
@@ -135,5 +141,10 @@ async def test_callback_escapes_idp_http_error_body(storage):
body = response.body.decode()
assert response.status_code == 500
# Strict: neither the raw payload nor an HTML-escaped form of the
# IdP body should appear — the page must show only the generic
# message + correlation ID.
assert XSS_PAYLOAD not in body
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in body
assert "&lt;script&gt;alert(1)&lt;/script&gt;" not in body
assert "An internal error occurred" in body
assert "Correlation ID" in body
+49 -5
View File
@@ -92,7 +92,13 @@ async def test_logout_deletes_refresh_token_and_session(storage):
request = _build_request(
cookie="sid-1",
oauth_context={"storage": storage, "config": {"discovery_url": None}},
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
)
with patch(
@@ -118,7 +124,10 @@ async def test_logout_calls_revocation_when_refresh_token_present(storage):
cookie="sid-2",
oauth_context={
"storage": storage,
"config": {"discovery_url": "http://idp/.well-known"},
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": "http://idp/.well-known",
},
},
)
@@ -138,7 +147,13 @@ 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, "config": {"discovery_url": None}},
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
)
response = await oauth_logout(request)
assert response.status_code == 302
@@ -157,7 +172,10 @@ async def test_logout_swallows_storage_errors(storage):
cookie="sid-3",
oauth_context={
"storage": broken_storage,
"config": {"discovery_url": None},
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
)
response = await oauth_logout(request)
@@ -295,6 +313,26 @@ async def test_logout_allows_referer_when_origin_missing(storage):
assert response.status_code == 302
async def test_logout_blocked_when_mcp_server_url_missing(storage):
"""Fail-closed CSRF (PR #758 round-3 finding 2): missing ``mcp_server_url``
in oauth_ctx must reject the logout, not allow it.
A future code path that leaves ``mcp_server_url`` unset would
otherwise silently disable CSRF protection. Blocking is recoverable.
"""
await storage.create_browser_session(session_id="sid-MM", user_id="alice")
request = _build_request(
cookie="sid-MM",
oauth_context={"storage": storage, "config": {"discovery_url": None}},
)
response = await oauth_logout(request)
assert response.status_code == 403
# Session must NOT have been deleted.
assert await storage.get_browser_session_user("sid-MM") == "alice"
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")
@@ -302,7 +340,13 @@ async def test_logout_handles_session_with_no_refresh_token(storage):
revoke = AsyncMock()
request = _build_request(
cookie="sid-4",
oauth_context={"storage": storage, "config": {"discovery_url": None}},
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
)
with patch(
"nextcloud_mcp_server.auth.browser_oauth_routes._revoke_refresh_token_at_idp",