fix(auth): address PR #758 auto-review (id-token verify, nonce, CI key)
Blocking: - AS proxy callback now calls verify_id_token before caching the proxy code so a tampered IdP response can't smuggle identity claims. Important: - Browser OAuth flow generates and verifies an OIDC nonce; new alembic migration 006 adds the nonce column to oauth_sessions. - _origin_matches_self logs a warning when CSRF check is bypassed. - oauth_tools.py uses get_shared_storage instead of fresh handles. Nits: - New token_utils.get_oidc_discovery shares the 5-minute cache with verify_id_token; oauth_login (integrated) and _revoke_refresh_token_at_idp now use it instead of issuing fresh discovery fetches. - Drop typing.Optional from oauth_tools.py in favour of X | None. CI: - test.yml generates an ephemeral Fernet TOKEN_ENCRYPTION_KEY per run with openssl, removing the dependency on a missing repo secret. 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
2ef4bfc4af
commit
4c84d82984
@@ -9,6 +9,11 @@ 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.
|
||||
|
||||
Also pins the AS-proxy callback's ID-token verification rejection path
|
||||
introduced in PR #758 finding 1 (auto-review): a forged or unsigned
|
||||
id_token must surface as a 400 ``invalid_token`` JSONResponse and must
|
||||
not register a proxy code.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
@@ -19,8 +24,15 @@ 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.oauth_routes import (
|
||||
ASProxySession,
|
||||
_as_proxy_sessions,
|
||||
_oauth_callback_as_proxy,
|
||||
_proxy_codes,
|
||||
oauth_callback_nextcloud,
|
||||
)
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
from nextcloud_mcp_server.auth.token_utils import IdTokenVerificationError
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
@@ -170,3 +182,85 @@ async def test_callback_no_session_row_does_not_crash(storage):
|
||||
|
||||
# No crash, no row, no surprises.
|
||||
assert await storage.get_oauth_session(state) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AS proxy callback (PR #758 finding 1): ID-token verification rejection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_as_proxy_request(*, code: str, state: str):
|
||||
request = MagicMock()
|
||||
request.query_params = {"code": code, "state": state}
|
||||
request.app.state.oauth_context = {
|
||||
"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_as_proxy_rejects_invalid_id_token():
|
||||
"""Forged/unsigned id_token in the IdP token response → 400 invalid_token.
|
||||
|
||||
Pins PR #758 finding 1. Without verification a compromised IdP or
|
||||
tampered transport could plant arbitrary identity claims into the
|
||||
cached ProxyCodeEntry that downstream clients pick up.
|
||||
"""
|
||||
server_state = "as-proxy-state-rejected"
|
||||
_as_proxy_sessions[server_state] = ASProxySession(
|
||||
client_id="mcp-client",
|
||||
client_redirect_uri="http://127.0.0.1:9999/callback",
|
||||
client_state="client-state-xyz",
|
||||
code_challenge="challenge",
|
||||
code_challenge_method="S256",
|
||||
requested_scopes="openid",
|
||||
)
|
||||
_proxy_codes.clear()
|
||||
|
||||
request = _build_as_proxy_request(code="auth-code", state=server_state)
|
||||
|
||||
fake_discovery = {
|
||||
"token_endpoint": "https://idp.example.com/token",
|
||||
"issuer": "https://idp.example.com",
|
||||
}
|
||||
fake_token_response = MagicMock(status_code=200)
|
||||
fake_token_response.json.return_value = {
|
||||
"access_token": "ac-tok",
|
||||
"refresh_token": "rf-tok",
|
||||
"id_token": "forged.id.token",
|
||||
"token_type": "Bearer",
|
||||
}
|
||||
|
||||
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(side_effect=IdTokenVerificationError("bad signature")),
|
||||
),
|
||||
):
|
||||
response = await _oauth_callback_as_proxy(request, server_state)
|
||||
|
||||
assert response.status_code == 400
|
||||
body = bytes(response.body).decode()
|
||||
assert "invalid_token" in body
|
||||
# Critical: the proxy code store must not have grown — a rejected
|
||||
# callback must not be turned into a redeemable proxy code.
|
||||
assert _proxy_codes == {}
|
||||
# And the session has been popped (one-time use).
|
||||
assert server_state not in _as_proxy_sessions
|
||||
|
||||
@@ -20,6 +20,7 @@ import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
from starlette.requests import HTTPConnection
|
||||
|
||||
from nextcloud_mcp_server.auth import token_utils
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import (
|
||||
_revoke_refresh_token_at_idp,
|
||||
oauth_logout,
|
||||
@@ -30,6 +31,20 @@ from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_oidc_discovery_cache():
|
||||
"""Reset the shared discovery cache so tests don't see each other's fetches.
|
||||
|
||||
``_revoke_refresh_token_at_idp`` was changed (PR #758 nit 6) to use
|
||||
``token_utils.get_oidc_discovery`` which caches for 5 minutes — without
|
||||
this clear, the second test in the file would see the first test's
|
||||
discovery doc and skip the MockTransport call.
|
||||
"""
|
||||
token_utils._discovery_cache.clear()
|
||||
yield
|
||||
token_utils._discovery_cache.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# storage fixture (real SQLite backend; lighter than mocking every call)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -337,9 +352,17 @@ async def test_revoke_helper_posts_to_revocation_endpoint():
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
# Discovery now goes through token_utils.get_oidc_discovery (PR #758 nit
|
||||
# 6); revocation POST still uses browser_oauth_routes' httpx client.
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
),
|
||||
):
|
||||
await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
@@ -373,9 +396,15 @@ async def test_revoke_helper_skips_when_no_revocation_endpoint():
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
),
|
||||
):
|
||||
# Returns None and does not raise
|
||||
result = await _revoke_refresh_token_at_idp(
|
||||
@@ -403,9 +432,15 @@ async def test_revoke_helper_silent_on_idp_error():
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.browser_oauth_routes.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
),
|
||||
):
|
||||
result = await _revoke_refresh_token_at_idp(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user