Files
mcp-nextcloud/tests/unit/test_oauth_callback_session_cleanup.py
T
Chris CoutinhoandClaude Opus 4.7 2ef4bfc4af fix(auth): fail closed on missing sub claim, delete Flow 2 callback session
Addresses the two remaining 🟡 findings from the PR #758 follow-up review:

  1. extract_user_id_from_token previously fell back to "default_user" when
     the verified access token had no sub claim. In a multi-tenant deployment
     a malformed IdP token could have bucketed every request under a single
     sentinel user, risking cross-tenant data exposure. The function now
     raises McpError on that branch; the BasicAuth no-token sentinel path is
     preserved.

  2. oauth_callback_nextcloud (Flow 2) read the PKCE code_verifier from
     oauth_sessions but never deleted the row, leaving the verifier valid for
     the full 10-minute TTL. The row is now deleted eagerly inside the same
     branch, mirroring oauth_login_callback in browser_oauth_routes.

Also wires TOKEN_ENCRYPTION_KEY through the docker-compose step in the CI
test workflow so the integration matrix can boot — every job had been
failing fast on the ${TOKEN_ENCRYPTION_KEY:?...} interpolation guard added
in PR #758 finding 5.

Tests pin both fixes (test_token_utils_user_id.py,
test_oauth_callback_session_cleanup.py).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 19:46:36 +02:00

173 lines
6.1 KiB
Python

"""Pin one-time-use semantics on the Flow-2 callback's oauth_session row.
The PR #758 follow-up review flagged that
``oauth_callback_nextcloud`` reads ``code_verifier`` from the
``oauth_sessions`` table but never deletes the row, leaving the verifier
valid for the rest of the 10-minute TTL. This test exercises the real
storage layer to confirm the row is gone after the callback runs.
We mock everything *after* the deletion (discovery + token exchange +
ID token verification) so the test focuses on the cleanup contract,
not the OAuth wire protocol.
"""
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.oauth_routes import oauth_callback_nextcloud
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
pytestmark = pytest.mark.unit
@pytest.fixture
async def storage():
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_callback_cleanup.db"
s = RefreshTokenStorage(
db_path=str(db_path), encryption_key=Fernet.generate_key().decode()
)
await s.initialize()
yield s
def _build_request(*, code: str, state: str, storage: RefreshTokenStorage):
request = MagicMock()
request.query_params = {"code": code, "state": state}
request.app.state.oauth_context = {
"storage": storage,
"config": {
"discovery_url": "https://idp.example.com/.well-known/openid-configuration",
"mcp_server_url": "https://mcp.example.com",
"client_id": "mcp-server",
"client_secret": "mcp-secret",
},
}
return request
async def test_callback_deletes_oauth_session_after_reading_verifier(storage):
"""After a successful callback exchange the row is gone.
Pins the PR #758 follow-up review fix: previously the row stayed
until the 10-minute TTL elapsed, leaving the stored ``code_verifier``
valid for replay if ``state`` leaked.
"""
state = "state-abc-123"
await storage.store_oauth_session(
session_id=state,
client_redirect_uri="http://localhost:9999/callback",
state=state,
mcp_authorization_code="verifier-pkce-secret",
flow_type="flow2",
)
# Sanity check: row exists before the callback runs.
assert await storage.get_oauth_session(state) is not None
request = _build_request(code="idp-auth-code", state=state, storage=storage)
# Stub everything after the deletion: discovery, token exchange, ID
# token verification, and the user_oidc UserInfo round-trip. The
# exact responses don't matter — we only care that the deletion has
# happened by the time these are invoked.
fake_discovery = {
"token_endpoint": "https://idp.example.com/token",
"userinfo_endpoint": "https://idp.example.com/userinfo",
"issuer": "https://idp.example.com",
}
fake_userinfo = {"sub": "alice", "email": "alice@example.com"}
fake_token_response = MagicMock()
fake_token_response.json.return_value = {
"access_token": "ac-tok",
"refresh_token": "rf-tok",
"id_token": "id-tok",
"expires_in": 3600,
}
fake_token_response.raise_for_status = MagicMock()
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)
with (
patch(
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
new=AsyncMock(return_value=fake_discovery),
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
return_value=fake_http,
),
patch(
"nextcloud_mcp_server.auth.oauth_routes.verify_id_token",
new=AsyncMock(return_value=fake_userinfo),
),
):
# The callback may go on to do extra work (storing tokens, redirecting,
# rendering HTML); we don't care about the response body, only the
# storage-level side effect.
try:
await oauth_callback_nextcloud(request)
except Exception:
# Any error past the deletion point is fine for this test.
pass
assert await storage.get_oauth_session(state) is None, (
"oauth_callback_nextcloud must delete the oauth_sessions row "
"after reading code_verifier (PR #758 follow-up review)"
)
async def test_callback_no_session_row_does_not_crash(storage):
"""If the row is already gone (e.g. expired), the callback proceeds."""
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",
}
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)
with (
patch(
"nextcloud_mcp_server.auth.oauth_routes._get_cached_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 await storage.get_oauth_session(state) is None