Download the PHP package itk-dev/openid-connect-bundle without Composer

On this page you can find all versions of the php package itk-dev/openid-connect-bundle. It is possible to download/install these versions without Composer. Possible dependencies are resolved automatically.

FAQ

After the download, you have to make one include require_once('vendor/autoload.php');. After that you have to import the classes with use statements.

Example:
If you use only one package a project is not needed. But if you use more then one package, without a project it is not possible to import the classes with use statements.

In general, it is recommended to use always a project to download your libraries. In an application normally there is more than one library needed.
Some PHP packages are not free to download and because of that hosted in private repositories. In this case some credentials are needed to access such packages. Please use the auth.json textarea to insert credentials, if a package is coming from a private repository. You can look here for more information.

  • Some hosting areas are not accessible by a terminal or SSH. Then it is not possible to use Composer.
  • To use Composer is sometimes complicated. Especially for beginners.
  • Composer needs much resources. Sometimes they are not available on a simple webspace.
  • If you are using private repositories you don't need to share your credentials. You can set up everything on our site and then you provide a simple download link to your team member.
  • Simplify your Composer build process. Use our own command line tool to download the vendor folder as binary. This makes your build process faster and you don't need to expose your credentials for private repositories.
Please rate this library. Is it a good library?

Informations about the package openid-connect-bundle

OpenId Connect Bundle

Github Release PHP Version Build Status Codecov Code Coverage Mutation Score Read License Package downloads on Packagist

Symfony bundle for authorization via OpenID Connect.

[!NOTE]

Symfony Native OIDC Support

Status as of September 2026.

Since this bundle was created Symfony has added support for OpenID Connect as documented in "Using OpenID Connect (OIDC)".

The authorization code flow has landed upstream

The native oidc_login authenticator we were tracking, symfony/symfony#64954, was merged on 2 September 2026 and ships with Symfony 8.2 (November 2026). The tracking issue symfony/symfony#50896 is closed, and the follow-ups the pull request was split into are all merged too, so the feature arrives whole rather than in instalments:

Documentation is in review in symfony/symfony-docs#22881. welcoMattic/oidc-login-demo is a runnable demo exercising every option across ten firewalls against Keycloak, and lists every pull request the feature is made of.

Everything Symfony shipped before this was stateless bearer token validation (the access_token authenticator): OIDC discovery, OAuth2 token introspection (RFC 7662) and JWE support, all added in Symfony 7.3. Those validate a token already present on the request; they do not drive the browser redirect, the callback and the session.

What this bundle still provides

Feature This bundle Symfony native
Authorization code flow ✅ ✅ ¹
Session-based browser login ✅ ✅ ¹
ID token signature verification ✅ ✅ ¹
OIDC discovery ✅ ✅
Multiple named OIDC providers ✅ ❌ ²
CLI login tokens ✅ ❌
Client secret expiry checks ✅ ❌
RP-initiated logout ❌ ✅ ¹
Bearer token validation (API) ❌ ✅
OAuth2 token introspection ❌ ✅

¹ Merged for Symfony 8.2, due November 2026, so not in a released version yet.

² oidc_login configures one provider per firewall: several providers means several firewalls, each with its own callback path. Several providers on one firewall is on the upstream roadmap, in the "Future steps" of #64954. Symfony's access_token handler does accept multiple issuers, but that is token validation, not a named provider model with distinct client credentials, redirect URIs and selectable login routes per provider.

What this means for the bundle

The gap this bundle exists to fill is closing upstream, and sooner than the previous version of this note assumed. Symfony 8.2 is not an LTS release, so applications tracking LTS — as ours do — get oidc_login with 8.4 in November 2027.

What has no upstream equivalent is now a short list: multiple named providers on one firewall, CLI login tokens, and the client secret expiry checks.

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 around the 8.4 LTS, so 2028.

Upgrading? See UPGRADE-6.1.md, and UPGRADE-5.0.md if you are coming from an earlier major.

Installation

To install run

Usage

