fix(auth): address PR #758 round-6 medium/low review
Five findings from the latest review on #758 (2 medium, 3 nit): Medium: - browser_oauth_routes.oauth_login_callback + oauth_routes.oauth_callback_nextcloud: fail closed with 400 when the oauth_session row is unknown/expired. Previously both callbacks fell through with code_verifier="" and expected_nonce=None, silently bypassing the PKCE + nonce protections introduced in earlier rounds. Symmetric unit tests pin both contracts. - token_utils.verify_id_token: use secrets.compare_digest for the nonce check instead of short-circuit !=. Mirrors the sibling PKCE verifier comparison; closes the last secret-equality timing-side-channel surface in the auth path. Nit: - Tighten the comment at all 4 mcp_authorization_code/code_verifier store + retrieve sites so a future refactor sees the field reuse immediately (renaming the column requires a schema migration). - _should_use_secure_cookies: explicit string normalisation instead of bool(settings.cookie_secure). Dynaconf normally coerces but tests / direct settings.set calls can leave the raw string in place — bool("false") is True. New parametrized unit tests cover the coercion matrix + http/https fallback. - oauth_routes.py:591 f-string log converted to lazy %s formatting (folded into the Flow 2 callback rewrite). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
e2955e8246
commit
ec9b9b2a75
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for ``browser_oauth_routes`` helpers.
|
||||
|
||||
Pins the round-6 review fix that ``_should_use_secure_cookies`` must not
|
||||
trust ``bool(settings.cookie_secure)`` — Dynaconf normally coerces but
|
||||
tests / direct ``settings.set`` calls can leave the raw string in place,
|
||||
and ``bool("false")`` is ``True``.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth import browser_oauth_routes
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _fake_settings(*, cookie_secure, mcp_server_url=""):
|
||||
return type(
|
||||
"S",
|
||||
(),
|
||||
{
|
||||
"cookie_secure": cookie_secure,
|
||||
"nextcloud_mcp_server_url": mcp_server_url,
|
||||
},
|
||||
)()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,expected",
|
||||
[
|
||||
(True, True),
|
||||
(False, False),
|
||||
("true", True),
|
||||
("false", False),
|
||||
("True", True),
|
||||
("FALSE", False),
|
||||
("0", False),
|
||||
("1", True),
|
||||
("no", False),
|
||||
("yes", True),
|
||||
("off", False),
|
||||
("on", True),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_should_use_secure_cookies_string_coercion(monkeypatch, value, expected):
|
||||
monkeypatch.setattr(
|
||||
browser_oauth_routes,
|
||||
"get_settings",
|
||||
lambda: _fake_settings(cookie_secure=value),
|
||||
)
|
||||
assert browser_oauth_routes._should_use_secure_cookies() is expected
|
||||
|
||||
|
||||
def test_should_use_secure_cookies_falls_back_to_https_scheme(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
browser_oauth_routes,
|
||||
"get_settings",
|
||||
lambda: _fake_settings(
|
||||
cookie_secure=None, mcp_server_url="https://mcp.example.com"
|
||||
),
|
||||
)
|
||||
assert browser_oauth_routes._should_use_secure_cookies() is True
|
||||
|
||||
|
||||
def test_should_use_secure_cookies_falls_back_to_http_scheme(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
browser_oauth_routes,
|
||||
"get_settings",
|
||||
lambda: _fake_settings(
|
||||
cookie_secure=None, mcp_server_url="http://localhost:8000"
|
||||
),
|
||||
)
|
||||
assert browser_oauth_routes._should_use_secure_cookies() is False
|
||||
@@ -20,10 +20,10 @@ import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
|
||||
from nextcloud_mcp_server.auth.oauth_routes import (
|
||||
ASProxySession,
|
||||
_as_proxy_sessions,
|
||||
@@ -136,51 +136,51 @@ async def test_callback_deletes_oauth_session_after_reading_verifier(storage):
|
||||
)
|
||||
|
||||
|
||||
async def test_callback_no_session_row_does_not_crash(storage):
|
||||
"""If the row is already gone (e.g. expired), the callback proceeds."""
|
||||
async def test_callback_unknown_state_returns_400(storage):
|
||||
"""Unknown/expired state must fail closed with 400.
|
||||
|
||||
Pins the PR #758 round-6 review fix: previously the callback fell
|
||||
through with empty ``code_verifier`` / ``expected_nonce=None``,
|
||||
silently bypassing the PKCE + nonce protections introduced in earlier
|
||||
rounds. The handler now returns 400 before any token exchange.
|
||||
"""
|
||||
state = "state-missing"
|
||||
# No store_oauth_session call — the row never existed.
|
||||
|
||||
request = _build_request(code="idp-auth-code", state=state, storage=storage)
|
||||
|
||||
fake_discovery = {
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"userinfo_endpoint": "https://idp.example.com/userinfo",
|
||||
"issuer": "https://idp.example.com",
|
||||
response = await oauth_callback_nextcloud(request)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert await storage.get_oauth_session(state) is None
|
||||
|
||||
|
||||
async def test_browser_callback_unknown_state_returns_400(storage):
|
||||
"""Symmetric unknown-state contract for the browser-flow callback.
|
||||
|
||||
Mirrors ``test_callback_unknown_state_returns_400`` for
|
||||
``oauth_login_callback`` — both callbacks must fail closed when the
|
||||
oauth_session row is missing/expired (PR #758 round-6 review).
|
||||
"""
|
||||
state = "state-missing-browser"
|
||||
|
||||
request = MagicMock()
|
||||
request.query_params = {"code": "idp-auth-code", "state": state}
|
||||
request.cookies = {}
|
||||
request.url_for = MagicMock(return_value="/oauth/login")
|
||||
request.app.state.oauth_context = {
|
||||
"storage": storage,
|
||||
"oauth_client": None, # Nextcloud-integrated mode
|
||||
"config": {
|
||||
"mcp_server_url": "https://mcp.example.com",
|
||||
"client_id": "mcp-server",
|
||||
"client_secret": "mcp-secret",
|
||||
},
|
||||
}
|
||||
fake_token_response = MagicMock()
|
||||
fake_token_response.json.return_value = {"access_token": "ac"}
|
||||
fake_token_response.raise_for_status = MagicMock(
|
||||
side_effect=httpx.HTTPStatusError(
|
||||
"boom",
|
||||
request=MagicMock(),
|
||||
response=MagicMock(status_code=400),
|
||||
)
|
||||
)
|
||||
|
||||
fake_http = MagicMock()
|
||||
fake_http.post = AsyncMock(return_value=fake_token_response)
|
||||
fake_http.__aenter__ = AsyncMock(return_value=fake_http)
|
||||
fake_http.__aexit__ = AsyncMock(return_value=None)
|
||||
response = await oauth_login_callback(request)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
|
||||
new=AsyncMock(return_value=fake_discovery),
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
|
||||
return_value=fake_http,
|
||||
),
|
||||
):
|
||||
# We don't care what happens past the deletion — just that the
|
||||
# missing-row branch doesn't try to delete a nonexistent session.
|
||||
try:
|
||||
await oauth_callback_nextcloud(request)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# No crash, no row, no surprises.
|
||||
assert response.status_code == 400
|
||||
assert await storage.get_oauth_session(state) is None
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user