OpenID Connect (OIDC) is the protocol behind most "Log in with ..." buttons. It lets your application delegate user authentication to an identity provider such as Keycloak, Authentik, Auth0, Okta or Microsoft Entra ID.

Symfony 6.3 added OIDC token handlers to the access token authenticator, but they only validate tokens that clients already have, which is perfect for APIs. To log users in from a browser, you needed third-party bundles. Symfony 8.2 adds a new oidc_login authenticator that implements the full OIDC Authorization Code Flow natively.

Logging In with OpenID Connect

Mathieu Santostefano
Contributed by Mathieu Santostefano in #64954 and #65814

First, install the HttpClient component and the library used to validate the ID tokens:

1
$ composer require symfony/http-client web-token/jwt-library

Then, enable oidc_login in your firewall:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# config/packages/security.yaml
security:
    providers:
        oidc_users:
            oidc: ~

    firewalls:
        main:
            provider: oidc_users
            oidc_login:
                provider_uri: '%env(OIDC_PROVIDER_URI)%'
                client_id: '%env(OIDC_CLIENT_ID)%'
                client_authentication:
                    client_secret_basic: '%env(OIDC_CLIENT_SECRET)%'
                scope: ['openid', 'profile', 'email']

You don't need to configure the provider's endpoints: Symfony reads them from the .well-known/openid-configuration document published by the provider. When anonymous users open a protected page, they are redirected to the provider; after logging in there, they are sent back to /oidc/callback (the default value of the check_path option) and the authenticator opens the session.

The routes of the login flow are imported automatically by the Symfony Flex recipe. If you prefer to show a "Log in with ..." button (e.g. in a login page with several login options), link to the route that starts the flow:

1
<a href="{{ path('_oidc_login_start_main') }}">Log in with Acme</a>

Secure by Default

Mathieu Santostefano
Contributed by Mathieu Santostefano in #64954 , #65798 and #65814

The authenticator handles the following security checks automatically for you:

  • the state and nonce parameters are checked to prevent login CSRF and to bind the ID token to the original request;
  • PKCE is applied by default, so intercepted authorization codes are useless;
  • the ID token signature is verified against the provider's published keys (JWKS). They are cached, and a key rotation at the provider requires no action on your side;
  • the iss, aud, exp and iat claims of the ID token are validated, as well as the iss parameter of the response to prevent mix-up attacks;
  • all the provider endpoints must use HTTPS (except loopback hosts during local development) and HTTP redirects are never followed.

Loading Users

Mathieu Santostefano
Contributed by Mathieu Santostefano in #64954 and #65817

The built-in oidc user provider shown above creates users on the fly from the claims returned by the provider. To store users in your database or to map provider groups to roles, create your own user provider. Its loadUserByIdentifier() method receives all the user claims as its second argument:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// src/Security/OidcUserProvider.php
namespace App\Security;

use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\User\AttributesBasedUserProviderInterface;
use Symfony\Component\Security\Core\User\UserInterface;

class OidcUserProvider implements AttributesBasedUserProviderInterface
{
    public function __construct(
        private UserRepository $userRepository,
    ) {
    }

    public function loadUserByIdentifier(string $identifier, array $attributes = []): UserInterface
    {
        // $identifier is the "sub" claim by default; $attributes contains all claims
        $user = $this->userRepository->findOneBy(['oidcSubject' => $identifier]) ?? new User($identifier);
        $user->setEmail($attributes['email'] ?? null);
        $user->setRoles(\in_array('admins', $attributes['groups'] ?? [], true) ? ['ROLE_ADMIN'] : []);

        // ... persist the user

        return $user;
    }

    // ...
}

You can also identify users by another claim (user_identifier_claim: email) and read the claims from the ID token instead of calling the UserInfo endpoint (user_data_source: id_token).

Customizing the Authorization Request

Mathieu Santostefano Yonel Ceruto
Contributed by Mathieu Santostefano and Yonel Ceruto in #65814 and #66047

Use the authorization_params option to add any static parameter supported by your provider (e.g. prompt: consent). When a parameter depends on the current request, listen to the new OidcAuthorizationRequestEvent, which is dispatched right before redirecting users to the provider:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// src/Security/OidcAuthorizationRequestListener.php
namespace App\Security;

use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\Security\Http\Event\OidcAuthorizationRequestEvent;

#[AsEventListener]
final class OidcAuthorizationRequestListener
{
    public function __invoke(OidcAuthorizationRequestEvent $event): void
    {
        $request = $event->getRequest();

        // show the provider login page in the same language as your application
        $event->setParam('ui_locales', $request->getLocale());

        // pre-fill the email field in the provider login page
        if ($email = $request->getSession()->get('login_email')) {
            $event->setParam('login_hint', $email);
        }
    }
}

Parameters that the authenticator relies on (state, nonce, redirect_uri, etc.) can't be changed through configuration or the event.

Client Authentication with Signed JWTs

Mathieu Santostefano Florent Morselli
Contributed by Mathieu Santostefano and Florent Morselli in #65799 , #65895 and #65916

The client_authentication option defines how your application authenticates itself when exchanging the authorization code for tokens. You can send a client secret (client_secret_basic or client_secret_post), or use none for public clients without a secret.

With private_key_jwt, your application signs a JWT assertion using its own private key:

1
2
3
4
5
6
7
8
9
10
# config/packages/security.yaml
security:
    firewalls:
        main:
            oidc_login:
                # ...
                client_authentication:
                    private_key_jwt:
                        key: '%env(OIDC_CLIENT_SIGNING_KEY)%'
                        algorithm: 'ES256'

The client_secret_jwt method is supported too. For any other scheme (e.g. mutual TLS), pass the ID of a service implementing the new ClientAuthenticationInterface.

Renewing Access Tokens

Florent Morselli
Contributed by Florent Morselli in #65875

The tokens returned by the provider are stored in the security token (e.g. $token->getAttribute('oidc_access_token')), so you can call the provider APIs on behalf of the user. Access tokens usually expire after a few minutes, so Symfony can now renew them using the refresh token:

1
2
3
4
5
6
7
8
9
# config/packages/security.yaml
security:
    firewalls:
        main:
            oidc_login:
                # ...
                scope: ['openid', 'profile', 'offline_access']
                refresh_access_token:
                    enabled: true

When enabled, the access token is renewed automatically when a request arrives shortly before it expires. If you prefer to do it on demand, inject the OidcTokenRefresher service and call its refreshIfNeeded() method.

Logging Out of the Provider

Mathieu Santostefano
Contributed by Mathieu Santostefano in #65817

By default, logging out only closes the session in your application. Users stay logged in at the provider, so the next visit to a protected page logs them in again without any prompt. Enable the enable_end_session option to also log users out of the provider (this is called RP-Initiated Logout):

1
2
3
4
5
6
7
8
# config/packages/security.yaml
security:
    firewalls:
        main:
            oidc_login:
                # ...
                enable_end_session: true
                post_logout_redirect_path: /

Other Options

Mathieu Santostefano Florent Morselli
Contributed by Mathieu Santostefano and Florent Morselli in #65814 , #65910 and #66195

You can also use max_age to require a recent authentication at the provider, or http_client to configure a custom HTTP client with your own timeouts, retries or User-Agent.

Providers that send their response using an HTTP POST are supported too (response_mode: form_post).

See the OpenID Connect login docs for the full configuration reference.

Published in #Living on the edge