Lines 67.64% 23 / 34
Functions and Methods 62.50% 5 / 8
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
GoogleApiTokenAuthenticator 67.64% 23 / 34 62.50% 5 / 8 26.79 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 supports 100.00% 1 / 1 100.00% 1 / 1 1
 authenticate 73.33% 11 / 15 0.00% 0 / 1 9.21
 onAuthenticationSuccess 100.00% 1 / 1 100.00% 1 / 1 1
 onAuthenticationFailure 0.00% 0 / 1 0.00% 0 / 1 2
 start 100.00% 1 / 1 100.00% 1 / 1 1
 findUser 33.33% 3 / 9 0.00% 0 / 1 5.67
 unauthorized 100.00% 5 / 5 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Security;
6
7use App\Entity\User;
8use App\Repository\UserRepository;
9use Doctrine\ORM\EntityManagerInterface;
10use Symfony\Component\HttpFoundation\JsonResponse;
11use Symfony\Component\HttpFoundation\Request;
12use Symfony\Component\HttpFoundation\Response;
13use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
14use Symfony\Component\Security\Core\Exception\AuthenticationException;
15use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
16use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
17use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
18use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
19use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
20use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
21
22/**
23 * Authenticates API requests carrying a Google ID token as a bearer token.
24 *
25 * Only users that already exist are accepted; nobody is registered here.
26 */
27class GoogleApiTokenAuthenticator extends AbstractAuthenticator implements AuthenticationEntryPointInterface
28{
29    private const string BEARER_PREFIX = 'Bearer ';
30
31    public function __construct(
32        private readonly GoogleIdTokenVerifierInterface $idTokenVerifier,
33        private readonly UserRepository $userRepository,
34        private readonly EntityManagerInterface $entityManager,
35    ) {
36    }
37
38    #[\Override]
39    public function supports(Request $request): bool
40    {
41        return \str_starts_with((string) $request->headers->get('Authorization'), self::BEARER_PREFIX);
42    }
43
44    #[\Override]
45    public function authenticate(Request $request): Passport
46    {
47        $idToken = \trim(\substr((string) $request->headers->get('Authorization'), \strlen(self::BEARER_PREFIX)));
48
49        if ('' === $idToken) {
50            throw new CustomUserMessageAuthenticationException('Missing ID token.');
51        }
52
53        $payload = $this->idTokenVerifier->verify($idToken);
54
55        if (null === $payload) {
56            throw new CustomUserMessageAuthenticationException('Invalid ID token.');
57        }
58
59        if (true !== ($payload['email_verified'] ?? false)) {
60            throw new CustomUserMessageAuthenticationException('Email address is not verified.');
61        }
62
63        $googleId = \is_string($payload['sub'] ?? null) ? $payload['sub'] : '';
64        $email = \is_string($payload['email'] ?? null) ? $payload['email'] : '';
65
66        if ('' === $googleId || '' === $email) {
67            throw new CustomUserMessageAuthenticationException('Invalid ID token.');
68        }
69
70        return new SelfValidatingPassport(
71            new UserBadge($googleId, fn (): User => $this->findUser($googleId, $email))
72        );
73    }
74
75    #[\Override]
76    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
77    {
78        return null;
79    }
80
81    #[\Override]
82    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
83    {
84        return $this->unauthorized(\strtr($exception->getMessageKey(), $exception->getMessageData()));
85    }
86
87    #[\Override]
88    public function start(Request $request, ?AuthenticationException $authException = null): JsonResponse
89    {
90        return $this->unauthorized('Authentication required.');
91    }
92
93    private function findUser(string $googleId, string $email): User
94    {
95        $user = $this->userRepository->findOneBy(['googleId' => $googleId]);
96
97        if (null !== $user) {
98            return $user;
99        }
100
101        // Link an existing account with the same email to this Google account
102        $user = $this->userRepository->findOneBy(['identifier' => $email]);
103
104        if (null === $user) {
105            throw new CustomUserMessageAuthenticationException('Unknown user.');
106        }
107
108        $user->setGoogleId($googleId);
109        $this->entityManager->flush();
110
111        return $user;
112    }
113
114    private function unauthorized(string $message): JsonResponse
115    {
116        return new JsonResponse(
117            ['error' => $message],
118            Response::HTTP_UNAUTHORIZED,
119            ['WWW-Authenticate' => 'Bearer'],
120        );
121    }
122}