Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
54 / 56
66.67% covered (warning)
66.67%
2 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
Rs256Verifier
96.43% covered (success)
96.43%
54 / 56
66.67% covered (warning)
66.67%
2 / 3
30
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verify
95.65% covered (success)
95.65%
44 / 46
0.00% covered (danger)
0.00%
0 / 1
24
 decodeJson
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Auth\Jwt;
6
7use JsonException;
8use Psr\Clock\ClockInterface;
9
10/**
11 * Verifies RS256 JSON Web Tokens and nothing else.
12 *
13 * The algorithm is fixed, the keys come from the resolver (never from the
14 * token), issuer and expiry are mandatory, not-before is honoured with a
15 * small leeway for clock skew, and the audience is checked when the host
16 * expects one. Every JWT weakness in the wild starts with trusting the
17 * header, so nothing in it is negotiable here.
18 */
19final class Rs256Verifier
20{
21    public function __construct(
22        private readonly KeyResolver $keys,
23        private readonly ClockInterface $clock,
24        private readonly ?string $expectedAudience = null,
25        private readonly int $leewaySeconds = 30,
26    ) {}
27
28    public function verify(string $token): Claims
29    {
30        $parts = explode('.', $token);
31        if (\count($parts) !== 3 || $parts[0] === '' || $parts[1] === '' || $parts[2] === '') {
32            throw new JwtException(JwtException::MALFORMED, 'A JWT has three non-empty parts.');
33        }
34        $header = self::decodeJson($parts[0]);
35        $payload = self::decodeJson($parts[1]);
36        $signature = Jwk::base64UrlDecode($parts[2]);
37        if ($signature === '') {
38            throw new JwtException(JwtException::MALFORMED, 'The signature is not valid base64url.');
39        }
40
41        if (($header['alg'] ?? null) !== 'RS256') {
42            throw new JwtException(JwtException::UNSUPPORTED_ALGORITHM, 'Only RS256 tokens are accepted.');
43        }
44        if (isset($header['typ']) && (!\is_string($header['typ']) || !\in_array(strtolower($header['typ']), ['jwt', 'at+jwt', 'application/at+jwt'], true))) {
45            throw new JwtException(JwtException::UNSUPPORTED_ALGORITHM, 'Unsupported token type.');
46        }
47        if (isset($header['crit'])) {
48            throw new JwtException(JwtException::UNSUPPORTED_ALGORITHM, 'Critical header extensions are not supported.');
49        }
50
51        $claims = new Claims($payload);
52        $issuer = $claims->issuer();
53        if ($issuer === null) {
54            throw new JwtException(JwtException::MISSING_CLAIM, 'The token has no issuer (iss).');
55        }
56        $keyId = \is_string($header['kid'] ?? null) ? $header['kid'] : null;
57        $candidates = $this->keys->publicKeys($issuer, $keyId);
58        if ($candidates === []) {
59            throw new JwtException(JwtException::UNKNOWN_ISSUER, 'The token issuer is not trusted.');
60        }
61
62        $signed = $parts[0] . '.' . $parts[1];
63        $verified = false;
64        foreach ($candidates as $pem) {
65            $key = openssl_pkey_get_public($pem);
66            if ($key === false) {
67                continue;
68            }
69            if (openssl_verify($signed, $signature, $key, OPENSSL_ALGO_SHA256) === 1) {
70                $verified = true;
71                break;
72            }
73        }
74        if (!$verified) {
75            throw new JwtException(JwtException::BAD_SIGNATURE, 'The token signature does not verify against the issuer\'s keys.');
76        }
77
78        // The clock's fraction counts as much as the claim's (R8-004).
79        $now = (float) $this->clock->now()->format('U.u');
80        // A present but malformed iat refuses the token too, although nothing compares it (R8-005).
81        $claims->issuedAt();
82        $exp = $claims->expiresAt();
83        if ($exp === null) {
84            throw new JwtException(JwtException::MISSING_CLAIM, 'The token has no expiry (exp).');
85        }
86        if ($exp <= $now - $this->leewaySeconds) {
87            throw new JwtException(JwtException::EXPIRED, 'The token has expired.');
88        }
89        $nbf = $claims->notBefore();
90        if ($nbf !== null && $nbf > $now + $this->leewaySeconds) {
91            throw new JwtException(JwtException::NOT_YET_VALID, 'The token is not valid yet.');
92        }
93        if ($this->expectedAudience !== null && !\in_array($this->expectedAudience, $claims->audience(), true)) {
94            throw new JwtException(JwtException::AUDIENCE_MISMATCH, 'The token is not meant for this server.');
95        }
96
97        return $claims;
98    }
99
100    /**
101     * @return array<string, mixed>
102     */
103    private static function decodeJson(string $segment): array
104    {
105        $json = Jwk::base64UrlDecode($segment);
106        if ($json === '') {
107            throw new JwtException(JwtException::MALFORMED, 'A token segment is not valid base64url.');
108        }
109        try {
110            $decoded = json_decode($json, true, 16, JSON_THROW_ON_ERROR);
111        } catch (JsonException) {
112            throw new JwtException(JwtException::MALFORMED, 'A token segment is not valid JSON.');
113        }
114        if (!\is_array($decoded) || array_is_list($decoded)) {
115            throw new JwtException(JwtException::MALFORMED, 'A token segment must be a JSON object.');
116        }
117
118        /** @var array<string, mixed> $decoded */
119        return $decoded;
120    }
121}