fix(auth): address PR #758 round-2 review
- oauth_login_callback's integrated-mode token-exchange branch now reuses the shared discovery cache via get_oidc_discovery (round-2 finding 1). - AS proxy flow now generates an OIDC nonce in oauth_authorize, stores it on ASProxySession, forwards it to the IdP, and passes it as expected_nonce to verify_id_token in _oauth_callback_as_proxy (round-2 finding 2). - Consolidate the two parallel discovery caches: oauth_routes' local _discovery_cache and _get_cached_discovery are removed; all callers now go through token_utils.get_oidc_discovery, which acquires the follow_redirects=True knob it needs for Nextcloud installs without pretty URLs (round-2 finding 3). - Demote per-user INFO logs in oauth_tools.py (check_logged_in, get_provisioning_status) to DEBUG; the elicitation auth URL is no longer logged because it contains a sensitive state token (round-2 finding 4). Also pin nonce binding behaviour with a new unit test that asserts _oauth_callback_as_proxy forwards session.nonce to verify_id_token, and update test mocks to track the cache consolidation. 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
4c84d82984
commit
c33d52ea91
@@ -15,6 +15,7 @@ import httpx
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from nextcloud_mcp_server.auth import token_utils
|
||||
from nextcloud_mcp_server.auth.browser_oauth_routes import oauth_login_callback
|
||||
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
|
||||
|
||||
@@ -24,6 +25,14 @@ pytestmark = pytest.mark.unit
|
||||
XSS_PAYLOAD = "<script>alert(1)</script>"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_oidc_discovery_cache():
|
||||
"""Reset the shared discovery cache between tests."""
|
||||
token_utils._discovery_cache.clear()
|
||||
yield
|
||||
token_utils._discovery_cache.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def storage():
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
@@ -109,9 +118,18 @@ async def test_callback_escapes_idp_http_error_body(storage):
|
||||
},
|
||||
)
|
||||
|
||||
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
|
||||
# round-2 nit 3); token-exchange 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,
|
||||
),
|
||||
):
|
||||
response = await oauth_login_callback(request)
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ async def test_registration_not_supported_when_no_endpoint():
|
||||
}
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
|
||||
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
|
||||
new_callable=AsyncMock,
|
||||
return_value=discovery_doc,
|
||||
):
|
||||
|
||||
@@ -109,7 +109,7 @@ async def test_callback_deletes_oauth_session_after_reading_verifier(storage):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
|
||||
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
|
||||
new=AsyncMock(return_value=fake_discovery),
|
||||
),
|
||||
patch(
|
||||
@@ -165,7 +165,7 @@ async def test_callback_no_session_row_does_not_crash(storage):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
|
||||
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
|
||||
new=AsyncMock(return_value=fake_discovery),
|
||||
),
|
||||
patch(
|
||||
@@ -242,7 +242,7 @@ async def test_as_proxy_rejects_invalid_id_token():
|
||||
|
||||
with (
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes._get_cached_discovery",
|
||||
"nextcloud_mcp_server.auth.oauth_routes.get_oidc_discovery",
|
||||
new=AsyncMock(return_value=fake_discovery),
|
||||
),
|
||||
patch(
|
||||
@@ -264,3 +264,66 @@ async def test_as_proxy_rejects_invalid_id_token():
|
||||
assert _proxy_codes == {}
|
||||
# And the session has been popped (one-time use).
|
||||
assert server_state not in _as_proxy_sessions
|
||||
|
||||
|
||||
async def test_as_proxy_passes_session_nonce_to_verify_id_token():
|
||||
"""The session-bound nonce must be forwarded as ``expected_nonce``.
|
||||
|
||||
Pins PR #758 round-2 finding 2: ``oauth_authorize`` generates a nonce
|
||||
and stores it on the ``ASProxySession``; the callback must pass it to
|
||||
``verify_id_token`` so an ID token harvested from a parallel auth
|
||||
request can't be replayed inside the AS-proxy flow.
|
||||
"""
|
||||
server_state = "as-proxy-state-with-nonce"
|
||||
server_nonce = "nonce-bound-to-this-request"
|
||||
_as_proxy_sessions[server_state] = ASProxySession(
|
||||
client_id="mcp-client",
|
||||
client_redirect_uri="http://127.0.0.1:9999/callback",
|
||||
client_state="client-state",
|
||||
code_challenge="challenge",
|
||||
code_challenge_method="S256",
|
||||
requested_scopes="openid",
|
||||
nonce=server_nonce,
|
||||
)
|
||||
_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",
|
||||
"id_token": "id-tok",
|
||||
"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)
|
||||
|
||||
verify_mock = AsyncMock(return_value={"sub": "alice"})
|
||||
|
||||
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,
|
||||
),
|
||||
patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes.verify_id_token",
|
||||
new=verify_mock,
|
||||
),
|
||||
):
|
||||
await _oauth_callback_as_proxy(request, server_state)
|
||||
|
||||
verify_mock.assert_awaited_once()
|
||||
kwargs = verify_mock.await_args.kwargs
|
||||
assert kwargs.get("expected_nonce") == server_nonce, (
|
||||
"AS-proxy callback must forward session.nonce to verify_id_token"
|
||||
)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Unit tests for OIDC discovery fetch in oauth_routes."""
|
||||
"""Unit tests for the shared OIDC discovery fetch in token_utils."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nextcloud_mcp_server.auth import oauth_routes
|
||||
from nextcloud_mcp_server.auth.oauth_routes import _get_cached_discovery
|
||||
from nextcloud_mcp_server.auth import token_utils
|
||||
from nextcloud_mcp_server.auth.token_utils import get_oidc_discovery
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
@@ -14,9 +14,9 @@ pytestmark = pytest.mark.unit
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_discovery_cache():
|
||||
"""Reset the in-memory discovery cache between tests."""
|
||||
oauth_routes._discovery_cache.clear()
|
||||
token_utils._discovery_cache.clear()
|
||||
yield
|
||||
oauth_routes._discovery_cache.clear()
|
||||
token_utils._discovery_cache.clear()
|
||||
|
||||
|
||||
async def test_discovery_follows_redirect_to_index_php():
|
||||
@@ -26,7 +26,8 @@ async def test_discovery_follows_redirect_to_index_php():
|
||||
redirect ``/.well-known/openid-configuration`` to
|
||||
``/index.php/.well-known/openid-configuration``. Without follow_redirects
|
||||
the OAuth authorize handler raises HTTPStatusError and returns 500
|
||||
(see oauth_routes._get_cached_discovery).
|
||||
(PR #758 round-2 nit 3 consolidated the discovery cache; see
|
||||
``token_utils.get_oidc_discovery``).
|
||||
"""
|
||||
|
||||
pretty_url = "https://nx.example.com/.well-known/openid-configuration"
|
||||
@@ -51,10 +52,10 @@ async def test_discovery_follows_redirect_to_index_php():
|
||||
return httpx.AsyncClient(**kwargs)
|
||||
|
||||
with patch(
|
||||
"nextcloud_mcp_server.auth.oauth_routes.nextcloud_httpx_client",
|
||||
"nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client",
|
||||
side_effect=fake_client,
|
||||
) as factory:
|
||||
result = await _get_cached_discovery(pretty_url)
|
||||
result = await get_oidc_discovery(pretty_url)
|
||||
|
||||
assert result == discovery_doc
|
||||
factory.assert_called_once()
|
||||
|
||||
Reference in New Issue
Block a user