Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
3 / 3
CRAP
100.00% covered (success)
100.00%
1 / 1
GoogleTokenInfoVerifier
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
3 / 3
18
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 verify
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
15
 claim
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace App\Identity\Infrastructure\Security;
6
7use App\Identity\Application\Port\GoogleIdentity;
8use App\Identity\Application\Port\GoogleIdTokenVerifier;
9use App\Identity\Domain\ValueObject\Email;
10use InvalidArgumentException;
11use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
12use Symfony\Contracts\HttpClient\HttpClientInterface;
13
14use function in_array;
15use function is_string;
16
17/**
18 * Verifies a Google ID token by asking Google about it.
19 *
20 * ponytail: the tokeninfo endpoint over a local JWKS verification. Google
21 * documents tokeninfo as a debugging aid ("requests may be throttled or
22 * otherwise subject to intermittent errors"), so the ceiling is availability and
23 * one network round trip per sign-in. Upgrade path: fetch and cache
24 * https://www.googleapis.com/oauth2/v3/certs and verify the signature locally —
25 * the claim assertions below stay exactly as they are.
26 */
27final class GoogleTokenInfoVerifier implements GoogleIdTokenVerifier
28{
29    private const TOKENINFO_URL = 'https://oauth2.googleapis.com/tokeninfo';
30
31    /** Google mints ID tokens under either spelling; both are legitimate. */
32    private const ISSUERS = ['accounts.google.com', 'https://accounts.google.com'];
33
34    public function __construct(
35        private readonly HttpClientInterface $http,
36        private readonly string $clientId,
37    ) {
38    }
39
40    public function verify(string $idToken): ?GoogleIdentity
41    {
42        if (trim($idToken) === '') {
43            return null;
44        }
45
46        try {
47            $response = $this->http->request('GET', self::TOKENINFO_URL, [
48                'query' => ['id_token' => $idToken],
49            ]);
50
51            if ($response->getStatusCode() !== 200) {
52                return null;
53            }
54
55            $claims = $response->toArray(false);
56        } catch (ExceptionInterface) {
57            return null;
58        }
59
60        // tokeninfo answers with every claim JSON-encoded as a string
61        // ("exp": "1433981953", "email_verified": "true"), unlike the ID token
62        // payload itself — hence the string comparisons below.
63        if ($this->claim($claims, 'aud') !== $this->clientId) {
64            return null;
65        }
66
67        if (!in_array($this->claim($claims, 'iss'), self::ISSUERS, true)) {
68            return null;
69        }
70
71        $expiry = $this->claim($claims, 'exp');
72        if ($expiry === null || !ctype_digit($expiry) || (int) $expiry <= time()) {
73            return null;
74        }
75
76        $verified = $claims['email_verified'] ?? null;
77        if ($verified !== 'true' && $verified !== true) {
78            return null;
79        }
80
81        $subject = $this->claim($claims, 'sub');
82        $email = $this->claim($claims, 'email');
83        if ($subject === null || $subject === '' || $email === null) {
84            return null;
85        }
86
87        try {
88            $address = new Email($email);
89        } catch (InvalidArgumentException) {
90            return null;
91        }
92
93        return new GoogleIdentity(
94            $subject,
95            $address,
96            $this->claim($claims, 'given_name'),
97            $this->claim($claims, 'family_name'),
98            $this->claim($claims, 'name'),
99        );
100    }
101
102    /**
103     * @param array<array-key, mixed> $claims
104     */
105    private function claim(array $claims, string $key): ?string
106    {
107        $value = $claims[$key] ?? null;
108
109        return is_string($value) ? $value : null;
110    }
111}