Lines 89.65% 26 / 29
Functions and Methods 71.42% 5 / 7
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
GoogleApiTokenAuthenticator 89.65% 26 / 29 71.42% 5 / 7 9.09 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 85.71% 12 / 14 0.00% 0 / 1 3.03
 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
 unauthorized 100.00% 10 / 10 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace App\Security;
6
7use App\Entity\User;
8use App\Service\AvatarUpdater;
9use Symfony\Component\HttpFoundation\JsonResponse;
10use Symfony\Component\HttpFoundation\Request;
11use Symfony\Component\HttpFoundation\Response;
12use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
13use Symfony\Component\Security\Core\Exception\AuthenticationException;
14use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
15use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
16use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
17use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
18use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
19use Symfony\Component\Security\Http\EntryPoint\AuthenticationEntryPointInterface;
20
21/**
22 * Authenticates API requests carrying a Google ID token as a bearer token.
23 *
24 * Unknown Google accounts are registered, like a Google login on the web: with ROLE_USER, so
25 * they see no agent stats until an admin makes them agents.
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 GoogleUserResolver $userResolver,
34        private readonly AvatarUpdater $avatarUpdater,
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        [$googleId, $email] = GoogleUserResolver::identity($payload);
60
61        return new SelfValidatingPassport(
62            new UserBadge($googleId, function () use ($googleId, $email, $payload): User {
63                $user = $this->userResolver->resolve($googleId, $email);
64                // The app signs in with Google only: its users get their picture here
65                $this->avatarUpdater->update($user, $payload['picture'] ?? null);
66
67                return $user;
68            })
69        );
70    }
71
72    #[\Override]
73    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
74    {
75        return null;
76    }
77
78    #[\Override]
79    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): JsonResponse
80    {
81        return $this->unauthorized(\strtr($exception->getMessageKey(), $exception->getMessageData()));
82    }
83
84    #[\Override]
85    public function start(Request $request, ?AuthenticationException $authException = null): JsonResponse
86    {
87        return $this->unauthorized('Authentication required.');
88    }
89
90    /**
91     * An RFC 7807 problem, like API Platform's own errors.
92     */
93    private function unauthorized(string $message): JsonResponse
94    {
95        return new JsonResponse(
96            [
97                'type' => '/errors/401',
98                'title' => 'An error occurred',
99                'status' => Response::HTTP_UNAUTHORIZED,
100                'detail' => $message,
101            ],
102            Response::HTTP_UNAUTHORIZED,
103            ['WWW-Authenticate' => 'Bearer', 'Content-Type' => 'application/problem+json'],
104        );
105    }
106}