fix(auth): address PR #758 round-5 medium/low review

Three findings from the latest review on #758 (1 medium, 2 low):

Medium:
- browser_oauth_routes.oauth_logout: move delete_browser_session into a
  finally block so an error from delete_refresh_token can no longer leave
  an orphan browser_sessions row. The orphan was not exploitable
  (SessionAuthBackend rejects sessions without a live refresh token), but
  it lingered until the hourly cleanup cron — a correctness gap. New
  regression test pins the fix.

Low:
- oauth_callback_nextcloud: drop redundant ``or None`` from
  ``expected_nonce=nonce``. ``nonce`` is already ``str | None`` and
  ``secrets.token_urlsafe`` never produces an empty string, so the
  coercion was a no-op that could mislead future readers into thinking
  empty-string was a valid skip-the-check path.
- storage.RefreshTokenStorage.initialize: fail fast at startup when
  SQLite < 3.35, since ``DELETE ... RETURNING`` (used in
  ``delete_browser_session``) needs that minimum. Ubuntu 20.04 ships
  3.31 and would otherwise hit OperationalError on every logout.
  Prerequisite also documented in docs/installation.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-03 01:37:38 +02:00
co-authored by Claude Opus 4.7
parent b696541918
commit e2955e8246
5 changed files with 86 additions and 5 deletions
+1
View File
@@ -5,6 +5,7 @@ This guide covers installing the Nextcloud MCP server on your system.
## Prerequisites ## Prerequisites
- **Python 3.11+** - Check with `python3 --version` - **Python 3.11+** - Check with `python3 --version`
- **SQLite 3.35+** - Check with `python3 -c "import sqlite3; print(sqlite3.sqlite_version)"`. The OAuth session storage uses `DELETE ... RETURNING`, which is only available from SQLite 3.35 (March 2021). Ubuntu 20.04 ships SQLite 3.31 and is **not** supported; upgrade the host or run from the Docker image, which bundles a newer libsqlite3.
- **Access to a Nextcloud instance** - Self-hosted or cloud-hosted - **Access to a Nextcloud instance** - Self-hosted or cloud-hosted
- **Administrator access** *(optional)* - Only needed to customise app-password policies in Nextcloud settings; not required for any deployment mode (single-user, multi-user BasicAuth, or Login Flow v2) - **Administrator access** *(optional)* - Only needed to customise app-password policies in Nextcloud settings; not required for any deployment mode (single-user, multi-user BasicAuth, or Login Flow v2)
@@ -683,11 +683,22 @@ async def oauth_logout(request: Request) -> RedirectResponse | JSONResponse:
await _revoke_refresh_token_at_idp(oauth_ctx, refresh_token) await _revoke_refresh_token_at_idp(oauth_ctx, refresh_token)
await storage.delete_refresh_token(user_id) await storage.delete_refresh_token(user_id)
logger.info("Refresh token revoked + deleted for user %s", user_id) logger.info("Refresh token revoked + deleted for user %s", user_id)
await storage.delete_browser_session(session_id)
except Exception as e: except Exception as e:
# Logout must always succeed locally; log and continue. # Logout must always succeed locally; log and continue.
logger.warning("Logout cleanup failed (continuing): %s", e) logger.warning("Logout cleanup failed (continuing): %s", e)
finally:
# Always drop the browser_sessions row, even when the
# refresh-token cleanup above failed — otherwise an orphan
# row lingers until the hourly cleanup cron (PR #758 round-5
# review medium 1). Not exploitable (SessionAuthBackend
# already rejects sessions without a live refresh token), but
# a correctness gap worth closing here.
try:
await storage.delete_browser_session(session_id)
except Exception as e:
logger.warning(
"Failed to delete browser session %s…: %s", session_id[:8], e
)
response = RedirectResponse(next_url, status_code=302) response = RedirectResponse(next_url, status_code=302)
response.delete_cookie("mcp_session") response.delete_cookie("mcp_session")
+6 -3
View File
@@ -647,15 +647,18 @@ async def oauth_callback_nextcloud(request: Request):
# Verify ID token signature + claims (issue #626 finding 1). # Verify ID token signature + claims (issue #626 finding 1).
# ``expected_nonce`` is the per-request nonce stored on the # ``expected_nonce`` is the per-request nonce stored on the
# oauth_session row (PR #758 round-3 finding 1); falsy → skip nonce # oauth_session row (PR #758 round-3 finding 1). ``nonce`` is already
# check for sessions written before the column existed. # ``str | None`` and ``secrets.token_urlsafe`` never produces an empty
# string, so passing it directly is correct — pre-migration-006 rows
# surface as ``None`` from ``oauth_session.get("nonce")``, which
# ``verify_id_token`` already treats as "skip the check".
logger.info("oauth_callback_nextcloud: Verifying ID token") logger.info("oauth_callback_nextcloud: Verifying ID token")
try: try:
userinfo = await verify_id_token( userinfo = await verify_id_token(
id_token, id_token,
discovery_url=discovery_url, discovery_url=discovery_url,
expected_audience=mcp_server_client_id, expected_audience=mcp_server_client_id,
expected_nonce=nonce or None, expected_nonce=nonce,
) )
except IdTokenVerificationError as e: except IdTokenVerificationError as e:
logger.error("ID token verification failed: %s", e) logger.error("ID token verification failed: %s", e)
+16
View File
@@ -29,6 +29,7 @@ import json
import logging import logging
import os import os
import socket import socket
import sqlite3
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -139,10 +140,25 @@ class RefreshTokenStorage:
1. New database: Run migrations from scratch 1. New database: Run migrations from scratch
2. Pre-Alembic database: Stamp with initial revision (no changes) 2. Pre-Alembic database: Stamp with initial revision (no changes)
3. Alembic-managed database: Upgrade to latest version 3. Alembic-managed database: Upgrade to latest version
Raises:
RuntimeError: when the underlying SQLite library is older than
3.35, which is required for ``DELETE ... RETURNING`` used by
``delete_browser_session`` (PR #758 round-5 review low 2).
Ubuntu 20.04 ships SQLite 3.31, so deployers on that
baseline must upgrade or use a newer Python image.
""" """
if self._initialized: if self._initialized:
return return
if sqlite3.sqlite_version_info < (3, 35):
raise RuntimeError(
"SQLite >= 3.35 is required (DELETE ... RETURNING is used "
"by delete_browser_session); detected "
f"{sqlite3.sqlite_version}. Upgrade SQLite or use a Python "
"image with a newer bundled libsqlite3."
)
# Ensure directory exists # Ensure directory exists
db_dir = Path(self.db_path).parent db_dir = Path(self.db_path).parent
db_dir.mkdir(parents=True, exist_ok=True) db_dir.mkdir(parents=True, exist_ok=True)
+50
View File
@@ -182,6 +182,56 @@ async def test_logout_swallows_storage_errors(storage):
assert response.status_code == 302 # logout still succeeds assert response.status_code == 302 # logout still succeeds
async def test_logout_deletes_session_when_refresh_token_delete_fails(storage):
"""Browser session row must be removed even if delete_refresh_token raises.
Pins PR #758 round-5 review medium 1: previously the two deletes lived
in the same try-block, so an error on ``delete_refresh_token`` left an
orphan ``browser_sessions`` row that lingered until the cleanup cron.
"""
await storage.create_browser_session(session_id="sid-orphan", user_id="dave")
await storage.store_refresh_token(
user_id="dave", refresh_token="rt-dave", flow_type="browser"
)
real_delete_refresh_token = storage.delete_refresh_token
real_delete_browser_session = storage.delete_browser_session
storage.delete_refresh_token = AsyncMock(side_effect=RuntimeError("boom"))
delete_browser_session_calls: list[str] = []
async def tracking_delete_browser_session(session_id: str) -> bool:
delete_browser_session_calls.append(session_id)
return await real_delete_browser_session(session_id)
storage.delete_browser_session = tracking_delete_browser_session
request = _build_request(
cookie="sid-orphan",
oauth_context={
"storage": storage,
"config": {
"mcp_server_url": "https://mcp.example.com",
"discovery_url": None,
},
},
)
try:
response = await oauth_logout(request)
finally:
storage.delete_refresh_token = real_delete_refresh_token
storage.delete_browser_session = real_delete_browser_session
assert response.status_code == 302
assert delete_browser_session_calls == ["sid-orphan"], (
"delete_browser_session must run even after delete_refresh_token raised"
)
assert await storage.get_browser_session_user("sid-orphan") is None, (
"browser_sessions row must be gone — finally branch failed to fire"
)
async def test_logout_blocks_cross_origin_post(storage): async def test_logout_blocks_cross_origin_post(storage):
"""POST from a foreign Origin must be rejected with 403 (PR #758 finding 5).""" """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") await storage.create_browser_session(session_id="sid-X", user_id="alice")