Signing In With a Provider

Letting an external provider verify the identity, then mapping the account it returns onto a local user row you control.

Syntax$clientRegistry->getClient('google')->redirect(['openid', 'email'], [])

Signing in with a provider means letting someone else check the password. Your application never sees it.

The flow

You redirect the visitor to the provider with your client id, the scopes you want and a redirect URI. They authenticate there. The provider sends them back to that URI carrying a short-lived authorization code. Your server exchanges the code plus your client secret for an access token, then calls the provider's user endpoint to find out who signed in. That is OAuth 2.0's authorization code flow.

OpenID Connect is a layer on top of it meant for identity specifically: the exchange also returns an ID token, a signed JWT whose sub claim is the provider's permanent identifier for that account.

The client bundle

A client bundle such as knpuniversity/oauth2-client-bundle, which wraps league/oauth2-client, does the mechanical parts. You configure one client per provider and name a redirect_route. It builds the redirect, generates and verifies the state value that protects the callback against forgery, and performs the code exchange.

Mapping to a local user

Keep your own user row. Store the provider name and the provider's subject identifier on it, and look users up by that pair. An email address is the wrong key: people change them, and some providers return one nobody verified.

The redirect URI you send must match the one registered with the provider character for character. A missing port, http instead of https, or a trailing slash produces redirect_uri_mismatch and nothing else.

Example

Example · php
<?php
// src/Security/GoogleAuthenticator.php

declare(strict_types=1);

namespace App\Security;

use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use League\OAuth2\Client\Provider\GoogleUser;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

final class GoogleAuthenticator extends OAuth2Authenticator
{
    public function __construct(
        private readonly ClientRegistry $clientRegistry,
        private readonly GoogleAccountLinker $linker,
        private readonly UrlGeneratorInterface $urlGenerator,
    ) {
    }

    public function supports(Request $request): ?bool
    {
        return 'connect_google_check' === $request->attributes->get('_route');
    }

    public function authenticate(Request $request): Passport
    {
        $client = $this->clientRegistry->getClient('google');

        // Exchanges the ?code= in the callback for an access token. Provided by
        // OAuth2Authenticator, which turns provider errors into
        // AuthenticationExceptions for you.
        $accessToken = $this->fetchAccessToken($client);

        return new SelfValidatingPassport(
            new UserBadge($accessToken->getToken(), function () use ($client, $accessToken): UserInterface {
                /** @var GoogleUser $googleUser */
                $googleUser = $client->fetchUserFromToken($accessToken);

                return $this->linker->findOrCreate($googleUser);
            })
        );
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
    {
        return new RedirectResponse($this->urlGenerator->generate('app_dashboard'));
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
    {
        return new RedirectResponse($this->urlGenerator->generate('app_login', ['oauth_failed' => 1]));
    }
}

// GET /connect/google/check with a valid ?code= signs the user in and returns
// 302 to /dashboard. A denied, reused or expired code returns 302 to
// /login?oauth_failed=1 with nobody signed in.

When to use it

  • Offer a Sign in with Google button beside the existing password form.
  • Let staff sign in through the company identity provider with OpenID Connect.
  • Link a provider to an account someone already created with a password.
  • Keep local and production callbacks both working without editing configuration between deploys.

More examples

The two routes the flow needs

Two GET routes and almost no code. The start route returns the redirect the bundle builds, carrying the scopes you ask for; openid and email are the two most OpenID Connect flows need. The check route exists so the URL resolves and so redirect_route has a name to point at, but the authenticator claims the request before the controller body runs. Both paths must be reachable without authentication, since the visitor is by definition not signed in yet.

Example · php
<?php
// src/Controller/GoogleConnectController.php

declare(strict_types=1);

namespace App\Controller;

use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;

final class GoogleConnectController extends AbstractController
{
    // Step one: send the visitor to Google.
    #[Route('/connect/google', name: 'connect_google_start', methods: ['GET'])]
    public function start(ClientRegistry $clientRegistry): Response
    {
        return $clientRegistry->getClient('google')->redirect(['openid', 'email'], []);
    }

    // Step two: Google sends them back here. This URL must be the one
    // registered with Google, and it must be what redirect_route generates.
    #[Route('/connect/google/check', name: 'connect_google_check', methods: ['GET'])]
    public function check(): Response
    {
        // Not reached in practice: GoogleAuthenticator::supports() matches this
        // route, and its onAuthenticationSuccess() returns the response.
        throw new \LogicException('Handled by GoogleAuthenticator.');
    }
}

Client configuration and credentials

type: google selects the provider package, which is installed separately as league/oauth2-google. The two credentials come from environment variables so the same configuration works everywhere and the secret never reaches version control; in production put it in the secrets vault rather than a file. redirect_route is the piece people get wrong. The bundle generates an absolute URL from that route and sends it as redirect_uri, so if the registered value is https://example.com/connect/google/check and the generated one is http://example.com/connect/google/check, the provider refuses before any of your code runs.

Example · yaml
# config/packages/knpu_oauth2_client.yaml
knpu_oauth2_client:
    clients:
        google:
            type: google
            client_id: '%env(OAUTH_GOOGLE_ID)%'
            client_secret: '%env(OAUTH_GOOGLE_SECRET)%'
            # The bundle generates the redirect URI from this route, so the
            # value registered with Google must be the URL this route produces.
            redirect_route: connect_google_check
            redirect_params: {}

# .env  (committed, placeholder values only)
# OAUTH_GOOGLE_ID=
# OAUTH_GOOGLE_SECRET=
#
# .env.local  (never committed), or the secrets vault in production
# OAUTH_GOOGLE_ID=1234567890-abc.apps.googleusercontent.com
# OAUTH_GOOGLE_SECRET=...

Mapping the external account to a local user

Keeping this out of the authenticator makes the rule testable on its own and gives you one place to change the policy. The important decision is the lookup key: googleId, the provider's sub claim, which stays the same even if the person renames their account. Looking up by email instead means someone who changes their Google address silently acquires a second account, and an unverified email from a provider becomes an account-takeover path. Put a unique index on googleId so two simultaneous callbacks fail loudly rather than creating duplicates.

Example · php
<?php
// src/Security/GoogleAccountLinker.php

declare(strict_types=1);

namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Doctrine\ORM\EntityManagerInterface;
use League\OAuth2\Client\Provider\GoogleUser;

final class GoogleAccountLinker
{
    public function __construct(
        private readonly UserRepository $users,
        private readonly EntityManagerInterface $entityManager,
    ) {
    }

    public function findOrCreate(GoogleUser $googleUser): User
    {
        // The subject id is permanent. The email address is not, so it is a
        // display value here and never the lookup key.
        $existing = $this->users->findOneBy(['googleId' => $googleUser->getId()]);

        if (null !== $existing) {
            return $existing;
        }

        $user = new User();
        $user->setGoogleId($googleUser->getId());
        $user->setEmail((string) $googleUser->getEmail());
        $user->setPassword(''); // no local password; this account signs in via Google

        $this->entityManager->persist($user); // schedules the insert
        $this->entityManager->flush();        // writes it

        return $user;
    }
}

// Output: the existing User row whose googleId matches, or a newly inserted
// one. Calling it twice with the same GoogleUser returns the same row.

Discussion

  • Be the first to comment on this lesson.