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>
This commit is contained in:
Chris Coutinho
2026-05-02 19:46:36 +02:00
co-authored by Claude Opus 4.7
parent 2d340a5a6b
commit 2ef4bfc4af
5 changed files with 284 additions and 3 deletions
@@ -0,0 +1,172 @@
"""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
+83
View File
@@ -0,0 +1,83 @@
"""Unit tests for ``extract_user_id_from_token`` (PR #758 follow-up review).
The function used to silently fall back to ``"default_user"`` whenever the
verified access token had no ``sub`` claim. In a multi-tenant deployment
that would let a malformed IdP token bucket every request under a single
sentinel user, risking cross-tenant data exposure. The fix is to keep the
no-token fallback (BasicAuth mode legitimately calls this without an
OAuth identity) but raise ``McpError`` whenever an access token is
present and ``resource`` is empty.
"""
import time
from unittest.mock import MagicMock, patch
import pytest
from mcp.server.auth.provider import AccessToken
from mcp.shared.exceptions import McpError
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
pytestmark = pytest.mark.unit
def _token(resource: str | None = "alice") -> AccessToken:
return AccessToken(
token="t",
client_id="test-client",
scopes=["openid"],
expires_at=int(time.time() + 3600),
resource=resource,
)
async def test_returns_user_id_when_token_has_sub():
"""Happy path: verified access token with sub → returns the sub."""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=_token("alice"),
):
user_id = await extract_user_id_from_token(MagicMock())
assert user_id == "alice"
async def test_returns_default_user_when_no_access_token():
"""BasicAuth mode: get_access_token() returns None → sentinel.
BasicAuth deployments don't issue OAuth tokens; the sentinel lets
BasicAuth-aware callers branch on it. Removing this fallback would
break the BasicAuth path.
"""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=None,
):
user_id = await extract_user_id_from_token(MagicMock())
assert user_id == "default_user"
async def test_raises_when_token_present_but_resource_empty():
"""Token present but ``resource`` empty → fail closed with McpError.
Pins the PR #758 follow-up review fix: a malformed IdP token must
not silently funnel users into a shared ``"default_user"`` SQLite
bucket.
"""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=_token(""),
):
with pytest.raises(McpError, match="Cannot determine user identity"):
await extract_user_id_from_token(MagicMock())
async def test_raises_when_resource_is_none():
"""Same fail-closed behaviour when ``resource`` is None rather than ''."""
with patch(
"nextcloud_mcp_server.auth.token_utils.get_access_token",
return_value=_token(None),
):
with pytest.raises(McpError, match="Cannot determine user identity"):
await extract_user_id_from_token(MagicMock())