Aller au contenu principal
Retour au blog

Symfony 8.2 OpenID Connect : login SSO natif avec oidc_login

Symfony 8.2 ajoute oidc_login : SSO Keycloak, Entra ID ou Okta sans bundle tiers. PKCE, JWKS et state par défaut, la config pas à pas et les pièges de prod.

Flavien Métivier · 10 min de lecture
Sommaire · 9 parties

oidc_login est le nouvel authenticator de Symfony 8.2 qui implémente le flux OpenID Connect Authorization Code dans un firewall, sans bundle tiers. OpenID Connect (OIDC) est le protocole qui délègue l'authentification à un fournisseur d'identité : Keycloak, Authentik, Auth0, Okta ou Microsoft Entra ID. Annoncé le 28 septembre 2026 sur le blog officiel, il arrive avec Symfony 8.2, dont la sortie est prévue en novembre 2026.

Brancher Keycloak, Entra ID ou Okta sur une application Symfony, c'est aujourd'hui un choix de bundle tiers. Ce choix a un coût : tu hérites de son rythme de maintenance, de ses défauts de configuration et d'un code de sécurité que tu n'as pas écrit. Symfony 8.2 met fin à ce détour pour le login navigateur.

Cet article fait partie d'une série sur les nouveautés de Symfony 8.2, une par article. Le premier épisode traitait du découpage de FrameworkBundle en un bundle par composant.

Pourquoi Symfony n’avait pas de login OIDC natif

Depuis la 6.3, l'authenticator access_token sait valider un token OIDC avec les handlers oidc et oidc_user_info. Mais, comme le rappelle le billet, ces handlers ne valident que des tokens que le client possède déjà. Parfait pour une API, inutile pour un navigateur : ils ne redirigent pas l'utilisateur et n'ouvrent pas de session. Pour le bouton « Se connecter avec… » d'une application web, il fallait donc un bundle tiers. La PR #64954 cite d'ailleurs drenso/symfony-oidc comme exemple de ce qui comblait le manque.

La distinction mérite d'être retenue telle quelle : le handler oidc de l'access_token (6.3) valide des tokens pour une API stateless ; oidc_login (8.2) connecte un utilisateur dans un navigateur, avec redirection, callback, échange du code, validation de l'ID token et ouverture de session. Les deux ne se remplacent pas.

Un point de méthode : la PR initiale, signée Mathieu Santostefano et mergée le 2 septembre 2026, était volontairement minimale. Elle a été complétée par une série de PR dans la même version (#65798 pour la vérification de signature, #65814 pour PKCE, #65817 pour le logout côté fournisseur, #66047 pour la requête d'autorisation dynamique), avec des contributions de Florent Morselli. La référence pour la configuration finale, c'est le billet et la documentation 8.2, pas le corps de la première PR.

Comment configurer oidc_login dans le firewall

Deux dépendances à installer explicitement : HttpClient pour parler au fournisseur, et web-token/jwt-library pour valider l'ID token.

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

Tu actives ensuite oidc_login dans le firewall. Le provider oidc est un user provider intégré qui fabrique l'utilisateur à la volée depuis les claims :

yaml
# 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']

L’équivalent en configuration PHP, tel que publié dans le billet :

php
// config/packages/security.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'security' => [
        'providers' => [
            'oidc_users' => ['oidc' => null],
        ],
        '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'],
                ],
            ],
        ],
    ],
]);

Tu ne déclares aucun endpoint : Symfony lit le document .well-known/openid-configuration publié par le fournisseur. Le callback par défaut est /oidc/callback (option check_path) ; enregistre son URL complète comme redirect URI chez le fournisseur. Selon la documentation 8.2, la recipe Flex de SecurityBundle importe déjà le loader de routes security.route_loader ; sans Flex, importe-le toi-même, sinon le routeur renvoie une 404 avant que le firewall ne voie le retour du fournisseur.

Pour une page de login qui propose plusieurs méthodes, ton bouton pointe vers la route qui démarre le flux, nommée d'après le firewall :

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

Ne fais jamais pointer un lien vers check_path : il ne gère que le retour du fournisseur et rejette toute requête sans code d'autorisation.

Quelles protections sont activées par défaut

