Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
87.21% covered (success)
87.21%
150 / 172
45.00% covered (danger)
45.00%
9 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
OidcIdTokenValidator
87.21% covered (success)
87.21%
150 / 172
45.00% covered (danger)
45.00%
9 / 20
115.29
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
 validate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 splitToken
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 decodeSegment
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
4.02
 validateClaims
97.50% covered (success)
97.50%
39 / 40
0.00% covered (danger)
0.00%
0 / 1
31
 isValidAudience
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
7.77
 validateSignature
85.71% covered (success)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
6.10
 fetchJwks
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 findKey
57.14% covered (warning)
57.14%
8 / 14
0.00% covered (danger)
0.00%
0 / 1
15.38
 createPemFromJwk
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 asn1Sequence
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asn1Integer
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 asn1ObjectIdentifier
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asn1Null
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asn1BitString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 asn1Length
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 base64UrlDecode
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
2.02
 audienceMatches
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
6.56
 getCurrentTimestamp
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 normalizeKey
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3/**
4 * OIDC ID token validator.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-04-18
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth\Oidc;
21
22use Closure;
23use JsonException;
24use RuntimeException;
25use SensitiveParameter;
26use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
27use Symfony\Contracts\HttpClient\HttpClientInterface;
28
29final readonly class OidcIdTokenValidator
30{
31    public function __construct(
32        private HttpClientInterface $httpClient,
33        private ?Closure $currentTimeProvider = null,
34    ) {
35    }
36
37    /**
38     * @return array<string, mixed>
39     * @throws ExceptionInterface
40     */
41    public function validate(
42        #[SensitiveParameter]
43        string $idToken,
44        OidcDiscoveryDocument $discoveryDocument,
45        string $expectedAudience,
46        string $expectedNonce,
47    ): array {
48        if (trim($idToken) === '') {
49            throw new RuntimeException('OIDC id_token is missing');
50        }
51
52        [$encodedHeader, $encodedPayload, $encodedSignature] = $this->splitToken($idToken);
53        $header = $this->decodeSegment($encodedHeader, 'header');
54        $claims = $this->decodeSegment($encodedPayload, 'payload');
55
56        $this->validateClaims($claims, $discoveryDocument->issuer, $expectedAudience, $expectedNonce);
57        $this->validateSignature(
58            $encodedHeader . '.' . $encodedPayload,
59            $encodedSignature,
60            $header,
61            $discoveryDocument->jwksUri,
62        );
63
64        return $claims;
65    }
66
67    /**
68     * @return array{0: string, 1: string, 2: string}
69     */
70    private function splitToken(#[SensitiveParameter] string $idToken): array
71    {
72        $parts = explode('.', $idToken);
73        if (count($parts) !== 3 || $parts[0] === '' || $parts[1] === '' || $parts[2] === '') {
74            throw new RuntimeException('OIDC id_token is malformed');
75        }
76
77        return [$parts[0], $parts[1], $parts[2]];
78    }
79
80    /**
81     * @return array<string, mixed>
82     */
83    private function decodeSegment(string $segment, string $context): array
84    {
85        $decoded = $this->base64UrlDecode($segment);
86
87        try {
88            $payload = json_decode($decoded, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
89        } catch (JsonException $exception) {
90            throw new RuntimeException(sprintf('OIDC id_token %s is not valid JSON', $context), previous: $exception);
91        }
92
93        if (!is_array($payload)) {
94            throw new RuntimeException(sprintf('OIDC id_token %s is not valid', $context));
95        }
96
97        $normalizedPayload = [];
98        foreach ($payload as $claimName => $claimValue) {
99            $normalizedPayload[(string) $claimName] = $claimValue;
100        }
101
102        return $normalizedPayload;
103    }
104
105    /**
106     * @param array<string, mixed> $claims
107     */
108    private function validateClaims(
109        array $claims,
110        string $expectedIssuer,
111        string $expectedAudience,
112        string $expectedNonce,
113    ): void {
114        if (!array_key_exists('iss', $claims) || !is_string($claims['iss']) || trim($claims['iss']) === '') {
115            throw new RuntimeException('OIDC id_token is missing the issuer claim');
116        }
117        if (trim($claims['iss']) !== $expectedIssuer) {
118            throw new RuntimeException('OIDC issuer mismatch in id_token');
119        }
120
121        if (!array_key_exists('sub', $claims) || !is_string($claims['sub']) || trim($claims['sub']) === '') {
122            throw new RuntimeException('OIDC id_token is missing the subject claim');
123        }
124
125        if (!array_key_exists('aud', $claims)) {
126            throw new RuntimeException('OIDC id_token is missing the audience claim');
127        }
128        $audience = $claims['aud'];
129        if (!$this->isValidAudience($audience)) {
130            throw new RuntimeException('OIDC id_token audience claim is invalid');
131        }
132        if (!$this->audienceMatches($audience, $expectedAudience)) {
133            throw new RuntimeException('OIDC audience mismatch in id_token');
134        }
135
136        $authorizedParty = trim((string) ($claims['azp'] ?? ''));
137        if ($authorizedParty !== '' && $authorizedParty !== $expectedAudience) {
138            throw new RuntimeException('OIDC authorized party mismatch in id_token');
139        }
140
141        if (is_array($audience) && count($audience) > 1 && $authorizedParty === '') {
142            throw new RuntimeException('OIDC authorized party missing in id_token');
143        }
144
145        if ($expectedNonce !== '') {
146            if (!array_key_exists('nonce', $claims) || !is_string($claims['nonce']) || $claims['nonce'] === '') {
147                throw new RuntimeException('OIDC id_token is missing the nonce claim');
148            }
149            if (!hash_equals($expectedNonce, $claims['nonce'])) {
150                throw new RuntimeException('OIDC nonce mismatch in id_token');
151            }
152        }
153
154        $now = $this->getCurrentTimestamp();
155
156        if (!array_key_exists('exp', $claims) || !is_numeric($claims['exp'])) {
157            throw new RuntimeException('OIDC id_token is missing the expiration claim');
158        }
159        if ($now >= (int) $claims['exp']) {
160            throw new RuntimeException('OIDC id_token has expired');
161        }
162
163        if (array_key_exists('nbf', $claims)) {
164            if (!is_numeric($claims['nbf'])) {
165                throw new RuntimeException('OIDC id_token has a non-numeric nbf claim');
166            }
167            if ($now < (int) $claims['nbf']) {
168                throw new RuntimeException('OIDC id_token is not valid yet');
169            }
170        }
171
172        if (!array_key_exists('iat', $claims) || !is_numeric($claims['iat'])) {
173            throw new RuntimeException('OIDC id_token is missing the issued-at claim');
174        }
175        $issuedAt = (int) $claims['iat'];
176        if ($issuedAt > ($now + 60)) {
177            throw new RuntimeException('OIDC id_token issued-at time is in the future');
178        }
179        if ($issuedAt < ($now - 86_400)) {
180            throw new RuntimeException('OIDC id_token issued-at time is too far in the past');
181        }
182    }
183
184    private function isValidAudience(mixed $audience): bool
185    {
186        if (is_string($audience)) {
187            return trim($audience) !== '';
188        }
189
190        if (!is_array($audience) || $audience === []) {
191            return false;
192        }
193
194        foreach ($audience as $entry) {
195            if (!is_string($entry) || trim($entry) === '') {
196                return false;
197            }
198        }
199
200        return true;
201    }
202
203    /**
204     * @param array<string, mixed> $header
205     * @throws ExceptionInterface
206     */
207    private function validateSignature(
208        string $signedPayload,
209        string $encodedSignature,
210        array $header,
211        string $jwksUri,
212    ): void {
213        $algorithm = (string) ($header['alg'] ?? '');
214        $keyId = trim((string) ($header['kid'] ?? ''));
215
216        $opensslAlgorithm = match ($algorithm) {
217            'RS256' => OPENSSL_ALGO_SHA256,
218            'RS384' => OPENSSL_ALGO_SHA384,
219            'RS512' => OPENSSL_ALGO_SHA512,
220            default => throw new RuntimeException('Unsupported OIDC id_token signing algorithm'),
221        };
222
223        $jwks = $this->fetchJwks($jwksUri);
224        $key = $this->findKey($jwks, $keyId);
225        $publicKey = $this->createPemFromJwk($key);
226        $signature = $this->base64UrlDecode($encodedSignature);
227
228        $verificationResult = openssl_verify($signedPayload, $signature, $publicKey, $opensslAlgorithm);
229        if ($verificationResult !== 1) {
230            throw new RuntimeException('OIDC id_token signature validation failed');
231        }
232    }
233
234    /**
235     * @return array<string, mixed>
236     * @throws ExceptionInterface
237     */
238    private function fetchJwks(string $jwksUri): array
239    {
240        $response = $this->httpClient->request('GET', $jwksUri);
241        $content = $response->getContent(false);
242
243        if ($response->getStatusCode() >= 400) {
244            throw new RuntimeException(sprintf('OIDC JWKS request failed with status %d', $response->getStatusCode()));
245        }
246
247        try {
248            $payload = json_decode($content, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
249        } catch (JsonException $exception) {
250            throw new RuntimeException('OIDC JWKS response is not valid JSON', previous: $exception);
251        }
252
253        if (!is_array($payload)) {
254            throw new RuntimeException('OIDC JWKS response is not valid');
255        }
256
257        $normalizedPayload = [];
258        foreach ($payload as $jwksKey => $jwksValue) {
259            $normalizedPayload[(string) $jwksKey] = $jwksValue;
260        }
261
262        return $normalizedPayload;
263    }
264
265    /**
266     * @param array<string, mixed> $jwks
267     * @return array<string, mixed>
268     */
269    private function findKey(array $jwks, string $keyId): array
270    {
271        $keys = $jwks['keys'] ?? null;
272        if (!is_array($keys) || $keys === []) {
273            throw new RuntimeException('OIDC JWKS response does not contain any keys');
274        }
275
276        foreach ($keys as $key) {
277            if (!is_array($key)) {
278                continue;
279            }
280
281            $candidateKeyId = trim((string) ($key['kid'] ?? ''));
282            if ($keyId !== '' && $candidateKeyId !== $keyId) {
283                continue;
284            }
285
286            return self::normalizeKey($key);
287        }
288
289        $firstKey = $keys[0] ?? null;
290        if ($keyId === '' && is_array($firstKey)) {
291            return self::normalizeKey($firstKey);
292        }
293
294        throw new RuntimeException('OIDC JWKS key could not be resolved');
295    }
296
297    /**
298     * @param array<string, mixed> $key
299     */
300    private function createPemFromJwk(array $key): string
301    {
302        $keyType = (string) ($key['kty'] ?? '');
303        $modulus = (string) ($key['n'] ?? '');
304        $exponent = (string) ($key['e'] ?? '');
305
306        if ($keyType !== 'RSA' || $modulus === '' || $exponent === '') {
307            throw new RuntimeException('OIDC JWK is not a supported RSA key');
308        }
309
310        $rsaPublicKey = $this->asn1Sequence(
311            $this->asn1Integer($this->base64UrlDecode($modulus))
312                . $this->asn1Integer($this->base64UrlDecode($exponent)),
313        );
314
315        $subjectPublicKeyInfo = $this->asn1Sequence(
316            $this->asn1Sequence($this->asn1ObjectIdentifier("\x2a\x86\x48\x86\xf7\x0d\x01\x01\x01") . $this->asn1Null())
317                . $this->asn1BitString($rsaPublicKey),
318        );
319
320        return (
321            "-----BEGIN PUBLIC KEY-----\n"
322            . chunk_split(base64_encode($subjectPublicKeyInfo), length: 64, separator: "\n")
323            . "-----END PUBLIC KEY-----\n"
324        );
325    }
326
327    private function asn1Sequence(string $value): string
328    {
329        return "\x30" . $this->asn1Length(strlen($value)) . $value;
330    }
331
332    private function asn1Integer(string $value): string
333    {
334        if ($value === '') {
335            $value = "\x00";
336        }
337
338        if (ord($value[0]) > 0x7f) {
339            $value = "\x00" . $value;
340        }
341
342        return "\x02" . $this->asn1Length(strlen($value)) . $value;
343    }
344
345    private function asn1ObjectIdentifier(string $value): string
346    {
347        return "\x06" . $this->asn1Length(strlen($value)) . $value;
348    }
349
350    private function asn1Null(): string
351    {
352        return "\x05\x00";
353    }
354
355    private function asn1BitString(string $value): string
356    {
357        return "\x03" . $this->asn1Length(strlen($value) + 1) . "\x00" . $value;
358    }
359
360    private function asn1Length(int $length): string
361    {
362        if ($length < 128) {
363            return chr($length);
364        }
365
366        $encoded = '';
367        while ($length > 0) {
368            $encoded = chr($length & 0xff) . $encoded;
369            $length >>= 8;
370        }
371
372        return chr(0x80 | strlen($encoded)) . $encoded;
373    }
374
375    private function base64UrlDecode(string $value): string
376    {
377        $padding = (4 - (strlen($value) % 4)) % 4;
378        $value .= str_repeat('=', $padding);
379        $decoded = base64_decode(strtr(string: $value, from: '-_', to: '+/'), strict: true);
380
381        if ($decoded === false) {
382            throw new RuntimeException('OIDC token could not be base64url decoded');
383        }
384
385        return $decoded;
386    }
387
388    private function audienceMatches(mixed $audience, string $expectedAudience): bool
389    {
390        if (is_string($audience)) {
391            return $audience === $expectedAudience;
392        }
393
394        if (!is_array($audience)) {
395            return false;
396        }
397
398        foreach ($audience as $entry) {
399            if (is_string($entry) && $entry === $expectedAudience) {
400                return true;
401            }
402        }
403
404        return false;
405    }
406
407    private function getCurrentTimestamp(): int
408    {
409        if ($this->currentTimeProvider instanceof Closure) {
410            return (int) ($this->currentTimeProvider)();
411        }
412
413        return time();
414    }
415
416    /**
417     * @param array<array-key, mixed> $key
418     * @return array<string, mixed>
419     */
420    private static function normalizeKey(array $key): array
421    {
422        $normalizedKey = [];
423        foreach ($key as $keyName => $keyValue) {
424            $normalizedKey[(string) $keyName] = $keyValue;
425        }
426
427        return $normalizedKey;
428    }
429}