Retour

/ 8 min read

SSO avec Keycloak et Symfony

Pourquoi Keycloak ?

Quand plusieurs applications d’un même écosystème doivent partager une authentification, gérer les utilisateurs manuellement dans chaque app devient vite un problème : chaque service a sa propre table users, sa propre logique de session, et si un utilisateur change son mot de passe sur l’une, les autres ne le savent pas.

Keycloak règle ça. C’est un serveur d’identité open source : un seul endroit pour gérer les utilisateurs, les rôles et les sessions. Toutes vos applications s’authentifient contre lui via OAuth2/OIDC, sans se parler entre elles.

Dans cet article, on met en place un SSO Keycloak complet avec Symfony — de la configuration du realm à la gestion des refresh tokens, avec Docker pour l’environnement local.


Architecture

Navigateur
│ 1. L'utilisateur clique sur "Se connecter"
Symfony App ──── 2. Redirige vers Keycloak ────▶ Keycloak
3. Login/password
◀──── 4. Redirige avec un code d'autorisation ─────┘
│ 5. Symfony échange le code contre un JWT (access + refresh token)
Ressources protégées

Keycloak joue le rôle d’Authorization Server : il authentifie l’utilisateur et délivre les tokens. Symfony est le Client : il ne gère plus les mots de passe lui-même, il fait confiance aux tokens signés par Keycloak.


1. Lancer Keycloak avec Docker

On utilise start-dev uniquement pour le développement local — ce mode désactive HTTPS et certaines vérifications de sécurité, ce qui simplifie la mise en place. En production, on utilise start avec un certificat TLS.

docker-compose.yml
services:
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
ports:
- '8080:8080'
Terminal window
docker compose up -d

L’interface d’administration est accessible sur http://localhost:8080. Connectez-vous avec admin / admin.


2. Configurer le Realm

Un realm est un espace totalement isolé dans Keycloak : ses propres utilisateurs, ses propres clients, ses propres rôles. Keycloak en crée un par défaut nommé master — c’est le realm d’administration de Keycloak lui-même. On ne l’utilise jamais pour nos applications, pour éviter de mélanger les droits d’admin Keycloak avec les droits applicatifs.

  1. Dans l’interface, cliquez sur le menu déroulant en haut à gauche, puis Create Realm
  2. Nommez-le mon-projet
  3. Activez-le et sauvegardez

3. Créer le Client Symfony

Dans la terminologie Keycloak, un client représente une application qui va consommer le SSO. Chaque application (Symfony, une API, une app mobile) a son propre client, avec ses propres URLs et son propre secret.

On choisit le mode confidential (Client authentication activé) parce que Symfony tourne côté serveur et peut garder un secret — contrairement à une SPA JavaScript qui ne peut rien cacher.

  1. Clients > Create client
  2. Remplissez :
    • Client ID : symfony-app
    • Client authentication : activé (mode confidential)
  3. Dans Settings, configurez les URLs autorisées. Keycloak les vérifie pour éviter les attaques de redirection :
    • Valid redirect URIs : https://mon-projet.fr/auth/keycloak/callback
    • Valid post logout redirect URIs : https://mon-projet.fr/
    • Web origins : https://mon-projet.fr
  4. Dans Credentials, notez le Client Secret généré — c’est lui qu’on mettra dans le .env.local

4. Créer les Rôles

Les rôles définissent ce qu’un utilisateur a le droit de faire dans votre application. On les définit dans Keycloak (et non dans Symfony) pour qu’ils soient centralisés : si un utilisateur change de rôle, il suffit de le modifier dans Keycloak, sans toucher au code.

  1. Clients > symfony-app > Roles > Create role
  2. Créez ROLE_USER et ROLE_ADMIN

Ces rôles doivent être inclus dans le JWT envoyé à Symfony. Pour ça, allez dans Clients > symfony-app > Client scopes, ouvrez le scope dédié, et vérifiez que le mapper de rôles a Add to ID token et Add to access token activés.


5. Créer un Utilisateur de test

  1. Users > Create new user, renseignez username et email, activez Email verified
  2. Onglet Credentials : définissez un mot de passe et désactivez Temporary (sinon l’utilisateur sera forcé de changer son mot de passe à la première connexion)
  3. Onglet Role mapping : assignez ROLE_USER

6. Intégration côté Symfony

Deux packages, deux rôles distincts