La partie la plus risquée d'un client OIDC, c'est la validation, bien plus que la redirection. C'est aussi celle qu'on bâcle le plus facilement dans un authenticator maison. D'après le billet et la documentation 8.2, l'authenticator applique automatiquement :

  • state et nonce : le premier protège contre le login CSRF, le second lie l’ID token à la requête d’origine.
  • PKCE est appliqué par défaut (méthode S256) : un code d’autorisation intercepté devient inutilisable.
  • La signature de l’ID token est vérifiée contre les clés publiées par le fournisseur (JWKS), mises en cache : une rotation de clés ne demande aucune action de ta part.
  • Les claims iss, aud, exp, iat et sub sont requis et validés, ainsi que le paramètre iss de la réponse, contre les attaques de type mix-up.
  • HTTPS est obligatoire sur tous les endpoints du fournisseur, hors hôtes loopback en développement, et aucune redirection HTTP n’est suivie.

Deux nuances de la documentation à ne pas oublier. PKCE et la vérification de signature peuvent être désactivés, sauf pour un client public : « par défaut » ne veut pas dire « impossible à affaiblir ». Et le provider oidc intégré n'accorde aucun rôle à partir des claims : l'identité vient du sub vérifié, le rôle est ROLE_USER. Le mapping de groupes vers des rôles est un choix explicite, dans ton propre provider. Pour le reste du durcissement de l'authentification, notre article sur la sécurité auth Symfony en 2026 (logout, CSRF, remember me) complète ce tableau.

Comment charger les utilisateurs et mapper les groupes en rôles

Dès que tu stockes les utilisateurs en base ou que tu veux mapper des groupes du fournisseur vers des rôles, tu écris un provider qui implémente AttributesBasedUserProviderInterface. Sa méthode loadUserByIdentifier() reçoit tous les claims en second argument. Voici l'exemple du billet :

php
// 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;
    }

    // ...
}

Votre dette technique s'accumule ?

Audit complet en 10 jours. Recommandations priorisées et actionnables.

Calculer mon audit →

Par défaut, l'identifiant est le claim sub, le seul que la spécification garantit stable et unique chez un fournisseur. L'option user_identifier_claim: email existe, mais la documentation est sans ambiguïté : quiconque peut modifier la valeur de ce claim chez le fournisseur peut se connecter comme l'utilisateur correspondant de ton application. N'utilise jamais un email modifiable par l'utilisateur ou non vérifié par le fournisseur. Garde sub comme clé en base, comme le fait l'exemple avec oidcSubject, et traite l'email comme un simple attribut.

Même vigilance pour le mapping de rôles : dans l'exemple, ROLE_ADMIN est recalculé à chaque login à partir du claim groups. Assure-toi que ce claim n'est pas modifiable par l'utilisateur côté fournisseur, sinon tu viens d'ouvrir une élévation de privilèges. Par défaut, les claims viennent de l'endpoint UserInfo ; si ton fournisseur met tout dans l'ID token, user_data_source: id_token évite cet appel.

Comment personnaliser la requête d’autorisation

Les paramètres statiques, comme prompt: consent, passent par l'option authorization_params. Quand la valeur dépend de la requête courante, écoute le nouvel événement OidcAuthorizationRequestEvent, dispatché juste avant la redirection :

php
// 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);
        }
    }
}

Les paramètres dont dépend l'authenticator (state, nonce, redirect_uri…) ne sont modifiables ni par la configuration ni par l'événement. C'est cohérent : on ne te laisse pas casser les garanties de la section précédente.

Authentification du client, refresh des tokens et logout côté fournisseur

Authentification du client. client_authentication accepte client_secret_basic, client_secret_post, none pour les clients publics, client_secret_jwt et private_key_jwt. Pour un autre schéma, mTLS par exemple, tu fournis un service qui implémente ClientAuthenticationInterface. Avec private_key_jwt, l'application signe une assertion JWT avec sa propre clé, sans secret partagé :

yaml
# config/packages/security.yaml
security:
    firewalls:
        main:
            oidc_login:
                # ...
                client_authentication:
                    private_key_jwt:
                        key: '%env(OIDC_CLIENT_SIGNING_KEY)%'
                        algorithm: 'ES256'

Refresh des tokens. Les tokens du fournisseur sont stockés dans le token de sécurité, ce qui permet d'appeler ses API au nom de l'utilisateur. Activé, le renouvellement se fait automatiquement quand une requête arrive peu avant l'expiration ; à la demande, le service OidcTokenRefresher expose refreshIfNeeded().

yaml
# config/packages/security.yaml
security:
    firewalls:
        main:
            oidc_login:
                # ...
                scope: ['openid', 'profile', 'offline_access']
                refresh_access_token:
                    enabled: true

Logout. Par défaut, se déconnecter ne ferme que la session de ton application. L'utilisateur reste connecté chez le fournisseur, et la prochaine page protégée le reconnecte sans aucune saisie. L'option enable_end_session active le RP-Initiated Logout :

