drenso/symfony-oidc-bundle
Symfony OIDC bundle adds OpenID Connect authentication to Symfony applications using the new authentication manager. Supports SURFconext/OpenConext, Keycloak, Auth0, Microsoft Entra ID and others. Requires Symfony 5.4+ and PHP 8.
Install and scaffold. composer require drenso/symfony-oidc-bundle. With Symfony Flex you get config/packages/drenso_oidc.yaml and matching env vars (OIDC_WELL_KNOWN_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET) appended to .env. Without Flex, create the config file manually — that is the whole install surface.
Where to look first, in order:
config/packages/drenso_oidc.yaml — confirm the default client block resolves from env.App\Entity\User / user provider — this is where most of the real work lives.config/packages/security.yaml — the oidc: firewall listener.docs/ms-entra-id.md and docs/adfs.md — only if you are on those IdPs; the defaults will not work for Entra ID.First use case — wire the three moving parts.
Client config (already generated by Flex):
drenso_oidc:
clients:
default:
well_known_url: '%env(OIDC_WELL_KNOWN_URL)%'
client_id: '%env(OIDC_CLIENT_ID)%'
client_secret: '%env(OIDC_CLIENT_SECRET)%'
User provider must implement OidcUserProviderInterface:
public function ensureUserExists(string $userIdentifier, OidcUserData $userData, OidcTokens $tokens): void
{
// bootstrap account on first login; throw if provisioning is disallowed
}
public function loadOidcUser(string $userIdentifier): UserInterface
{
return $this->loadUserByIdentifier($userIdentifier);
}
Firewall + login route:
security:
firewalls:
main:
pattern: ^/
oidc: ~
#[Route('/login_oidc', name: 'login_oidc')]
#[IsGranted('PUBLIC_ACCESS')]
public function login(OidcClientInterface $oidcClient): RedirectResponse
{
return $oidcClient->generateAuthorizationRedirect();
}
The authenticator only accepts the callback on check_path (default /login_check), which must match the redirect URI registered at the IdP. Set both and you have a working login on day one.
Multi-tenant / multi-IdP via named clients. Any extra client under clients: is autowirable by name: OidcClientInterface $linkOidcClient. Anything that does not match a configured name silently resolves to the default client, so keep client names distinct from any future class/service names and verify with debug:container when autowiring surprises you. Pair this with a per-firewall client: value to build separate login entry points (staff vs. partner IdP) in one application.
Route the login entry through a controller, not the firewall. generateAuthorizationRedirect() accepts prompt, scopes and arbitrary query params — this is the extension point for prompt=login, acr_values, login_hint, or locale negotiation. Do the IdP-specific choice there rather than scattering it into security config.
Choose your identity source deliberately. Three switches interact:
user_identifier_property (default sub, supports Symfony PropertyAccessor paths, object-based access)user_identifier_from_idtoken (default false — identifier comes from the userinfo endpoint)enable_retrieve_user_info / user_info_from_idtokenFor id_token-only flows (saves an HTTP round-trip per login, common with Entra ID), set enable_retrieve_user_info: false and user_identifier_from_idtoken: true. For richer profile data, keep the default. For IdPs with flaky userinfo endpoints, user_info_from_idtoken: true lets you keep the endpoint enabled but read claims from the token.
Separate loadOidcUser from loadUserByIdentifier. The dedicated method exists so you can distinguish OIDC-originated logins from form logins (different password/credential handling, different failure messages, different audit logs) inside one provider. Forwarding is fine only if OIDC identifiers are globally unique in your table.
Remember me. Add _remember_me=1 to the URL that hits your login controller, set enable_remember_me: true on the listener, and ensure Symfony's remember_me authenticator is configured. Override the parameter name per client with remember_me_parameter.
Logout. Set enable_end_session_listener: true plus logout: true on the firewall, and leave use_logout_target_path: true so the IdP bounces the user back. For per-client logout endpoints, the client's end_session_endpoint must appear in the well-known document.
Caching. Enable symfony/cache in the app and the bundle caches both well-known and JWKS for well_known_cache_time / jwks_cache_time (default 3600s). This is the single cheapest latency win on this integration — without it, the discovery and key fetches land on every login. Use a shared cache adapter in multi-node deployments.
PKCE. code_challenge_method: 'S256' where the IdP supports it; plain only for legacy providers. Treat this as default-on for public/SPA-adjacent deployments.
Version floor is hard. v2+ requires Symfony 5.4 (new authenticator manager) and PHP 8. On Symfony <6, enable_authenticator_manager: true is still needed or the oidc: listener is ignored entirely. If you are on 5.3/PHP 7.4, stay on the v1.x branch and its separate docs.
The check_path / redirect URI mismatch is the classic failure. If the IdP redirect URI and check_path disagree, the authenticator never accepts the callback and you get a redirect loop rather than an error. Check redirect_route in the client config against check_path in the firewall — both default to /login_check, so changing one without the other breaks silently.
Config is not per-client for the whole matrix. Authentication behavior (user_identifier_property, enable_remember_me, success/failure handlers) lives on the firewall listener, while token/discovery settings live on the client. With multiple clients behind one firewall you get one identity configuration; split firewalls if you need different identifier properties.
ensureUserExists is the security boundary. It runs during authentication and a throw there means hard auth failure — do not let a downstream exception (duplicate email, race on concurrent first login) surface as a generic 500. Wrap provisioning in a transaction and handle the uniqueness race explicitly.
Disabling userinfo has a hard precondition. Setting enable_retrieve_user_info: false without user_identifier_from_idtoken: true is an invalid combination; the bundle has no identifier source. Validate this in your test suite, not production.
IdP quirks are documented, not defaulted. Entra ID will not work out of the box (docs/ms-entra-id.md explains the required overrides). On-prem ADFS v1 needs allow_discovery_access_token_issuer: true to accept the non-standard access_token_issuer discovery field. Both are opt-in flags — expect the first Entra ID attempt to fail until you follow the doc.
Logout is disabled by default for a good reason. SSO logout only terminates the IdP session for the current app; the user may remain signed in elsewhere. Enabling enable_end_session_listener gives a false sense of security unless your app's own session is also destroyed. Document the gap rather than pretending it closes.
Debug sequence. bin/console debug:config drenso_oidc (resolved client values), debug:firewall main (listener registered?), then check the redirect URI echoed in the authorization URL. Most failures are resolved-value failures (env var not interpolated, trailing slash, http/https mismatch) rather than code issues.
Extension points worth knowing. well_known_parser accepts a service ID for a custom parser — the right hook when an IdP publishes a malformed or non-standard discovery document. custom_client_headers gets you through proxies and IdPs requiring extra headers. token_leeway_seconds (default 300) absorbs clock skew in token validation; raise it only when you can measure skew, never as a blind fix for nbf/iat errors.
Support posture. 99 stars, zero dependent packages, and the repository is a mirror of an internal GitLab instance — the maintainers accept issues/PRs, but do not expect a broad third-party ecosystem or quick community answers. Budget for reading the bundle source (OidcClient, OidcAuthenticator, OidcTokens) when you hit an edge case; it is a thin OO wrapper over jumbojett/OpenID-Connect-PHP, so behavior usually traces directly there. Apache-2.0 licensing is unproblematic for commercial use.
Testing. The authorization redirect is cheap to unit test — inject the client, assert on the URL/state param. The full callback flow needs a stubbed IdP (a local symfony/http-client mock adapter against the well-known/JWKS endpoints) so you exercise signature validation rather than bypassing it.
How can I help you explore Laravel packages today?