"""Unit tests for OIDC ID token verification (issue #626 finding 1). The OAuth callback handlers used to call `jwt.decode(id_token, options={"verify_signature": False})` and trust the result. They now go through `verify_id_token`, which checks signature against JWKS and validates issuer / audience / exp / nonce per OIDC core spec §3.1.3.7. """ import json import time from base64 import urlsafe_b64encode from unittest.mock import patch import anyio import httpx import jwt import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from nextcloud_mcp_server.auth import token_utils from nextcloud_mcp_server.auth.token_utils import ( IdTokenVerificationError, verify_id_token, ) pytestmark = pytest.mark.unit @pytest.fixture(autouse=True) def _clear_oidc_caches(): """Reset the discovery+JWKS caches so tests don't share fetched data.""" token_utils._discovery_cache.clear() token_utils._jwks_cache.clear() token_utils._fetch_locks.clear() yield token_utils._discovery_cache.clear() token_utils._jwks_cache.clear() token_utils._fetch_locks.clear() # Generated once per process — RSA keypair generation is slow. _KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) _PRIVATE_PEM = _KEY.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) _OTHER_KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) _OTHER_PRIVATE_PEM = _OTHER_KEY.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) ISSUER = "https://idp.example.com" DISCOVERY_URL = f"{ISSUER}/.well-known/openid-configuration" JWKS_URI = f"{ISSUER}/jwks" def _b64u_uint(n: int) -> str: raw = n.to_bytes((n.bit_length() + 7) // 8, "big") return urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") def _build_jwks() -> dict: pub = _KEY.public_key().public_numbers() return { "keys": [ { "kty": "RSA", "use": "sig", "kid": "test-key-1", "alg": "RS256", "n": _b64u_uint(pub.n), "e": _b64u_uint(pub.e), } ] } def _sign( claims: dict, *, kid: str = "test-key-1", key_pem: bytes = _PRIVATE_PEM ) -> str: return jwt.encode(claims, key_pem, algorithm="RS256", headers={"kid": kid}) def _idp_handler(request: httpx.Request) -> httpx.Response: if str(request.url) == DISCOVERY_URL: return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI}) if str(request.url) == JWKS_URI: return httpx.Response( 200, content=json.dumps(_build_jwks()).encode(), headers={"content-type": "application/json"}, ) return httpx.Response(404) @pytest.fixture def mock_idp(): """Patch nextcloud_httpx_client used inside token_utils.verify_id_token.""" transport = httpx.MockTransport(_idp_handler) def fake_client(**kwargs): kwargs["transport"] = transport return httpx.AsyncClient(**kwargs) with patch( "nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client", side_effect=fake_client, ): yield async def test_verify_id_token_accepts_valid_token(mock_idp): now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, } ) payload = await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) assert payload["sub"] == "alice" async def test_verify_id_token_rejects_wrong_audience(mock_idp): now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "other-client", "sub": "alice", "iat": now, "exp": now + 60, } ) with pytest.raises(IdTokenVerificationError): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) async def test_verify_id_token_rejects_expired_token(mock_idp): now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now - 120, "exp": now - 60, } ) with pytest.raises(IdTokenVerificationError): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) async def test_verify_id_token_rejects_wrong_issuer(mock_idp): now = int(time.time()) token = _sign( { "iss": "https://evil.example.com", "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, } ) with pytest.raises(IdTokenVerificationError): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) async def test_verify_id_token_rejects_wrong_signature(mock_idp): """Token signed with a different key but matching kid header must fail.""" now = int(time.time()) forged = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, }, key_pem=_OTHER_PRIVATE_PEM, ) with pytest.raises(IdTokenVerificationError): await verify_id_token( forged, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) async def test_verify_id_token_rejects_unknown_kid(mock_idp): now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, }, kid="not-in-jwks", ) with pytest.raises(IdTokenVerificationError, match="No JWKS key matches"): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) async def test_verify_id_token_nonce_mismatch_rejected(mock_idp): now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, "nonce": "actual", } ) with pytest.raises(IdTokenVerificationError, match="nonce"): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client", expected_nonce="expected", ) async def test_verify_id_token_missing_token_rejected(): with pytest.raises(IdTokenVerificationError, match="missing"): await verify_id_token( "", discovery_url=DISCOVERY_URL, expected_audience="test-client" ) async def test_verify_id_token_recovers_after_kid_rotation(): """Unknown kid → JWKS is refetched once and verification succeeds. Pins the fix for the PR #758 follow-up review: previously a kid-miss raised immediately, so every login failed for up to _OIDC_CACHE_TTL after the IdP rotated its signing key. """ rotated_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) rotated_pem = rotated_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.TraditionalOpenSSL, encryption_algorithm=serialization.NoEncryption(), ) def _build_rotated_jwks() -> dict: pub = rotated_key.public_key().public_numbers() return { "keys": [ { "kty": "RSA", "use": "sig", "kid": "rotated-key", "alg": "RS256", "n": _b64u_uint(pub.n), "e": _b64u_uint(pub.e), } ] } jwks_fetches = {"count": 0} def rotation_handler(request: httpx.Request) -> httpx.Response: url = str(request.url) if url == DISCOVERY_URL: return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI}) if url == JWKS_URI: jwks_fetches["count"] += 1 # First fetch: stale JWKS (without rotated kid). # Subsequent fetches: post-rotation JWKS (with rotated kid). jwks = ( _build_jwks() if jwks_fetches["count"] == 1 else _build_rotated_jwks() ) return httpx.Response( 200, content=json.dumps(jwks).encode(), headers={"content-type": "application/json"}, ) return httpx.Response(404) transport = httpx.MockTransport(rotation_handler) def fake_client(**kwargs): kwargs["transport"] = transport return httpx.AsyncClient(**kwargs) now = int(time.time()) token = jwt.encode( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, }, rotated_pem, algorithm="RS256", headers={"kid": "rotated-key"}, ) with patch( "nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client", side_effect=fake_client, ): # Prime the cache with the stale JWKS by triggering a verification # that misses on the rotated kid. payload = await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) assert payload["sub"] == "alice" assert jwks_fetches["count"] == 2, ( "JWKS should be refetched once on kid miss " f"(actual fetches: {jwks_fetches['count']})" ) async def test_verify_id_token_rotation_retry_still_misses(): """Refresh that still doesn't include the kid surfaces the original error.""" fetches = {"count": 0} def handler(request: httpx.Request) -> httpx.Response: url = str(request.url) if url == DISCOVERY_URL: return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI}) if url == JWKS_URI: fetches["count"] += 1 return httpx.Response( 200, content=json.dumps(_build_jwks()).encode(), headers={"content-type": "application/json"}, ) return httpx.Response(404) transport = httpx.MockTransport(handler) def fake_client(**kwargs): kwargs["transport"] = transport return httpx.AsyncClient(**kwargs) now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, }, kid="never-existed", ) with patch( "nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client", side_effect=fake_client, ): with pytest.raises(IdTokenVerificationError, match="No JWKS key matches"): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) assert fetches["count"] == 2, "JWKS should be refetched once before raising" async def test_verify_id_token_rotation_retry_network_error_wraps(): """A 500 on the kid-miss refresh fetch surfaces as IdTokenVerificationError. Pins the fail-closed branch in the new refresh block: a network error during JWKS refetch must not bubble out as a bare exception — it has to be wrapped in IdTokenVerificationError so the caller's existing error handling stays correct. """ fetches = {"jwks": 0} def handler(request: httpx.Request) -> httpx.Response: url = str(request.url) if url == DISCOVERY_URL: return httpx.Response(200, json={"issuer": ISSUER, "jwks_uri": JWKS_URI}) if url == JWKS_URI: fetches["jwks"] += 1 # First fetch: stale-but-valid JWKS. Second (refresh): 500. if fetches["jwks"] == 1: return httpx.Response( 200, content=json.dumps(_build_jwks()).encode(), headers={"content-type": "application/json"}, ) return httpx.Response(500, content=b"upstream broke") return httpx.Response(404) transport = httpx.MockTransport(handler) def fake_client(**kwargs): kwargs["transport"] = transport return httpx.AsyncClient(**kwargs) now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, }, kid="not-cached-yet", ) with patch( "nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client", side_effect=fake_client, ): with pytest.raises( IdTokenVerificationError, match="Failed to refresh JWKS after kid miss" ): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) assert fetches["jwks"] == 2 async def test_verify_id_token_caches_discovery_and_jwks(): """Discovery + JWKS must be cached: two verifications, one fetch each. Pins the fix for PR #758 finding 4 — every login previously made two extra HTTP round-trips to the IdP for the same metadata. """ fetches: dict[str, int] = {} def counting_handler(request: httpx.Request) -> httpx.Response: url = str(request.url) fetches[url] = fetches.get(url, 0) + 1 return _idp_handler(request) transport = httpx.MockTransport(counting_handler) def fake_client(**kwargs): kwargs["transport"] = transport return httpx.AsyncClient(**kwargs) now = int(time.time()) token = _sign( { "iss": ISSUER, "aud": "test-client", "sub": "alice", "iat": now, "exp": now + 60, } ) with patch( "nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client", side_effect=fake_client, ): await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) await verify_id_token( token, discovery_url=DISCOVERY_URL, expected_audience="test-client" ) assert fetches.get(DISCOVERY_URL) == 1, "discovery fetched more than once" assert fetches.get(JWKS_URI) == 1, "JWKS fetched more than once" async def test_get_cached_coalesces_concurrent_misses(): """Concurrent cache misses must collapse into a single HTTP fetch. PR #758 round-3 review: without the per-URL lock in ``_get_cached``, N simultaneous callers at cache expiry would each fire their own request to the IdP, potentially tripping rate limits. The async handler yields with ``anyio.sleep(0.01)`` so all 10 callers reach the cache-miss branch concurrently — without coalescing the count would be 10. """ fetch_count = {"n": 0} async def slow_handler(request: httpx.Request) -> httpx.Response: fetch_count["n"] += 1 # Yield so concurrent waiters all reach the lock acquisition # while the first holder is still mid-fetch. await anyio.sleep(0.01) return _idp_handler(request) transport = httpx.MockTransport(slow_handler) def fake_client(**kwargs): kwargs["transport"] = transport return httpx.AsyncClient(**kwargs) results: list[dict] = [] async def fetch_once(): results.append(await token_utils._get_cached(token_utils._jwks_cache, JWKS_URI)) with patch( "nextcloud_mcp_server.auth.token_utils.nextcloud_httpx_client", side_effect=fake_client, ): async with anyio.create_task_group() as tg: for _ in range(10): tg.start_soon(fetch_once) assert fetch_count["n"] == 1, ( f"expected exactly one fetch via lock coalescing, got {fetch_count['n']}" ) assert len(results) == 10 assert all(r == results[0] for r in results), ( "concurrent callers received divergent cached data" ) # Pin the round-4 cleanup invariant: _fetch_locks must drain after the # fetch completes so a probed deployment can't accumulate locks for # arbitrary URLs. assert len(token_utils._fetch_locks) == 0, ( "expected _fetch_locks to be empty after fetch, " f"found {list(token_utils._fetch_locks)}" )