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"