ronvanderheijden/openid-connect
OpenID Connect authentication for Laravel. Add OIDC login to your app with discovery, authorization code flow, token handling, and user info support.
The package turns a Laravel app that already uses Laravel Passport into an OpenID Connect Provider (OP) — it adds the discovery document, a JWKS endpoint, an ID token on top of Passport's access tokens, and a userinfo endpoint.
Minimal path:
composer require ronvanderheijden/openid-connect (Passport must already be installed and migrated).php artisan passport:install (or passport:keys for non-persisted keys).php artisan vendor:publish --tag=openid-connect-config
issuer to the exact public base URL clients will use, then php artisan config:cache.php artisan passport:client --redirect_uri=... and grant it the scopes you advertise.curl https://your-app.test/.well-known/openid-configuration. If this document is correct, most standards-compliant clients will configure themselves from it.First realistic use case: you have an existing Laravel SaaS with Passport-issued tokens, and a first-party SPA or mobile app (or a partner app) that wants "Sign in with our product" using the authorization code + PKCE flow, with an ID token the client can verify against your JWKS.
Discovery-first integration. Let clients (and your own tests) bootstrap from /.well-known/openid-configuration rather than hardcoding endpoints. This is also the cheapest regression test you can write:
$this->getJson('/.well-known/openid-configuration')
->assertOk()
->assertJsonPath('issuer', config('app.url'))
->assertJsonStructure(['authorization_endpoint', 'token_endpoint', 'jwks_uri', 'scopes_supported', 'response_types_supported']);
Authorization code + PKCE for SPAs. Keep the SPA public (no secret), register the redirect URI exactly, and require the openid scope plus whatever profile scopes you advertise. The openid scope is what makes Passport issue an ID token at all — forgetting it is the classic "why is there no id_token?" issue.
Server-to-server / M2M. Use Passport's client credentials grant for machine tokens and reserve the OIDC flows for user-facing auth. The OIDC layer is about identity; don't try to force ID tokens into M2M flows.
Claims mapping. Put your identity claims in one place (a user-to-claims transformer or a custom userinfo response) and reuse it for both the ID token and the userinfo endpoint so the two never disagree. Add claims only when a client actually needs them — email, name, sub cover most integrations, and sub must be stable and never recycled.
Resource-server verification in sibling services. Second Laravel API consumes the access token via Passport's guard; it validates the JWT signature against your jwks_uri and checks iss and aud. Cache the JWKS on the consumer side with a sane TTL so you're not fetching keys on every request.
Testing. Use Passport::actingAs($user, ['openid']) for endpoint tests, and add one end-to-end test that walks the real authorize → code → token → userinfo sequence. The flows are stateful and cookie/session-dependent, so mocked unit tests miss most of the failure modes.
Multi-tenant / multi-guard. If you serve several brands from one codebase, prefer separate app instances (and therefore separate issuers and key sets) over dynamically switching the issuer per request. The discovery document is a single global route; per-tenant issuers get messy fast.
The 1.2.1 fix matters more than it looks. Prior to 1.2.1 the scopes_supported key in /.well-known/openid-configuration was emitted incorrectly. Strict clients (and some OIDC libraries that validate the metadata document before use) can fail to register the provider, or silently fall back to a default scope set. After upgrading to 1.2.1, re-fetch the discovery document and confirm scopes_supported is a proper array of the scopes you actually grant. If you had a client working around this by hardcoding scopes, this is a good moment to remove the workaround.
Keep scopes_supported in sync with reality. The advertised scopes come from config, but what's actually grantable comes from Passport. These drift silently — you advertise profile but no client has it enabled, and the authorize step rejects with an opaque error. Treat the config list as the source of truth and create/update Passport scopes to match.
Issuer mismatch is the #1 support ticket. Behind a load balancer or proxy, config('app.url') often resolves to http://internal-host. The issuer must byte-for-byte match the URL the client used to reach discovery, otherwise token validation fails with iss mismatch. Fix trusted proxies / APP_URL / force HTTPS scheme rather than patching the client.
Config cache. Changing issuer, scopes, or route prefixes without php artisan config:cache (in production) produces confusing stale behavior.
Keys and rotation. passport:keys output and the JWKS endpoint must agree. After rotating keys, keep the old public key in the JWKS until the longest-lived token has expired, or clients will reject tokens mid-flight. Also watch file permissions on storage/oauth-*.key — a readable-but-wrong key gives signature failures that look like client bugs.
Token endpoint is form-encoded. application/json bodies return confusing 400s. Same for the Authorization: Basic client auth path when the client is confidential.
Middleware and sessions. The authorize endpoint needs the session/web guard; the token endpoint must not. If you customise routes, keep that split or you'll get "unauthenticated" on token exchange or CSRF failures on authorize.
Debugging order. (1) discovery document, (2) jwks_uri returns a key with the expected kid, (3) decode the ID token at jwt.io and check iss/aud/exp/nonce, (4) only then look at application code. Clock skew of more than a minute between containers is a surprisingly common cause of token expired.
Extension points. The supported way to customise behaviour is the published config plus overriding the package's controllers/routes in your app rather than editing vendor code — the userinfo response and claim set are the usual things you'll want to replace. Check the package's route registration before adding conflicting routes, and keep the well-known paths unprefixed and publicly reachable (no auth middleware, no locale prefix).
Pinning. Given the package wraps Passport, pin both the package and laravel/passport in composer.json and read the changelog for both before a Laravel major upgrade — most breakage shows up as discovery or grant-type incompatibility rather than a PHP error.
How can I help you explore Laravel packages today?