Terminal window
composer require knpuniversity/oauth2-client-bundle
composer require stevenmaguire/oauth2-keycloak

Ce ne sont pas des alternatives — les deux sont nécessaires et jouent des rôles différents :

  • knpuniversity/oauth2-client-bundle : c’est le bundle Symfony qui s’intègre au système de sécurité. Il fournit l’OAuth2Authenticator, gère le flow de redirection vers le serveur d’auth, et expose le ClientRegistry. Il est générique et fonctionne avec n’importe quel provider OAuth2.

  • stevenmaguire/oauth2-keycloak : c’est le provider spécifique à Keycloak. Il connaît les endpoints de Keycloak (/auth/realms/{realm}/protocol/openid-connect/...), sait parser le KeycloakResourceOwner, et gère la notion de realm. Sans lui, le bundle ne saurait pas comment parler à Keycloak.

Configuration du bundle

config/packages/knpu_oauth2_client.yaml
knpu_oauth2_client:
clients:
keycloak:
type: keycloak # indique au bundle d'utiliser le provider Keycloak
auth_server_url: '%env(KEYCLOAK_URL)%'
realm: '%env(KEYCLOAK_REALM)%'
client_id: '%env(KEYCLOAK_CLIENT_ID)%'
client_secret: '%env(KEYCLOAK_CLIENT_SECRET)%'
redirect_route: auth_keycloak_callback
version: '24.0'

Variables d’environnement

# .env.local — ne jamais commiter ce fichier
KEYCLOAK_URL=http://localhost:8080
KEYCLOAK_REALM=mon-projet
KEYCLOAK_CLIENT_ID=symfony-app
KEYCLOAK_CLIENT_SECRET=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

L’Authenticator

C’est le cœur de l’intégration côté Symfony. Il s’exécute uniquement quand Keycloak redirige l’utilisateur vers le callback, récupère le token d’accès, puis charge (ou crée) l’utilisateur correspondant en base.

src/Security/KeycloakAuthenticator.php
<?php
namespace App\Security;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use KnpU\OAuth2ClientBundle\Security\Authenticator\OAuth2Authenticator;
use Stevenmaguire\OAuth2\Client\Provider\KeycloakResourceOwner;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
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;
class KeycloakAuthenticator extends OAuth2Authenticator
{
public function __construct(
private ClientRegistry $clientRegistry,
private RouterInterface $router,
) {}
// Ne s'active que sur la route de callback — les autres requêtes ne passent pas ici
public function supports(Request $request): ?bool
{
return $request->attributes->get('_route') === 'auth_keycloak_callback';
}
public function authenticate(Request $request): Passport
{
$client = $this->clientRegistry->getClient('keycloak');
// Échange le code d'autorisation contre un access token + refresh token
$accessToken = $this->fetchAccessToken($client);
return new SelfValidatingPassport(
new UserBadge($accessToken->getToken(), function () use ($accessToken, $client) {
/** @var KeycloakResourceOwner $keycloakUser */
// Appel à Keycloak pour récupérer les infos de l'utilisateur (email, uuid, rôles...)
$keycloakUser = $client->fetchUserFromToken($accessToken);
return $this->loadOrCreateUser($keycloakUser);
})
);
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return new RedirectResponse($this->router->generate('app_home'));
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new RedirectResponse($this->router->generate('app_login'));
}
private function loadOrCreateUser(KeycloakResourceOwner $keycloakUser): mixed
{
// À adapter selon votre UserRepository
// $keycloakUser->getId() → UUID Keycloak (stable, à utiliser comme identifiant)
// $keycloakUser->getEmail() → email
return null; // Remplacer par votre logique
}
}

Le contrôleur OAuth

Deux routes suffisent : une pour déclencher la redirection vers Keycloak, une pour recevoir le callback. Le corps de callback() est vide car c’est l’authenticator qui prend la main.

src/Controller/SecurityController.php
<?php
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;
class SecurityController extends AbstractController
{
#[Route('/auth/keycloak', name: 'auth_keycloak_start')]
public function connect(ClientRegistry $clientRegistry): Response
{
// Les scopes demandés : openid (obligatoire), email et profile pour les infos user
return $clientRegistry->getClient('keycloak')->redirect(['openid', 'email', 'profile']);
}
#[Route('/auth/keycloak/callback', name: 'auth_keycloak_callback')]
public function callback(): void
{
// Géré automatiquement par KeycloakAuthenticator
}
#[Route('/logout', name: 'app_logout')]
public function logout(): void
{
// Géré par le pare-feu Symfony Security
}
}

