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
+26
View File
@@ -1178,6 +1178,16 @@ class RefreshTokenStorage:
ttl_seconds,
)
# Audit log to match the pattern used by the other security-relevant
# storage operations (PR #758 round-3 nit 5). Browser session
# establishment is a security-relevant event.
await self._audit_log(
event="create_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
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.
@@ -1210,7 +1220,16 @@ class RefreshTokenStorage:
if not self._initialized:
await self.initialize()
# SELECT the row before DELETE so we can attribute the audit log
# entry to the right user (PR #758 round-3 nit 5).
async with aiosqlite.connect(self.db_path) as db:
async with db.execute(
"SELECT user_id FROM browser_sessions WHERE session_id = ?",
(session_id,),
) as cursor:
row = await cursor.fetchone()
user_id = row[0] if row else None
cursor = await db.execute(
"DELETE FROM browser_sessions WHERE session_id = ?", (session_id,)
)
@@ -1219,6 +1238,13 @@ class RefreshTokenStorage:
if deleted:
logger.debug("Deleted browser session %s", session_id[:8])
if user_id:
await self._audit_log(
event="delete_browser_session",
user_id=user_id,
resource_type="browser_session",
resource_id=session_id[:8],
)
return deleted
async def cleanup_expired_browser_sessions(self) -> int: