ajgarlag/openid-connect-provider-bundle
A Symfony bundle that adds OpenID Connect Provider support on top of league/oauth2-server-bundle, providing standards-compliant discovery and RP-initiated logout endpoints with configurable routes and sensible defaults.
This is a Symfony bundle, not a Laravel package. Before anything else, decide where it will live:
IdToken, AuthCodeGrant nonce handling, EndSessionController, discovery route) onto Laravel + league/oauth2-server. Do not attempt to composer require a Symfony bundle into a Laravel app and expect the DI/route/config pipeline to work.Minimal setup (Symfony host app):
Confirm prerequisites. PHP 8.2+, Symfony 6.4 / 7.4 are the tested matrix. Symfony 8.0 was removed from CI workflows in 0.3.0 — if you were relying on the previous "8.0 supported" claim, treat it as untested until you verify against your app, and check the bundle's composer.json constraints before pinning to it. You must already have league/oauth2-server-bundle installed and fully working, because this bundle only extends it. As of 0.3.0 both league/oauth2-server-bundle:^1 and :^2 are supported — greenfield projects should start on v2, existing projects can stay on v1 but see the upgrade note in Gotchas.
Install the bundle.
composer require ajgarlag/openid-connect-provider-bundle
Do the league/oauth2-server-bundle setup end-to-end: generate an RSA keypair, configure league_oauth2_server with the authorization_code (and optionally implicit) grant, register at least one client, and implement the user-facing authorization/consent controller. The README links to their README — follow it fully, it is not optional boilerplate. If you are on league v2, read its upgrade notes first; grant/response class names and Client entity behavior differ from v1.
Register routes in config/routes/ajgarlag_openid_connect_provider.yaml:
ajgarlag_openid_connect_provider:
resource: '@AjgarlagOpenIDConnectProviderBundle/config/routes.php'
type: php
This gives you the discovery document, JWKS, and the end-session (RP-initiated logout) endpoint. Without it, discovery returns 404 and RPs will refuse to configure you. As of 0.3.0, keys in JWKS now include a kid value (PR #30) — this is what most RPs need to pick the right signing key, so the previous "JWKS has no kid" failure mode is gone.
Review defaults in config/packages/ajgarlag_openid_connect_provider.yaml. The knobs on day one are:
ajgarlag_openid_connect_provider:
discovery:
authorization_endpoint_route: 'oauth2_authorize'
token_endpoint_route: 'oauth2_token'
jwks_endpoint_route: 'openid_connect_jwks'
end_session_endpoint_route: 'openid_connect_end_session'
end_session:
cancel_logout_default_path: '/'
If your authorize/token routes are named differently (likely, if you customized league's routing), fix these now — otherwise discovery advertises URLs that 404.
Decide on a custom client entity (0.3.0). If you need per-client metadata that the default League\Bundle\OAuth2ServerBundle\Model\Client does not carry — most commonly post_logout_redirect_uris for RP-initiated logout, or tenant/scope-policy fields — define your own Client entity and point the bundle at it. See "Custom clients" in Implementation Patterns.
Where to look first (in order):
config/routes.php inside the bundle — tells you exactly which endpoints exist and their route names.DependencyInjection/Configuration.php — the authoritative list of options, including the custom-client configuration shape added in 0.3.0.UserClaimsResolveEvent listener interface — this is where 95% of your integration work will happen.SessionSidTrait — needed for correct RP-initiated logout.league/oauth2-server-bundle source for anything token-related; this bundle barely touches issuance beyond the ID token. On v2, double-check class names before copying snippets from v1 docs.First use case: Internal SSO. Register your first-party apps as clients, point them at /.well-known/openid-configuration, wire them to the authorization code flow, and verify with OpenID Connect Debugger or oidc-client-js in a dev SPA. Get discovery + code flow + a valid id_token green before touching logout. Confirm the JWKS response now includes kid on each key — that is the single most useful sanity check after upgrading to 0.3.0.
1. Claim resolution via UserClaimsResolveEvent
This is the primary extension point. The bundle asks your code to answer "what claims go in this ID token for this user?" Implement a listener that maps your User entity to OIDC claims:
final class UserClaimsListener
{
public function __invoke(UserClaimsResolveEvent $event): void
{
$user = $event->getUser();
$event->setClaim('sub', (string) $user->getId());
$event->setClaim('email', $user->getEmail());
$event->setClaim('email_verified', $user->isEmailVerified());
$event->setClaim('name', $user->getDisplayName());
$event->setClaim('groups', $user->getGroupNames());
}
}
Register it as a normal Symfony event subscriber. Treat sub as sacred: it must be stable and opaque-ish, and it must not be the user's email if email can change. Keep a sub column or use an immutable UUID. The 0.3.0 service-reference cleanup (PR #34) means services are referenced by string id consistently — if you decorate or override bundle services, make sure you target the id, not the class name, or your decorator may silently stop applying.
2. Scope-aware claims
Requesting clients send scope=openid email profile. Don't leak profile data to clients that only asked for email. Read the requested scopes off the event (or the underlying authorization) and conditionally set claims:
$scopes = $event->getScopes(); // if available in your version
if (in_array('profile', $scopes, true)) {
$event->setClaim('name', $user->getDisplayName());
}
At minimum, gate email, phone, address, and any groups claim — that last one is a classic privilege-escalation footgun.
3. Custom clients (new in 0.3.0)
PR #28 lets you plug in your own client entity instead of the stock League\Bundle\OAuth2ServerBundle\Model\Client. Use this when you need to store OIDC-specific client metadata that the default model doesn't carry:
class OidcClient extends \League\Bundle\OAuth2ServerBundle\Model\Client
{
/** @var string[] */
private array $postLogoutRedirectUris = [];
private bool $trustedFirstParty = false;
// getters/setters, mapped via Doctrine
}
Then configure the bundle to use OidcClient (see Configuration.php for the exact option key in 0.3.0; the release notes only describe the feature, not the config surface). This is the clean place to store:
post_logout_redirect_uris — the allow-list you validate against during RP-initiated logout. Previously the docs said "the redirect allow-list is your responsibility," which implied a hand-rolled table; now it can live on the client.trusted_first_party / tenant — so you can skip or customize the consent screen for internal apps.Custom clients are not dynamic client registration. You still provision clients through your admin UI; you just own the schema.
4. RP-initiated logout (OIDC 1.0)
The end-session endpoint accepts id_token_hint, post_logout_redirect_uri, and state. Three things you must do:
SessionSidTrait::getSid() in whatever service resolves the current session, so the sid claim in issued ID tokens matches the session being terminated. Without a matching sid, logout verification will reject the request (this was buggy pre-0.2.1 — use ≥0.2.5; 0.3.0 keeps the fix).post_logout_redirect_uri against the client's registered logout URIs. With custom clients (0.3.0), store the URIs on the client and validate in your end-session listener/controller decorator; the bundle still only validates the ID token.end_session.cancel_logout_default_path (default /).5. Discovery as the contract
Instead of hand-wiring clients, always point RPs at /.well-known/openid-configuration. Then customize behavior by changing route names in config, not by patching discovery output. If you need extra metadata (e.g., scopes_supported, claims_supported), check whether the bundle exposes it via config first; otherwise decorate the controller service rather than forking. The kid addition in 0.3.0 is a good example of why this matters: discovery and JWKS gained a standard field without any host-app change.
6. Client registration workflow
There is no dynamic client registration. Your onboarding procedure is: an admin creates a client record (via whatever CRUD you build on top of league/oauth2-server-bundle's Client entity, or your custom one), hands over client_id/client_secret, and collects the allowed redirect URIs and post-logout URIs. Bake this into an admin UI/form early — it becomes the operational bottleneck otherwise. On 0.3.0 this got easier: put post-logout URIs on the custom client so operators fill one form instead of two systems.
7. Testing pattern
UserClaimsResolveEvent directly, asserting every claim including sub stability across email changes.oidc-client-js in a dev SPA covers code flow, PKCE (if enabled), silent renew, and logout in one harness.GET /.well-known/openid-configuration returns 200 and contains the four endpoint URLs — this catches the "route renamed, discovery stale" failure immediately.GET /jwks contains at least one key with a non-empty kid and an alg. This was a real RP failure mode before 0.3.0 and is worth locking in.8. Laravel-side integration (if you're the RP)
If Laravel apps consume this Symfony OP, use league/oauth2-client + steverhoades/oauth2-openid-connect-provider on the Laravel side. The Symfony app is only the issuer; your day-to-day Laravel work is standard OIDC client code: state/nonce validation, JWKS caching, and refresh. The kid in JWKS (0.3.0) makes key-rotation handling in the RP cache correct on the first try.
Symfony 8.0 is no longer in CI (0.3.0). The release title says "Complete symfony 8.0 removal from workflows," which means 8.0 is not part of the tested matrix. If your host app is on 8.0, either test thoroughly yourself or pin to Symfony 7.4. Do not assume 8.0 works just because an older README said so. Check composer show ajgarlag/openid-connect-provider-bundle for the actual constraint before upgrading.
league/oauth2-server-bundle:^2 is now supported, but the leap is not free. PR #39 adds compatibility on the bundle side. The league v2 upgrade brings its own changes (grant/response class names, Client entity, response type handling). If you have decorated or extended v1 classes, budget time. Strategy: upgrade this bundle to 0.3.0 first while staying on league v1 (should be a no-op install), then upgrade league to v2 in a separate PR so failures are attributable. If you have composer why-not blockers on v2, 0.3.0 still installs against v1.
Custom clients require wiring, not just a class. Defining OidcClient is half the job — you also need Doctrine mapping, the bundle config key that names your client class, and the league/oauth2-server-bundle config that resolves clients through your repository. If clients are created via the stock admin command, the custom fields will be null; write your own creation path or populate defaults. Also migrate existing client rows (backfill post_logout_redirect_uris as empty array, not null) or the first logout attempt after upgrade will error on a null iterable.
No ID token for client credentials. Fixed in 0.2.2 by short-circuiting issuance, but it means: if you're using client_credentials for machine-to-machine, expect an access token only. There is no openid scope in that grant and no user — that's correct behavior, don't fight it.
Nonce is conditional. As of 0.2.5, nonce is only set in the ID token if present in the authorization request. If an RP complains that nonce is missing from the ID token, the bug is on the RP side — they didn't send it. Don't "fix" it by unconditionally setting nonce; that breaks the replay-protection contract.
IdToken::getClaim returns the default when missing (0.2.5). If you were relying on a null/exception to detect missing claims, update your assertions. Also check your custom code doesn't pass a null default and then type-error downstream.
sid must be the real session ID. Use SessionSidTrait::getSid() (added 0.2.1) — it reads the existing session ID without generating a new one. Calling $session->getId() after the session was invalidated will silently rotate it and break logout verification. This is the #1 RP-initiated-logout bug.
PKCE is not configured by this bundle. It's a league/oauth2-server-bundle concern. For any public client (SPA, mobile), you must enable it there. Treat "PKCE not enabled" as a code-review blocker for those client types. The same is true on league v2; the config key differs, so read the league README, not this bundle's.
Discovery route names drift silently. Renaming a route in the host app won't error — discovery will just advertise a dead URL until an RP fails to log in. Add the smoke test mentioned above, and keep the four route names in your config file under version control alongside the router config.
JWKS kid presence is now expected. As of 0.3.0 every key in JWKS carries a kid. If you wrote tests asserting the previous shape (keys without kid), they will break. More importantly, if an RP is configured with a hard-coded kid from before the upgrade, rotate/refresh the RP config — the previous keys had no kid so there is nothing to mismatch, but RPs that cached the old JWKS may need a nudge.
Service references now go by name (0.3.0). PR #34 standardizes DI references to string ids. If you were decorating by class name — decorates: Some\Class\Name — verify after upgrade that your decorator is still applied. The fix is to switch to the service id used by the bundle (see config/services.php in the bundle).
Symfony version pinning (pre-0.3.0 history). 7.2 and 7.3 were removed in 0.2.4. If your project is locked to those, you're stuck on 0.2.3. 0.3.0 continues that policy and additionally walks back 8.0, so the supported/tested matrix is 6.4 and 7.4. Check composer why-not before upgrading; constraints are strict.
Doctrine ORM mapping pass. 0.2.3 added a guard around DoctrineOrmMappingPass. If you're using the bundle in a non-Doctrine-ORM app (e.g., custom repositories, MongoDB), the guard prevents a hard boot failure, but make sure your league/oauth2-server-bundle client/token storage is wired to something that actually persists — and that your custom client entity (0.3.0) is persistable through whatever mechanism you chose.
Debugging an RP that "fails to log in": check in this order — (1) discovery document endpoint URLs resolve, (2) JWKS is reachable and returns keys with kid (fixed in 0.3.0; if you still see no kid, you didn't upgrade), (3) redirect URI is an exact string match on the client (no trailing-slash leniency, and http:// vs https:// matters), (4) ID token aud equals client_id, (5) clock skew on the host (RPs reject tokens with iat in the future). Most "invalid ID token" tickets land at step 3 or 5. After upgrading to 0.3.0, an RP that suddenly rejects tokens is almost always a cached JWKS — clear the RP cache.
Extension points, ranked by usefulness:
UserClaimsResolveEvent — claims, scopes, sub mapping. This is the workhorse.cancel_logout_default_path — customize the cancel path, decorate for your own consent UI.league/oauth2-server-bundle grants/responses — for anything beyond authorization code/implicit (e.g., token exchange, custom grants), that's the correct layer, not this bundle. On v2 specifically, this is where any custom-grant work must live.Upgrade checklist for 0.3.0. (a) Read the CHANGELOG and the linked league v2 notes if you're moving both. (b) Run the two smoke tests (discovery, JWKS-with-kid). (c) If you added a custom client entity, verify the config key and the migration of existing rows. (d) Re-verify any service decorators by id not class. (e) Confirm your Symfony version is 6.4 or 7.4 unless you've tested 8.0 yourself. (f) Re-run the full auth code + logout path against a real RP; the kid addition is benign but any JWKS cache in front of it may not be.
Documentation is TBD. The README states it plainly, and 0.3.0 does not fix that — notably, the exact configuration surface for custom clients is not in the release notes. The bundle's config/routes.php, Configuration.php, and the CHANGELOG are your real docs. The CHANGELOG is unusually informative for behavior changes (nonce, sid, client credentials, kid) — read it before every minor bump.
Maturity signal (updated). Single maintainer, still pre-1.0. 0.3.0 is a meaningful feature release — custom clients, kid in JWKS, league v2 support — which is a positive signal, but it also means the dependency surface broadened. Budget for the possibility that you need to submit a PR or vendor a patch. For an internal SSO provider that's fine; for a public-facing identity platform, weigh it against Keycloak/Ory Hydra, which handle dynamic registration and higher-scale concerns this bundle explicitly doesn't. The custom-clients feature closes part of the operational gap, but not the dynamic-registration one.
How can I help you explore Laravel packages today?