Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
96.43% |
54 / 56 |
|
66.67% |
2 / 3 |
CRAP | |
0.00% |
0 / 1 |
| Rs256Verifier | |
96.43% |
54 / 56 |
|
66.67% |
2 / 3 |
30 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| verify | |
95.65% |
44 / 46 |
|
0.00% |
0 / 1 |
24 | |||
| decodeJson | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace LambdaTwelve\OneRecord\Auth\Jwt; |
| 6 | |
| 7 | use JsonException; |
| 8 | use 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 | */ |
| 19 | final 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 | } |