yaml
# config/packages/security.yaml
security:
    firewalls:
        main:
            oidc_login:
                # ...
                enable_end_session: true
                post_logout_redirect_path: /

Le billet mentionne aussi max_age pour exiger une authentification récente, http_client pour fixer tes propres timeouts, et response_mode: form_post pour les fournisseurs qui répondent en POST.

Les pièges de production à connaître

  • Pas de firewall stateless. state, nonce et le verifier PKCE vivent en session. Ton firewall d’API et ton firewall web doivent rester séparés.
  • form_post et cookie de session. Avec un fournisseur sur un autre site, le POST de retour est cross-site : sans SameSite=None (donc HTTPS), le cookie de session n’est pas envoyé et le login échoue.
  • Entry point. Si le firewall contient aussi un form_login, tu dois définir entry_point, sinon le container refuse de compiler.
  • Page d’échec. Sans failure_path, un échec renvoie vers /login. Dans une application 100 % OIDC, cette page n’existe pas : chaque échec finit en 404.
  • Pas de remember me. oidc_login n’ajoute volontairement aucun badge. Florent Morselli l’explique sous le billet : l’authentification appartient au fournisseur. Pour contrôler sa fraîcheur, passe par max_age ou prompt=login.

Faut-il abandonner son bundle OIDC actuel ?

Pas avant la sortie stable, et pas sans préparation. La documentation ne propose ni guide de migration depuis un bundle tiers, ni comparatif officiel. Je n'ai pas audité l'état des bundles existants, donc je ne les déclare pas obsolètes. Ce qui est sourcé, c'est le gain de la solution native : un authenticator maintenu par le cœur, et des défauts de sécurité que tu n'as plus à écrire. Avant de basculer, quatre questions :

  • Tes utilisateurs sont-ils indexés par sub ? S’ils le sont par email, choisis entre une migration vers sub et user_identifier_claim: email, en connaissance du risque.
  • Comptes-tu sur always_remember_me ? Il faudra repenser la durée de session.
  • Ton fournisseur envoie-t-il les claims dans l’ID token ou via UserInfo ? Règle user_data_source en conséquence.
  • Le logout doit-il fermer la session chez le fournisseur ? Si oui, active enable_end_session.

D'ici novembre, teste en développement avec un fournisseur local (Keycloak ou Authentik sous Docker) et des tests de bout en bout. Une bascule d'authentification est un vrai chantier, pas une modification de config. Et si tu veux passer en revue le reste de ta surface d'attaque en parallèle, la checklist OWASP pour sécuriser une app Symfony en 30 minutes reste un bon point de départ.

Ma lecture

Pour un nouveau projet sur Symfony 8.2 qui a besoin du « Se connecter avec… », oidc_login doit être ton point de départ. Du code de sécurité maintenu par le cœur vaut mieux qu'un authenticator maison ou qu'une dépendance tierce de plus, surtout quand il applique PKCE, JWKS et la validation des claims sans que tu aies à les réclamer.

Pour une application existante qui tourne bien avec un bundle, rien ne presse. Attends la sortie stable, relis la documentation de la version finale, et vérifie trois points sur un environnement de test avec ton fournisseur réel : le mapping sur sub, la durée de session sans remember me, le comportement du logout. Garde les handlers oidc de l'access_token pour tes API : les deux mécanismes cohabitent.

Si ton SSO repose aujourd'hui sur un bundle tiers ou du code maison, la vraie question n'est pas « quand passer à oidc_login », mais « est-ce que ma configuration actuelle vérifie bien tout ça ». C'est le type de point que couvre un Bear Scan : un audit de ton application en 10 jours, avec des recommandations priorisées, pour savoir ce qu'il faut corriger maintenant et ce qui peut attendre la 8.2.

Cet article vous a été utile ? Partagez-le.

Portrait de Flavien Métivier, fondateur de The Bearded Bear : barbe longue, lunettes, sur fond sombre

Écrit par

Flavien Métivier

Expert PHP/Symfony indépendant depuis 2014, ancien CTO de Kartable et d'Extrabat. Plus de 80 projets menés, de l'audit à la migration.

Mon parcours

À lire ensuite

Votre dette technique s'accumule ?

Audit complet en 10 jours. Recommandations priorisées et actionnables.

Newsletter Bear Bytes

Veille Claude Code, Symfony et PHP, retours d'expérience terrain et conseils techniques, directement dans votre boîte mail.