Symfony bundle for authorization via OpenID Connect.
Note
Status as of August 2026.
Since this bundle was created Symfony has added support for OpenID Connect as documented in "Using OpenID Connect (OIDC)".
Symfony's native OIDC support has improved significantly in recent releases:
- OIDC discovery was added in
Symfony 7.3 (May 2025), removing the need for manual keyset configuration.
Keys are fetched and cached automatically from the provider's
.well-known/openid-configurationendpoint. - OAuth2 Token Introspection (RFC 7662) support was added in Symfony 7.3, useful when access tokens are opaque (not JWTs).
- JWE (encrypted token) support was added in Symfony 7.3 for OIDC token handlers.
Everything released so far is designed for stateless bearer token
validation (the access_token authenticator) only. It validates tokens that
are already present on the request (e.g. in an Authorization: Bearer header),
and does not implement the authorization code flow — the browser-based
login where the application redirects to the IdP, handles the callback with an
authorization code, exchanges it for tokens, and establishes a session. That
gap is tracked in symfony/symfony#50896.
A native oidc_login authenticator is being added in
symfony/symfony#64954,
targeted at Symfony 8.2 (November 2026). The pull request is in active
review and is being reworked into a feature-complete implementation covering
discovery, PKCE, configurable scopes and claims mapping, token-endpoint client
authentication, and RP-initiated logout. One review question is still open —
ID token signature verification, which this bundle's underlying library
already does.
| Feature | This bundle | Symfony native |
|---|---|---|
| Authorization code flow | ✅ | ⏳ ¹ |
| Session-based browser login | ✅ | ⏳ ¹ |
| Multiple named OIDC providers | ✅ | ❌ ² |
| CLI login tokens | ✅ | ❌ |
| Client secret expiry checks | ✅ | ❌ |
| OIDC discovery | ✅ | ✅ |
| Bearer token validation (API) | ❌ | ✅ |
| OAuth2 token introspection | ❌ | ✅ |
¹ In review for Symfony 8.2, see above.
² Symfony's access_token handler accepts multiple issuers for token
validation, but this is not the same as this bundle's named provider model
with distinct client credentials, redirect URIs, and selectable login routes
per provider.
Long term we expect Symfony core to replace most of this bundle. It is not there yet: multiple providers per firewall, CLI login and the client secret expiry checks have no upstream equivalent, and our applications track Symfony LTS releases.
Until those gaps close the bundle remains fully supported. New features that upstream will provide are frozen; security and compatibility fixes continue. A deprecation will be announced here and in the CHANGELOG once a migration path exists — realistically no earlier than 2028.
Upgrading? See UPGRADE-6.1.md, and UPGRADE-6.0.md / UPGRADE-5.0.md if you are coming from an earlier major.
To install run
composer require itk-dev/openid-connect-bundleBefore being able to use the bundle, you must have your own User entity and database setup.
Once you have this, you need to
- Configure variables for OpenId Connect
- Create an Authenticator class that extends the bundle authenticator,
OpenIdLoginAuthenticator - Configure
LoginTokenAuthenticatorin order to use CLI login.
In /config/packages/ you need the following itkdev_openid_connect.yaml file
for configuring OpenId Connect variables
itkdev_openid_connect:
cache_options:
cache_pool: 'cache.app' # Cache item pool for caching discovery document and CLI login tokens
cli_login_options:
route: '%env(string:OIDC_CLI_LOGIN_ROUTE)%' # Redirect route for CLI login
user_provider: ~ #
logging_options:
# Optional: service id of the PSR-3 logger to receive failure logs.
# Defaults to the application logger. See "Logging" below.
logger: 'monolog.logger.openid_connect'
audit_options:
# Optional: write an authentication audit trail. OFF by default because
# audit records identify people. See "Audit logging" below.
enabled: false
secret_expiry_options:
# Optional: how many days ahead of expiry to start warning (default: 30).
warning_days: 30
openid_providers:
# Define one or more providers
# [providerKey]:
# options:
# metadata_url: …
# …
admin:
options:
metadata_url: '%env(string:ADMIN_OIDC_METADATA_URL)%'
client_id: '%env(string:ADMIN_OIDC_CLIENT_ID)%'
client_secret: '%env(string:ADMIN_OIDC_CLIENT_SECRET)%'
# Optional: date the client secret expires. Set it and the bundle warns
# before the secret expires; unset means the provider is not
# monitored and reports "unknown". Set it where the real secret
# lives. See "Client secret expiry" below.
client_secret_expires_at: '%env(string:ADMIN_OIDC_CLIENT_SECRET_EXPIRES_AT)%'
# Specify redirect URI
redirect_uri: '%env(string:ADMIN_OIDC_REDIRECT_URI)%'
# Optional: the path the callback arrives on, for a proxy that rewrites it
# without sending X-Forwarded-Prefix. Defaults to the path of
# redirect_uri, or of the generated redirect_route. See "Which
# requests count as a callback" below.
callback_path: '/auth/callback'
# Optional: Specify leeway (seconds) to account for clock skew between provider and hosting
# Defaults to 10
leeway: '%env(int:ADMIN_OIDC_LEEWAY)%'
# Optional: Cache duration (seconds) for the OIDC discovery document and JWKS
# Defaults to 86400 (24 hours)
cache_duration: '%env(int:ADMIN_OIDC_CACHE_DURATION)%'
# Optional: Send a PKCE challenge (RFC 7636, S256) with the authorization
# request. Defaults to true. See "PKCE" below.
pkce: true
# Optional: Scopes to request. Defaults to openid, email, profile.
# Must include openid. A space-separated string is accepted too,
# so the value can come from an environment variable.
scopes: ['openid', 'email', 'profile']
# Optional: Allow (non-secure) http requests (used for mocking a IdP). NOT RECOMMENDED FOR PRODUCTION.
# Defaults to false
allow_http: '%env(bool:ADMIN_OIDC_ALLOW_HTTP)%'
user:
options:
metadata_url: '%env(string:USER_OIDC_METADATA_URL)%'
client_id: '%env(string:USER_OIDC_CLIENT_ID)%'
client_secret: '%env(string:USER_OIDC_CLIENT_SECRET)%'
# As an alternative to using (a more or less) hardcoded redirect uri,
# a Symfony route can be used as redirect URI
redirect_route: 'default'
# Define any params for the redirect_route
# redirect_route_parameters: { type: user }With the following .env environment variables
###> itk-dev/openid-connect-bundle ###
# "admin" open id connect configuration variables (values provided by the OIDC IdP)
ADMIN_OIDC_METADATA_URL=ADMIN_APP_METADATA_URL
ADMIN_OIDC_CLIENT_ID=ADMIN_APP_CLIENT_ID
ADMIN_OIDC_CLIENT_SECRET=ADMIN_APP_CLIENT_SECRET
ADMIN_OIDC_CLIENT_SECRET_EXPIRES_AT=2027-01-31
ADMIN_OIDC_REDIRECT_URI=ADMIN_APP_REDIRECT_URI
ADMIN_OIDC_LEEWAY=30
ADMIN_OIDC_CACHE_DURATION=86400
ADMIN_OIDC_ALLOW_HTTP=false
# "user" open id connect configuration variables
USER_OIDC_METADATA_URL=USER_APP_METADATA_URL
USER_OIDC_CLIENT_ID=USER_APP_CLIENT_ID
USER_OIDC_CLIENT_SECRET=USER_APP_CLIENT_SECRET
# cli redirect url
OIDC_CLI_LOGIN_ROUTE=OIDC_CLI_LOGIN_ROUTE
###< itk-dev/openid-connect-bundle ###
Set the actual values your env.local file to ensure they are not committed to Git.
An expired client secret breaks every login: the token exchange starts failing
with invalid_client and there is nothing in the flow that says why. The expiry
date is known when the secret is created, so telling the bundle about it turns an
outage into a calendar item.
itkdev_openid_connect:
secret_expiry_options:
warning_days: 30 # default
openid_providers:
admin:
options:
client_secret_expires_at: '2027-01-31'Any date strtotime() understands is accepted, and the value is normally supplied
from an environment variable as above. Date-only values are anchored to midnight
UTC so the day count does not drift with the time of day the check runs.
A value that cannot be parsed — a typo, or an environment variable that is set but
blank — reports the provider as unknown and logs an error saying it is not being
monitored. It is not a fatal error, because a mistyped date should not take an
application down; but it is not silent either, because the effect is that nothing is
watching that secret.
Each provider is then in one of four states:
| Status | Meaning |
|---|---|
unknown |
no date configured — nothing can be said |
ok |
more than warning_days remaining |
expiring_soon |
warning_days or fewer remaining |
expired |
the date has passed |
unknown is deliberately distinct from ok: an installation that has not set a
date is not fine, it is unmonitored.
What the bundle does with each state, when a login is attempted:
| Status | Behaviour |
|---|---|
expired |
a critical record; the login still proceeds |
expiring_soon |
a warning record; the login proceeds |
ok, unknown |
nothing logged |
Nothing here blocks a login. The status depends on a manually maintained date,
which can fall out of step with the secret it describes: rotate a secret without
updating client_secret_expires_at and the date reads expired while the secret
works perfectly. The date is therefore an indicator, not authority — the identity
provider is what decides whether a secret still works. These records exist so that
when it does stop working, the reason is already in the log.
For a genuinely expired secret that means the login still fails, at the callback,
with invalid_client — but the critical record here and the failure record from
the callback together name the cause without anyone having to reproduce it.
client_secret_expires_at is optional, and where you set it matters more than that
you set it. Put it with the real secret — the production secret store, or a when@prod
block. A date in a committed .env default is a date nobody maintains: it reports ok
while measuring nothing, which is worse than the unknown you get by leaving it out.
Quote it: YAML reads an unquoted 2027-01-31 as a number, and a value that is not a
string is rejected while the container compiles.
A provider still reaches unknown at runtime when the value resolves to something
unusable — an environment variable that is set but blank, or a date
DateTimeImmutable cannot parse — and that is reported at error, because an
unmonitored secret is no better than not having this feature.
The records above only appear when somebody attempts a login, which is no help on a
quiet Sunday before a Monday-morning expiry. For scheduled monitoring, inject
ClientSecretExpiryChecker — it is a public service — and surface it through
whatever health endpoint the application already has:
use ItkDev\OpenIdConnectBundle\Util\ClientSecretExpiry;
use ItkDev\OpenIdConnectBundle\Util\ClientSecretExpiryChecker;
// Shape will differ per application; this follows a tagged-service aggregator.
readonly class ClientSecretHealthCheck implements HealthCheckInterface
{
public function __construct(private ClientSecretExpiryChecker $expiryChecker)
{
}
public function getName(): string
{
return 'oidc_client_secret';
}
public function check(): HealthCheckResult
{
$statuses = $this->expiryChecker->getAllStatuses();
$expired = array_filter($statuses, static fn (ClientSecretExpiry $e): bool => $e->isExpired());
if ([] !== $expired) {
return HealthCheckResult::degraded($this->getName(), sprintf(
'Client secret expired for: %s',
implode(', ', array_keys($expired)),
));
}
$expiring = array_filter($statuses, static fn (ClientSecretExpiry $e): bool => $e->isExpiringSoon());
if ([] !== $expiring) {
return HealthCheckResult::degraded($this->getName(), sprintf(
'Client secret expires soon for: %s',
implode(', ', array_keys($expiring)),
));
}
return HealthCheckResult::ok($this->getName());
}
}getAllStatuses() returns a ClientSecretExpiry per provider, keyed by provider
key, each with isExpired(), isExpiringSoon(), status and toArray().
The bundle ships no health endpoint of its own, and that is deliberate:
- Monitoring should have one endpoint to poll. A second one, differently shaped and living under this bundle's route prefix, is the one that gets forgotten.
- The application owns how such an endpoint is authenticated. That reasoning can be subtle — an application whose user provider is database-backed may need to authenticate its health route at the edge rather than in Symfony, so the endpoint can still answer during a database outage. A route shipped by this bundle would sit outside that decision.
- The application owns what may be disclosed. Provider keys and expiry dates are information about a deployment, and whether they belong in a public readiness response or an authenticated detail response is not this bundle's call.
Exposing the data rather than a verdict also avoids a lossy mapping. The checker
distinguishes four states, and unknown — a provider with no date configured — is
not the same as healthy. Collapsing that into another library's pass/fail result
would throw the distinction away, whereas an application mapping it itself can
decide whether "nobody is tracking this secret" counts as degraded.
This composes with whatever health system is in use:
- a bespoke aggregator, as in the example above;
macpaw/symfony-health-check-bundle, where checks implement its ownCheckInterfaceand are listed by service id in configuration;liip/monitor-bundle, which auto-discovers any class implementingLaminas\Diagnostics\Check\CheckInterface.
If an adapter is ever shipped from here, that last one is the target: the
laminas/laminas-diagnostics contract depends on nothing but PHP, and its
Success/Warning/Failure/Skip results map onto this bundle's four states
almost exactly — including Skip for a provider with no date configured. It is not
shipped today because no consuming application uses it yet, and a dependency added
for hypothetical reach is a dependency to carry for nothing.
The bundle logs every login failure: an invalid state, an empty nonce, a failed
token exchange, an unknown or unreachable provider, and the CLI login token
paths. This is how a problem like an expired client_secret becomes visible —
the IdP's own message is logged, with the causing exception attached to the
record as context['exception'].
The bundle decides how severe each failure is; your application decides which levels it keeps. Severity is not configurable, because it is a property of the event rather than of a deployment:
| Event | Level |
|---|---|
| Token exchange or ID-token validation failed (an expired secret lands here) | error |
| Provider not configured, or the session lost its provider key | error |
| Identity provider unreachable, or the discovery cache failed | error |
| CLI login token could not be resolved, or resolved to a bad value | error |
| Invalid state, or a missing/empty nonce | warning |
| Unknown provider key requested | warning |
| No CLI login token provided | warning |
The warning events are routine and client-driven — a stale bookmark, a replayed
callback, a probe. The error events are the ones an operator needs to act on.
The bundle's services are tagged onto the openid_connect Monolog channel, so
records flow to whatever handlers your application already has. Your existing
monolog configuration therefore determines what is written, with no extra setup.
To give this bundle its own log file and threshold — for example to keep only
error and above — add a handler scoped to the channel, and exclude that channel
from your default handler so records are not written twice:
monolog:
handlers:
# Everything except this bundle, at your usual level.
main:
type: stream
path: '%kernel.logs_dir%/%kernel.environment%.log'
level: debug
channels: ['!openid_connect']
# This bundle only, with its own threshold.
openid_connect:
type: stream
path: '%kernel.logs_dir%/openid_connect.log'
channels: ['openid_connect']
level: errorThe level key on the handler is what gives you "only errors and above". Raising
it filters out the warning events in the table above while keeping every
error.
logging_options.logger takes any PSR-3 service id. Note that it replaces the
channel logger rather than composing with it, so it is an escape hatch for sending
these records somewhere else entirely — not the way to filter them:
itkdev_openid_connect:
logging_options:
logger: 'my_app.audit_logger'Point it at the NullLogger the bundle registers for the purpose:
itkdev_openid_connect:
logging_options:
logger: 'itkdev_openid_connect.null_logger'Your authenticator must be an autoconfigured service to receive a configured
logger, since it is applied through registerForAutoconfiguration(). That is the
default for services in config/services.yaml. With autoconfiguration disabled
the authenticator falls back to a NullLogger and logs nothing, while the rest of
the bundle keeps logging.
A configured logger also takes precedence over a setLogger() call on the
authenticator's own service definition. Disabling autoconfiguration is the way to
wire a logger yourself.
Separately from the failure logging above, the bundle can write an authentication audit trail: who logged in, when, by which method, and which attempts were refused. This answers a different question from the error log — "who did what?" rather than "is something broken?" — which is why it is a separate channel rather than another level.
Important
The audit trail records personal data (user identifiers, IP addresses). It is off by default, and enabling it makes retention, access control and the lawful basis for that processing your responsibility. Nothing is recorded, and no record is even assembled, while it is disabled.
itkdev_openid_connect:
audit_options:
enabled: true
# Optional: defaults to the application logger.
logger: 'monolog.logger.openid_connect_audit'
# Optional: 'raw' (default) or 'hashed'.
identifier: rawRecords are written at info on the openid_connect_audit channel, with one
fixed context schema so the trail can be queried:
| Key | Meaning |
|---|---|
event |
one of the event names below |
method |
oidc or cli_token — the coarse category to query on |
authenticator |
concrete authenticator class, null for CLI token issuance |
subject |
user identifier, or null where none is available |
provider |
OIDC provider key, null for CLI token logins |
firewall |
firewall that handled the login |
ip |
client IP |
outcome |
success or failure |
reason |
failure cause, null on success |
Events: authentication.login_succeeded, authentication.login_failed,
authentication.cli_token_issued, authentication.cli_token_reissued,
authentication.cli_token_denied.
Give it a handler that will not be filtered out by an operational threshold, and retain it on whatever schedule your policy requires:
monolog:
handlers:
openid_connect_audit:
type: stream
path: '%kernel.logs_dir%/openid_connect_audit.log'
channels: ['openid_connect_audit']
level: infoOnly logins that went through this bundle's authenticators are recorded.
Symfony dispatches its login events for every authenticator in the application, so
if a project also offers password or API-token login, those events reach this
subscriber and are deliberately ignored: an OIDC bundle silently recording an
application's password logins would extend the personal-data processing past what
was opted into, and provider would be meaningless for them. Applications wanting
a complete authentication trail should subscribe to the same events themselves.
Both method and authenticator are recorded because they answer different
questions. method is stable and queryable; authenticator says which class
actually ran, which matters because consumers subclass OpenIdLoginAuthenticator
and an application may have several — one per provider, for instance.
Three details worth knowing:
- Failed OIDC logins carry no
subject. This bundle raises its failures while building the passport, so at that point Symfony has no authenticated identity to report. The record still carries the provider, the IP and the reason. - CLI login tokens are never recorded. The token is bearer-equivalent, so issuance is audited by subject only — the token and the login URL that embeds it stay out of the trail.
Setting identifier: hashed replaces the identifier with an HMAC-SHA256 keyed on
the application secret. It is stable, so records for the same person still
correlate, but it is not reversible from a list of known email addresses — which a
plain digest would be.
Note
identifier cannot come from an environment variable. The key is chosen while the
container compiles, so the mode has to be known then; an environment variable
would leave it hashing with an empty key, which looks pseudonymised without being
so. To vary it per environment, use Symfony's environment-specific configuration
(when@prod:), which is resolved at compile time.
Each provider accepts an optional http_client_options block that is forwarded
to the underlying Guzzle HTTP client used by league/oauth2-client. The bundle
applies a sensible default timeout of 30 seconds so a slow IdP cannot block
worker processes indefinitely (Guzzle's own default is 0, i.e. wait forever).
Override it per provider, or set it to 0 to opt back into Guzzle's behaviour.
itkdev_openid_connect:
openid_providers:
user:
options:
# ... existing keys ...
# @see https://docs.guzzlephp.org/en/stable/request-options.html
http_client_options:
# Float describing the total timeout of the request in seconds. Defaults to 30; set to 0 to wait indefinitely.
timeout: 5.0
# Pass a string to specify an HTTP proxy, or an array to specify different proxies for different protocols. (Default: none)
proxy: "%env(string:HTTP_PROXY)%"
# Describes the SSL certificate verification behavior of a request. (Default: true)
verify: true The bundle accepts only timeout, proxy, and verify under
http_client_options — these are the keys league/oauth2-client forwards to
Guzzle (verify is consulted only when proxy is set). Any other key causes
an InvalidConfigurationException at container compile time.
Why Guzzle and not Symfony HttpClient?
league/oauth2-client, which the underlyingitk-dev/openid-connectlibrary extends, hard-types its HTTP client asGuzzleHttp\ClientInterface. Symfony HttpClient implements PSR-18 / HTTPlug, not Guzzle's interface, and no maintained adapter goes Symfony → Guzzle. Configure Guzzle via the options above; full transport replacement is not currently possible without a custom adapter we are not yet shipping.
In /config/routes/ you need a similar itkdev_openid_connect.yaml file for
configuring the routing
itkdev_openid_connect:
resource: "@ItkDevOpenIdConnectBundle/src/Resources/config/routes.yaml"
prefix: "/openidconnect" # Prefix for bundle routesIt is not necessary to add a prefix to the bundle routes, but in case you want
i.e. another /login route, it makes distinguishing between them easier.
When invoking the login controller action (route itkdev_openid_connect_login)
the key of a provider must be set in the provider parameter, e.g.
<a href="{{ path('itkdev_openid_connect_login', {provider: 'user'}) }}">{{ 'Sign in'|trans }}</a> $router->generate('itkdev_openid_connect_login', ['provider => 'user']);Make sure to allow anonymous access to the login controller route, i.e. something along the lines of
# config/packages/security.yaml
security:
# …
access_control:
# …
- { path: ^/openidconnect/login(/.+)?$, role: IS_AUTHENTICATED_ANONYMOUSLY }In order to use the CLI login feature the following environment variable must be set in order for Symfony to be able to generate URLs in commands:
DEFAULT_URI=See Symfony documentation: Generating URLs in Commands for more information.
You must also add the bundles CliLoginTokenAuthenticator to the security.yaml
file:
security:
firewalls:
main:
custom_authenticators:
- ItkDev\OpenIdConnectBundle\Security\CliLoginTokenAuthenticatorFinally, configure the Symfony route to use for login links: cli_login_options: route. If yoy have multiple firewalls that are active for different url patterns
you need to make sure you add LoginTokenAuthenticator to the firewall active
for the route specified here.
The bundle can help you get the claims received from the authorizer – the only
functions that need to be implemented are authenticate(),
onAuthenticationSuccess() and start().
<?php
namespace App\Security;
use ItkDev\OpenIdConnect\Exception\ItkOpenIdConnectException;
use ItkDev\OpenIdConnectBundle\Security\OpenIdLoginAuthenticator;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
class SomeAuthenticator extends OpenIdLoginAuthenticator
{
public function authenticate(Request $request): Passport
{
// Get the OIDC claims.
try {
$claims = $this->validateClaims($request);
// Authentication success
// TODO: Implement authenticate() method.
} catch (ItkOpenIdConnectException $exception) {
// Authentication failed. Chain the cause: the bundle reads it back in
// onAuthenticationFailure() to decide what the user is shown.
throw new CustomUserMessageAuthenticationException($exception->getMessage(), previous: $exception);
}
}
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
// Back to whatever the user was trying to reach, or your default.
return $this->createTargetPathRedirect($request, $firewallName, '/');
}
public function start(Request $request, AuthenticationException $authException = null)
{
// TODO: Implement start() method.
}
}See below for a full authenticator example.
Make sure to add your authenticator to the security.yaml file - and if you
have more than one to add an entry point.
security:
firewalls:
main:
custom_authenticators:
- App\Security\ExampleAuthenticator
- ItkDev\OpenIdConnectBundle\Security\LoginTokenAuthenticator
entry_point: App\Security\ExampleAuthenticatorWith one authenticator per provider, override getSupportedProviderKeys() in each so
it only answers its own provider's callback:
protected function getSupportedProviderKeys(): array
{
return ['admin'];
}Without the override every authenticator supports every callback path, Symfony asks them in the order above, and the session's provider key decides which provider validates the callback — which is how existing setups already work.
A request is treated as an OpenID Connect callback when it carries both state and
code and arrives on a provider's configured callback path — the path of
redirect_uri, of the generated redirect_route, or callback_path when set. Every
provider must declare one of the three.
?state=…&code=… on any other URL is ignored by the authenticator, and the firewall
handles the request as it would without them: an anonymous visitor is sent to your
entry point, a logged-in one gets the page.
The path is matched against getBaseUrl() plus getPathInfo(), so an application
deployed in a subdirectory, or behind a proxy that sends X-Forwarded-Prefix with
Symfony's trusted proxies
configured, matches without further configuration: the prefix is part of the base URL
on the way in and part of redirect_uri on the way out.
Set callback_path when a proxy rewrites the path without announcing it — an
external https://app.example.org/prefix/auth/callback that arrives here as
/auth/callback. Nothing in the request says where the prefix went, so the path has to
be declared:
callback_path: '/auth/callback'Give it the path as this application receives it, including any base path of its own.
createTargetPathRedirect() sends the user back to the page that triggered the login,
falling back to a URL of your choosing when there is nothing to go back to:
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->createTargetPathRedirect($request, $firewallName, $this->router->generate('dashboard'));
}Symfony saves the requested page when your entry point fires, so this works both for applications that redirect straight to the identity provider and for those that show a login screen with a provider link on it. The fallback covers a user who went to the login link directly. The saved page is cleared on use, so a later visit to that link does not replay it.
For a login link on a public page, where nothing was denied and so nothing was saved, name the destination on the link itself:
<a href="{{ path('itkdev_openid_connect_login', {provider: 'admin', target_path: '/admin/reports'}) }}">Log in</a>The value must be a path within the application: a single leading /, no backslash,
no ://, no control characters. Anything else is dropped and logged at warning,
because it would otherwise turn the login route into an open redirect. When a page was
also denied, that page wins — it is what the user was actually stopped from reaching.
Only pages that exist and are access-controlled return this way, and that is by
design. Routing runs before security — RouterListener on kernel.request at
priority 32, the firewall at 8 — so a link to a URL with no route is a 404 before the
firewall is reached: no entry point fires, nothing is saved, and there is nothing to
come back to. A link to a page that exists but is public simply loads. Neither is
affected by the login flow.
Here is an example using a User with a name and email property. First we
extract data from the claims, then check if this user already exists and finally
update/create it based on whether it existed or not.
<?php
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use ItkDev\OpenIdConnect\Exception\ItkOpenIdConnectException;
use ItkDev\OpenIdConnectBundle\Exception\InvalidProviderException;
use ItkDev\OpenIdConnectBundle\Security\OpenIdConfigurationProviderManager;
use ItkDev\OpenIdConnectBundle\Security\OpenIdLoginAuthenticator;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
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\Exception\CustomUserMessageAuthenticationException;
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 AzureOIDCAuthenticator extends OpenIdLoginAuthenticator
{
/**
* AzureOIDCAuthenticator constructor
*
* @param EntityManagerInterface $entityManager
* @param RequestStack $requestStack
* @param UrlGeneratorInterface $router
* @param OpenIdConfigurationProviderManager $providerManager
*/
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly RequestStack $requestStack,
private readonly UrlGeneratorInterface $router,
private readonly OpenIdConfigurationProviderManager $providerManager
) {
parent::__construct($providerManager);
}
/** @inheritDoc */
public function authenticate(Request $request): Passport
{
try {
// Validate claims
$claims = $this->validateClaims($request);
// Extract properties from claims
$name = $claims['name'];
$email = $claims['upn'];
// Check if user exists already - if not create a user
$user = $this->entityManager->getRepository(User::class)
->findOneBy(['email'=> $email]);
if (null === $user) {
// Create the new user and persist it
$user = new User();
$this->entityManager->persist($user);
}
// Update/set user properties
$user->setName($name);
$user->setEmail($email);
$this->entityManager->flush();
return new SelfValidatingPassport(new UserBadge($user->getUserIdentifier()));
} catch (ItkOpenIdConnectException|InvalidProviderException $exception) {
throw new CustomUserMessageAuthenticationException($exception->getMessage(), previous: $exception);
}
}
/** @inheritDoc */
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return $this->createTargetPathRedirect(
$request,
$firewallName,
$this->router->generate('homepage_authenticated')
);
}
/** @inheritDoc */
public function start(Request $request, AuthenticationException $authException = null): Response
{
return new RedirectResponse($this->router->generate('itkdev_openid_connect_login', [
'provider' => 'user',
]));
}
}The authorization request asks for openid, email and profile. Set scopes per
provider to ask for something else:
openid_providers:
admin:
options:
scopes: ['openid', 'profile', 'groups']openid must be among them — OpenID Connect Core 1.0 §3.1.2.1 defines an
authentication request as one that asks for it, and without it the provider returns an
OAuth2 grant with no ID token, which is the only thing this bundle can validate. A list
missing it fails at compile time.
A space-separated string is accepted and split, since an environment variable can only carry a scalar:
scopes: '%env(ADMIN_OIDC_SCOPES)%' # ADMIN_OIDC_SCOPES=openid profile groupsThe bundle sends a PKCE challenge (RFC 7636, S256) with every authorization request. The login route generates a verifier, keeps it in the session, and sends only its SHA-256 challenge; the authenticator redeems the authorization code with the verifier. An intercepted code is then useless to whoever intercepted it, because they do not have the verifier.
It is on by default and needs no configuration. RFC 6749 §3.1 requires an authorization server to ignore parameters it does not recognise, so an identity provider that has never heard of PKCE behaves exactly as it did before. Turn it off only for one that rejects the parameters outright:
openid_providers:
legacy:
options:
pkce: falseThe verifier lives in the session alongside the state and the nonce, and is consumed on every callback — success, failure or refusal — so it can never be redeemed against a code it does not belong to.
A provider that will not issue a code redirects back to the callback with an error
and no code — the user closed the consent screen, their session at the provider had
expired, a tenant policy said no. The bundle recognises that callback, spends the
one-time session values like any other, and throws ProviderErrorException.
It extends AuthenticationFailedException, so anything already catching the bundle's
login failure catches this too, and it implements Symfony's HttpExceptionInterface,
so the kernel answers a refusal with 403 rather than a 500 — 503 where the
provider reports its own trouble, 500 where the error says our request or
registration is wrong. Nothing is required of the application to get that.
The error code is an accessor, not something to search the message for:
use ItkDev\OpenIdConnectBundle\Exception\ProviderErrorException;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
#[AsEventListener(KernelEvents::EXCEPTION, priority: 1)]
public function onLoginRefused(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if (!$exception instanceof ProviderErrorException) {
return;
}
$template = ProviderErrorException::ACCESS_DENIED === $exception->getError()
? 'security/login_cancelled.html.twig'
: 'security/login_failed.html.twig';
$event->setResponse(new Response(
$this->twig->render($template, ['error' => $exception->getError()]),
$exception->getStatusCode(),
));
}error and error_description reach you sanitized — control characters collapsed,
invalid UTF-8 dropped, capped at 200 characters — and neither is read at all until
the callback's state matches, so a forged callback cannot put text in your logs or on
your page. getErrorDescription() is whatever the provider sent, which may be
nothing; it is a diagnostic, not a message to show a user.
You can also pin the status and log level without writing a listener:
framework:
exceptions:
ItkDev\OpenIdConnectBundle\Exception\ProviderErrorException:
log_level: info
status_code: 403One thing is required of your authenticator: when authenticate() catches a bundle
exception and raises Symfony's, chain the cause — previous: $exception, as the
examples above do. The bundle reads it back to decide what the user is shown, and an
unchained failure arrives as a plain 500 with the reason only in the message.
If your application has its own listener that redirects 403 responses to a login
page, exclude ProviderErrorException from it. Otherwise a refusal is sent straight
back to the provider that refused it, which is the loop this handling exists to
prevent.
See ADR 004 for the reasoning.
The bundle is safe to run under a worker runtime, where one process serves many requests and every service outlives the request that created it.
No service in the bundle retains request data. State, nonce, PKCE verifier and claims
live on the request or in the session, never on a collaborator, and every one-time
session value is spent on the callback that uses it. getProvider() returns a fresh
provider each call, because league/oauth2-client records the authorization request's
state on the provider it builds.
Three things are shared across requests on purpose:
| What | Why it is safe |
|---|---|
| The Guzzle client, one per provider | Fixed options and a connection pool. Sharing it is the point: a token exchange reuses an open connection instead of renegotiating TLS. |
| The derived callback paths | Computed from your configuration and the routing base URL, and cached under that base URL. Two requests sharing a key derive identical values. |
| The authenticator's logger | Injected once by the container, never per request. |
Your OpenIdLoginAuthenticator subclass is a shared service too. Its authenticate()
and onAuthenticationSuccess() run once per request on the same object, so nothing
belonging to a request may be assigned to a property:
// Wrong: the next request through this process sees the previous user's claims.
private array $claims;
public function authenticate(Request $request): Passport
{
$this->claims = $this->validateClaims($request);
// ...
}Pass the values down instead, or put them on the request's attributes. The same applies to a user provider, a claims mapper, or anything else you inject into the flow.
The authorization code flow spans two requests, and the session is what ties them
together. A firewall declared stateless: true throws StatelessFirewallException,
naming the setting to remove.
CI runs igor-php, a static analyser for
worker-mode state leaks, on every pull request. The three values above are recorded in
igor-baseline.json, each with a written reason for why sharing it is safe. Anything
else that appears fails the build.
If you audit your own application with it, expect the same shape of result: the tool reports shared mutable state, which is not the same thing as a leak. Judge each finding and record the safe ones with a reason rather than refactoring them away.
Pointing a development environment at the real identity provider is usually impractical: it will not have your local hostname among its registered redirect URIs, and you may not want real accounts logging into a laptop. A mock provider gives you the whole authorization code flow locally, so the callback path, the claims mapping and the failure paths are exercised the way they will be in production.
oidc-provider-mock needs no
configuration file — users are given as repeated --user-claims flags, and it accepts
any client id and secret. A service in an override file, so it never starts on a
server:
services:
idp:
image: ghcr.io/geigerzaehler/oidc-provider-mock:latest
networks: [app]
expose:
- "80"
command:
- "--port"
- "80"
- "--user-claims"
- '{"sub": "admin", "email": "admin@example.org", "name": "Admin Jensen", "groups": ["administrator"]}'
- "--user-claims"
- '{"sub": "editor", "email": "editor@example.org", "name": "Ed Editor", "groups": ["editor"]}'At the login screen you pick which of those identities to be, which makes testing a role or a claim a matter of choosing a different subject.
Point a provider at it in development-only configuration:
# config/packages/dev/itkdev_openid_connect.yaml
itkdev_openid_connect:
openid_providers:
admin:
options:
metadata_url: 'http://idp/.well-known/openid-configuration'
# Any values will do; the mock accepts whatever it is given.
client_id: 'client-id'
client_secret: 'client-secret'
redirect_uri: 'http://localhost:8080/openid-connect/callback'
# Required: the mock is reached over http between containers, and the
# bundle refuses plain http otherwise. Never set this in production.
allow_http: trueTwo things to know:
allow_http: trueis mandatory here. Traffic between containers is http, and sinceitk-dev/openid-connect5.1 the scheme check covers every endpoint the discovery document announces, not onlymetadata_url. Keep it in development-only configuration rather than driving it from an environment variable that could be set wrong somewhere else.- PKCE needs no special handling. The mock accepts the challenge and the verifier,
so a login completes with the bundle's default. It does not advertise
code_challenge_methods_supported, so it is not checking the challenge — the round trip is exercised, the protection is not. Setpkce: falseonly for a provider that rejects the parameters outright rather than ignoring them.
Prefer a mock over turning security off in dev. Disabling the firewall for the
development environment is the tempting shortcut, and it means no OpenID Connect code
path is exercised until it reaches a server: a broken callback path, a renamed claim
or a login loop all stay invisible locally. It is also easy to forget, so the next
person to debug an authentication problem loses an afternoon to a firewall that was
never running.
deltag.aarhus.dk has a worked example, including two providers side by side.
ITK Dev developers: the internal ITK Dev documentation covers the fuller setup.
Rather than signing in via OpenId Connect, you can get a sign in url from the
command line by providing a username. Make sure to configure
OIDC_CLI_REDIRECT_URL. Run
bin/console itk-dev:openid-connect:login <username>or
bin/console itk-dev:openid-connect:login --helpfor details.
Be aware that a login token only can be used once before it is removed, and if
you used email as your user provider property the email goes into the username
argument.
A docker-compose.yml file with a PHP 8.3+ image is included in this project.
A Taskfile is used to run common development tasks.
To set up the project:
task setupThis starts the Docker containers and installs Composer dependencies.
To run all checks locally (coding standards, static analysis, tests):
task pr:actionstask testRun the test suite across all supported PHP versions (8.3, 8.4, 8.5) with both lowest and stable dependencies, mirroring the CI matrix:
task test:matrixThis runs PHPUnit with coverage for each combination and prints a summary of pass/fail results.
Line coverage shows which code the tests execute; mutation testing shows which code they actually verify. Infection applies small changes (mutants) to the source code — flipping a comparison, removing a method call — and runs the test suite against each one. If the tests still pass, the mutant "escaped": a potential bug the tests would not catch.
task test:mutationThe minimum mutation score (minCoveredMsi) is defined in infection.json5
and enforced both locally and in CI — no command line flags needed. CI
annotates escaped mutants inline on pull requests, and results for develop
are published to the
Stryker dashboard,
which also feeds the mutation score badge above. Detailed reports are written
to infection.log and infection.html on each run.
task analyzetask analyze:worker # audit for state that leaks between requests
task analyze:worker:check # fail if the baseline lists findings that no longer occur
task analyze:worker:baseline # regenerate the baseline after judging new findingsigor-baseline.json records the state this bundle shares on purpose, one written
reason per entry. A new finding fails task analyze:worker: either make the code
stateless, or add it to the baseline with a reason that says why sharing it is safe.
Never add an entry without one.
The analyser is a Go binary that the composer package downloads on first run. task
pins the version, since Igor is pre-1.0 and its rules change between releases; bump
IGOR_VERSION in Taskfile.yml and .github/workflows/php.yaml together, and
regenerate the baseline when you do.
Check all coding standards:
task lintFix PHP coding standards (php-cs-fixer):
task lint:php:fixFix Markdown files:
task lint:markdown:fixFix YAML files:
task lint:yaml:fixRun task --list to see all available tasks.
GitHub Actions are used to run the test suite, mutation tests and code style checks on all PRs.
We use SemVer for versioning. For the versions available, see the tags on this repository.
Upgrading across a major: UPGRADE-6.0.md, UPGRADE-5.0.md. CHANGELOG.md has the rest.
This project is licensed under the MIT License - see the LICENSE.md file for details