Files
mcp-nextcloud/tests/unit/test_id_token_verification.py
T
Chris CoutinhoandClaude Opus 4.7 15dbb26349 fix(auth): harden OAuth/session for hosted multi-tenant deployment (#626)
Pre-launch hardening for the hosted Astrolabe Cloud offering. Addresses
all five findings raised in #626 (Tim Kaufmann, code review of v0.65.0).
Re-verified against master before fixing.

Finding 3 (LLM-controllable user_id) — drop user_id from the public
signatures of provision_nextcloud_access, revoke_nextcloud_access,
check_provisioning_status, check_logged_in. Tool wrappers now always
derive identity from the verified AccessToken; user_id is no longer
accepted as MCP input. Adds parameterized CI-guard test that locks the
schema.

Finding 2 (predictable session cookie) — replace mcp_session=<user_id>
cookie with a cryptographically random session_id mapped server-side
(new browser_sessions table, alembic 005). Cookie value is opaque,
expires, revocable. SessionAuthBackend looks up user_id via the new
mapping and additionally requires a refresh token to fail closed.

Finding 4 (logout doesn't revoke refresh token) — oauth_logout now
calls the IdP revocation_endpoint (RFC 7009) when advertised, deletes
the stored refresh token regardless, and clears the browser_sessions
row. Cleanup is best-effort: logout always 302s.

Finding 1 (unverified ID token decodes) — verify_id_token helper does
JWKS signature + issuer + audience + exp + nonce checks per OIDC core
3.1.3.7. Used by both OAuth callback handlers (browser + MCP). Removes
the four "verify_signature: False" decodes that previously trusted IdP
claims unconditionally. Drops dead-code _validate_token_audience in
token_broker. Refactors token_utils + provisioning_decorator to read
user_id from the verified AccessToken instead of re-decoding the JWT.

Finding 5 (hardcoded Fernet keys in docker-compose.yml) — replace the
three inline TOKEN_ENCRYPTION_KEY values with required env var
interpolation; document in env.sample.

Test coverage: 4 new unit test modules (signature pinning, browser
sessions, ID-token verification, logout + revoke + session backend).
693 unit tests pass; ruff/format/ty clean.

Migration note: existing browser admin-UI sessions become invalid on
rollout (cookies are looked up against the new browser_sessions table,
which starts empty). Users re-login. MCP API access is unaffected.

Tracked on Astrolabe Cloud POC board card #37.

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

234 lines
6.5 KiB
Python

"""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 httpx
import jwt
import pytest
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from nextcloud_mcp_server.auth.token_utils import (
IdTokenVerificationError,
verify_id_token,
)
pytestmark = pytest.mark.unit
# 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"
)