Spaces:
Sleeping
Sleeping
File size: 5,318 Bytes
e86dfae | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | """
Unit tests for JWT verification.
Covers the guarantees that were missing before the PyJWT migration:
algorithm pinning, issuer validation, and authorized-party validation.
"""
import json
from datetime import datetime, timedelta, timezone
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from app.core.exceptions import AuthenticationError
from app.core.security import verify_jwt_token
pytestmark = pytest.mark.unit
ISSUER = "https://test-app.clerk.accounts.dev"
JWKS_URL = f"{ISSUER}/.well-known/jwks.json"
KID = "test-key-1"
@pytest.fixture
def rsa_key():
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@pytest.fixture
def configured(monkeypatch, rsa_key):
"""Point the verifier at an in-memory JWKS built from our test key."""
from app.config import settings
from app.core import security
monkeypatch.setattr(settings, "CLERK_JWKS_URL", JWKS_URL)
monkeypatch.setattr(settings, "CLERK_ISSUER", "")
monkeypatch.setattr(settings, "CLERK_AUTHORIZED_PARTIES", ["https://app.test"])
jwk = json.loads(jwt.algorithms.RSAAlgorithm.to_jwk(rsa_key.public_key()))
jwk["kid"] = KID
jwk["alg"] = "RS256"
client = security.JWKSClient(JWKS_URL)
client._keys = {KID: jwk}
client._last_fetch = datetime.now(timezone.utc)
monkeypatch.setattr(security, "_jwks_client", client)
return client
def make_token(rsa_key, *, alg="RS256", key=None, kid=KID, **claims):
now = datetime.now(timezone.utc)
payload = {
"sub": "user_abc123",
"iss": ISSUER,
"azp": "https://app.test",
"iat": now,
"exp": now + timedelta(minutes=5),
}
payload.update(claims)
return jwt.encode(
payload,
key if key is not None else rsa_key,
algorithm=alg,
headers={"kid": kid},
)
# ── Happy path ─────────────────────────────────────────────────────────────────
async def test_valid_token_is_accepted(configured, rsa_key):
payload = await verify_jwt_token(make_token(rsa_key))
assert payload["sub"] == "user_abc123"
# ── Signature / algorithm ──────────────────────────────────────────────────────
async def test_rejects_token_signed_by_another_key(configured, rsa_key):
attacker = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with pytest.raises(AuthenticationError):
await verify_jwt_token(make_token(rsa_key, key=attacker))
async def test_rejects_alg_none(configured, rsa_key):
"""The unsigned-token attack: alg=none must never validate."""
token = jwt.encode(
{"sub": "user_abc123", "iss": ISSUER, "azp": "https://app.test"},
key="",
algorithm="none",
headers={"kid": KID},
)
with pytest.raises(AuthenticationError):
await verify_jwt_token(token)
async def test_rejects_unknown_kid(configured, rsa_key):
with pytest.raises(AuthenticationError):
await verify_jwt_token(make_token(rsa_key, kid="rotated-away"))
# ── Claims ─────────────────────────────────────────────────────────────────────
async def test_rejects_expired_token(configured, rsa_key):
past = datetime.now(timezone.utc) - timedelta(hours=1)
with pytest.raises(AuthenticationError, match="expired"):
await verify_jwt_token(make_token(rsa_key, exp=past))
async def test_rejects_wrong_issuer(configured, rsa_key):
with pytest.raises(AuthenticationError):
await verify_jwt_token(make_token(rsa_key, iss="https://evil.clerk.dev"))
async def test_rejects_foreign_authorized_party(configured, rsa_key):
"""A valid Clerk token minted for a different site must not work here."""
with pytest.raises(AuthenticationError):
await verify_jwt_token(make_token(rsa_key, azp="https://other-app.test"))
async def test_rejects_missing_azp_when_configured(configured, rsa_key):
token = make_token(rsa_key)
decoded = jwt.decode(token, options={"verify_signature": False})
decoded.pop("azp")
stripped = jwt.encode(decoded, rsa_key, algorithm="RS256", headers={"kid": KID})
with pytest.raises(AuthenticationError):
await verify_jwt_token(stripped)
async def test_rejects_missing_sub(configured, rsa_key):
token = make_token(rsa_key)
decoded = jwt.decode(token, options={"verify_signature": False})
decoded.pop("sub")
stripped = jwt.encode(decoded, rsa_key, algorithm="RS256", headers={"kid": KID})
with pytest.raises(AuthenticationError):
await verify_jwt_token(stripped)
async def test_azp_check_skipped_when_unconfigured(configured, rsa_key, monkeypatch):
"""Backwards compatibility: empty allowlist warns but does not break."""
from app.config import settings
monkeypatch.setattr(settings, "CLERK_AUTHORIZED_PARTIES", [])
payload = await verify_jwt_token(make_token(rsa_key, azp="https://anything.test"))
assert payload["sub"] == "user_abc123"
|