Configuration du pare-feu

config/packages/security.yaml
security:
firewalls:
main:
lazy: true
custom_authenticators:
- App\Security\KeycloakAuthenticator
logout:
path: app_logout
target: app_home
access_control:
- { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/app, roles: ROLE_USER }

7. Gestion des Refresh Tokens

L’access token Keycloak est intentionnellement court (5 minutes par défaut). L’idée : s’il est volé, la fenêtre d’exploitation est réduite. Pour ne pas forcer l’utilisateur à se reconnecter toutes les 5 minutes, on utilise le refresh token — plus long, et échangeable contre un nouvel access token sans interaction de l’utilisateur.

Configurer les durées dans Keycloak

Dans Realm Settings > Tokens :

  • Access Token Lifespan : 5 minutes — court par design
  • SSO Session Max : 8 heures — durée de vie du refresh token, au-delà l’utilisateur doit se reconnecter

Rafraîchir le token côté Symfony

src/Service/TokenRefreshService.php
<?php
namespace App\Service;
use KnpU\OAuth2ClientBundle\Client\ClientRegistry;
use League\OAuth2\Client\Token\AccessToken;
class TokenRefreshService
{
public function __construct(private ClientRegistry $clientRegistry) {}
public function refresh(string $refreshToken): AccessToken
{
$provider = $this->clientRegistry->getClient('keycloak')->getOAuth2Provider();
// Keycloak retourne un nouvel access token ET un nouveau refresh token
return $provider->getAccessToken('refresh_token', [
'refresh_token' => $refreshToken,
]);
}
}

Stockez le refresh token en session lors de l’authentification et appelez ce service avant chaque appel à une ressource protégée.


8. Récupérer les Rôles depuis le Token JWT

Keycloak encode les rôles directement dans le JWT, dans le claim resource_access. Pour les extraire :

$tokenData = $accessToken->getValues();
// Les rôles du client "symfony-app" sont dans resource_access.symfony-app.roles
$keycloakRoles = $tokenData['resource_access']['symfony-app']['roles'] ?? [];
// On les mappe ensuite sur les rôles Symfony dans le UserProvider

C’est important de vérifier que les rôles viennent bien du bon client (symfony-app), et pas des rôles globaux du realm qui peuvent contenir d’autres permissions.


9. Docker Compose complet (dev + prod)

Pour que Symfony puisse appeler Keycloak via son nom de service Docker (keycloak), les deux doivent être dans le même réseau. On ajoute aussi PostgreSQL car Keycloak ne doit pas stocker ses données en mémoire en dehors du mode start-dev.

docker-compose.yml
services:
app:
build: .
environment:
KEYCLOAK_URL: http://keycloak:8080 # nom du service Docker, pas localhost
KEYCLOAK_REALM: mon-projet
KEYCLOAK_CLIENT_ID: symfony-app
KEYCLOAK_CLIENT_SECRET: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx
depends_on:
- keycloak
keycloak:
image: quay.io/keycloak/keycloak:24.0
command: start-dev
environment:
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://db:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
depends_on:
- db
db:
image: postgres:16
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak

En production : remplacez start-dev par start, ajoutez KC_HOSTNAME avec votre domaine, et faites passer Keycloak derrière un reverse proxy Nginx ou Traefik avec HTTPS.


Points à retenir

  • Ne jamais utiliser le realm master pour vos applications — c’est réservé à l’administration de Keycloak
  • Access Tokens courts (5 min) + Refresh Tokens longs (8h) : c’est le bon équilibre sécurité/UX
  • Le client_secret ne va que dans les variables d’environnement, jamais dans le code
  • En Docker, Symfony appelle Keycloak via son nom de service, pas localhost
  • Les rôles dans le JWT viennent de resource_access.{client-id}.roles, pas du niveau realm

Conclusion

Keycloak peut sembler imposant au premier abord parce qu’il y a beaucoup de concepts à poser (realm, client, scopes, mappers…). Mais une fois la configuration faite, l’intégration Symfony est relativement légère : un bundle, un authenticator, quelques routes. Et le vrai gain se mesure quand on ajoute une deuxième application : elle bénéficie du même SSO sans retoucher la gestion des utilisateurs.