Before being able to use the bundle, you must have your own User entity and database setup.

Once you have this, you need to

Variable configuration

In /config/packages/ you need the following itkdev_openid_connect.yaml file for configuring OpenId Connect variables

With the following .env environment variables

Set the actual values your env.local file to ensure they are not committed to Git.

Client secret expiry

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.

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.

Monitoring expiry

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:

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:

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:

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.

Logging

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.

Default: nothing to configure

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.

A dedicated channel with its own level

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:

The 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.

Using a different logger service

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:

Turning logging off

Point it at the NullLogger the bundle registers for the purpose:

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.

Audit logging

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.

Records 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:

Only 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:

Pseudonymising identifiers

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.

Configuring the HTTP client

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.

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 underlying itk-dev/openid-connect library extends, hard-types its HTTP client as GuzzleHttp\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

It 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.

Make sure to allow anonymous access to the login controller route, i.e. something along the lines of

CLI login

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:

See Symfony documentation: Generating URLs in Commands for more information.

You must also add the bundles CliLoginTokenAuthenticator to the security.yaml file:

Finally, 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.

Creating the Authenticator

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().

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.

With one authenticator per provider, override getSupportedProviderKeys() in each so it only answers its own provider's callback:

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.

Which requests count as a callback

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:

Give it the path as this application receives it, including any base path of its own.

Returning to the originally requested page

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:

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:

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.

Example authenticator functions

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.

Scopes

The authorization request asks for openid, email and profile. Set scopes per provider to ask for something else:

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:

PKCE

The 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:

The 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.

When the identity provider refuses

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:

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:

One 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.

Worker mode (FrankenPHP, Roadrunner)

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.

What your authenticator must not do

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:

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 firewall must be stateful

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.

How this is enforced

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.

Local development against a mock identity provider

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:

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:

Two things to know:

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.

Sign in from command line

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

or

for 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.

Development Setup

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:

This starts the Docker containers and installs Composer dependencies.

Running All CI Checks

To run all checks locally (coding standards, static analysis, tests):

Unit Testing

Test Matrix

Run the test suite across all supported PHP versions (8.3, 8.4, 8.5) with both lowest and stable dependencies, mirroring the CI matrix:

This runs PHPUnit with coverage for each combination and prints a summary of pass/fail results.

Mutation Testing

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.

The 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.

PHPStan Static Analysis

Worker Mode Analysis

igor-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.

Coding Standards

Check all coding standards:

Fix PHP coding standards (php-cs-fixer):

Fix Markdown files:

Fix YAML files:

Available Tasks

Run task --list to see all available tasks.

CI

GitHub Actions are used to run the test suite, mutation tests and code style checks on all PRs.

Versioning

We use SemVer for versioning. For the versions available, see the tags on this repository.

Upgrading across a major: UPGRADE-6.0.md, CHANGELOG.md has the rest.

License

This project is licensed under the MIT License - see the LICENSE.md file for details


All versions of openid-connect-bundle with dependencies

PHP Build Version
Package Version
Requires php Version ^8.3
ext-json Version *
ext-openssl Version *
doctrine/orm Version ^2.8 || ^3.0
itk-dev/openid-connect Version ^5.1
psr/log Version ^3.0
symfony/cache Version ^6.4 || ^7.0 || ^8.0
symfony/clock Version ^6.4 || ^7.0 || ^8.0
symfony/event-dispatcher Version ^6.4 || ^7.0 || ^8.0
symfony/framework-bundle Version ^6.4.13 || ^7.0 || ^8.0
symfony/security-bundle Version ^6.4.13 || ^7.0 || ^8.0
symfony/uid Version ^6.4 || ^7.0 || ^8.0
symfony/yaml Version ^6.4 || ^7.0 || ^8.0
Composer command for our command line client (download client) This client runs in each environment. You don't need a specific PHP version etc. The first 20 API calls are free. Standard composer command

The package itk-dev/openid-connect-bundle contains the following files

Loading the files please wait ...