facile-it/php-openid-client
Full OpenID Connect client for PHP supporting OAuth 2.0 authorization code, implicit and hybrid flows, discovery, dynamic client registration, UserInfo/ID tokens with JWT signing and encryption, refresh tokens, and token revocation/introspection.
1. Install with a PSR-18 stack. The package only declares PSR interfaces, so you must supply concrete implementations. In a Laravel app Guzzle + guzzlehttp/psr7 is already present:
composer require facile-it/php-openid-client guzzlehttp/psr7
php -m | grep gmp # gmp may be required for RSA/JWK handling
If you want the optional session middleware you also need dflydev/fig-cookies. If you need non-RSA signing/encryption algorithms, install the corresponding web-token/jwt-library algorithm packages — only RSA ships by default.
2. Bind the three moving parts in a service provider. Day-to-day you interact with an Issuer (discovery document + JWKS), a Client (your metadata), and stateless services (AuthorizationService, UserInfoService, …). Build them once and resolve from the container:
$this->app->singleton(Issuer::class, fn () => (new IssuerBuilder())
->setMetadataProviderBuilder(
(new MetadataProviderBuilder())->setCache(cache()->store())->setCacheTtl(86400)
)
->setJwksProviderBuilder(
(new JwksProviderBuilder())->setCache(cache()->store())->setCacheTtl(3600)
)
->build(config('openid.discovery_url')));
$this->app->singleton(ClientInterface::class, fn ($app) => (new ClientBuilder())
->setIssuer($app->make(Issuer::class))
->setClientMetadata(ClientMetadata::fromArray([
'client_id' => config('openid.client_id'),
'client_secret' => config('openid.client_secret'),
'token_endpoint_auth_method' => 'client_secret_basic',
'redirect_uris' => [route('openid.callback')],
]))
->build());
$this->app->singleton(AuthorizationService::class, fn () => (new AuthorizationServiceBuilder())->build());
$this->app->singleton(UserInfoService::class, fn () => (new UserInfoServiceBuilder())->build());
3. First use case — the login redirect. Two routes and nothing else:
// GET /login -> RedirectController
$state = Str::random(40);
$nonce = Str::random(40);
session(['openid_state' => $state, 'openid_nonce' => $nonce]);
return redirect($authorizationService->getAuthorizationUri($client, [
'scope' => 'openid profile email offline_access',
'state' => $state,
'nonce' => $nonce,
]));
Where to look first: the README's "Basic Usage" block is the entire happy path; after that, the Service\Builder\* classes are the real API surface (each service has a builder with setters for the PSR-18 client, request factory, and options), and Facile\OpenIDClient\Token\TokenSetInterface is what you'll be passing around the most.
Callback handling belongs in a controller, not middleware. The package ships PSR-15 middlewares (ClientProviderMiddleware, AuthRequestProviderMiddleware, AuthRedirectHandler, CallbackMiddleware, UserInfoMiddleware), but Laravel's HTTP kernel isn't PSR-15 and the session plumbing (SessionCookieMiddleware writing a fig-cookie) duplicates Laravel's own session. In practice you translate the Laravel request to PSR-7 and call the service directly:
use Symfony\Bridge\PsrHttpMessage\Factory\Psr17Factory; // or nyholm/psr7 + manual copy
$psrRequest = app(Psr17Factory::class)->createRequest($request);
$params = $authorizationService->getCallbackParams($psrRequest, $client);
abort_unless(hash_equals(session()->pull('openid_state'), $params['state'] ?? ''), 400, 'State mismatch');
$tokenSet = $authorizationService->callback($client, $params);
$claims = $tokenSet->claims(); // verifies signature/iss/aud/exp on the ID token
abort_unless(hash_equals(session()->pull('openid_nonce'), $claims['nonce'] ?? ''), 400, 'Nonce mismatch');
Auth::login(User::firstOrCreate(['sub' => $claims['sub']], $claims), true);
session(['openid.refresh_token' => $tokenSet->getRefreshToken()]);
callback() exchanges the code and validates the ID token, but state and nonce are yours to check — the middleware does it for you only if you adopt SessionCookieMiddleware.
Resolve the local user via sub, not email. Issuers change email frequently; sub is the stable identifier. Store sub + issuer (for multi-provider apps) as a unique compound key.
Refresh deliberately. $authorizationService->refresh($client, $refreshToken) returns a new TokenSet. Persist the new refresh token every time — many IdPs rotate it and the old one dies. Guard with a try/catch: an expired/revoked refresh token raises an exception and should mean "log the user out", not a 500:
try {
$tokenSet = $authorizationService->refresh($client, $stored);
session(['openid.refresh_token' => $tokenSet->getRefreshToken()]);
} catch (\Throwable) {
Auth::logout();
session()->invalidate();
return redirect()->route('login');
}
Separate the "who is this" call from the "tell me about them" call. $tokenSet->claims() gives you the ID token claims (cheap, always present). $userInfoService->getUserInfo($client, $tokenSet) makes a network call to the UserInfo endpoint for the richer profile — only call it when you actually need profile fields, and cache the result keyed on sub + updated_at claim if you render it on every page.
For machine-to-machine, skip the browser dance entirely and use the client-credentials grant via AuthorizationService (or IntrospectionService/RevocationService for token hygiene). This is the easiest place to start if you're not sure the package fits — no redirects, no cookies.
Multi-tenant / multi-IdP: register one named Client per issuer in a config array and resolve by a route segment or a provider column on your users table. Key your PSR-16 cache by issuer URL (the builders use the issuer URI as part of the key) so tenants don't collide.
Testing: the services accept an injectable PSR-18 client via their builders, so bind a mock ClientInterface in tests and assert the authorization URI is well-formed and that callback() receives the right params. You never need a live IdP for the bulk of your tests.
setCache() on MetadataProviderBuilder and JwksProviderBuilder, every single request fetches /.well-known/openid-configuration and the JWKS endpoint. This is the number-one performance and reliability issue with this library. Note the cache type is PSR-16, and Illuminate\Contracts\Cache\Repository extends Psr\SimpleCache\CacheInterface, so cache()->store() works directly — but keys are namespaced by the provider, so don't hand-roll your own keys unless you also add a prefix.gmp is a soft requirement that bites hard. Everything works on dev machines and fails in a slim Docker image when the IdP signs with RS256 and the JWK math needs gmp. Add ext-gmp to your composer.json require block and to your Dockerfile.getCallbackParams() requires a ServerRequestInterface. symfony/psr-http-message-bridge (already a transitive dep of Laravel) plus guzzlehttp/psr7 or nyholm/psr7 is the least friction. Watch out that the bridge drops the raw body unless you pass the content through, which matters for POST/form_post response modes.token_endpoint_auth_method defaults matter. client_secret_basic URL-encodes credentials (fixed in 0.1.4) — if you set it to client_secret_post or use private_key_jwt, the IdP must have the client registered accordingly or you get an opaque invalid_client with no useful message.http vs https. route('openid.callback') generating http:// behind a proxy is a classic production-only failure — configure TrustProxies/APP_URL first.openid must be in the scope or you get an OAuth token without an ID token. offline_access is required for a refresh token; asking for it silently yields no refresh token on some IdPs.$tokenSet->getIdToken() can be null (e.g. client-credentials grant, or a provider that doesn't return one). Always branch on it before calling claims().ES256 (common with Azure AD / some Keycloak configs) or you request encrypted ID tokens or a signed request object, you must install the matching JWT Framework algorithm package or you'll get a confusing "algorithm not supported" exception at verification time.web-token/jwt-library 4.x, which has its own migration guide — pin and test this in a branch, especially if you also depend on facile-it/jose-verifier (the JWKS provider lives there).Facile\OpenIDClient\Exception\* includes runtime, invalid-request, and OAuth-error variants. Catch OAuthException separately so you can surface error_description to logs while showing the user a generic "sign-in failed" page — never leak the raw exception message.end_session_endpoint, pull it from $issuer->getMetadata()->getEndSessionEndpoint() and build the redirect yourself; the library doesn't wrap it.Middleware::log() (or a tap on the handler stack) to the PSR-18 client you inject into the builders. Nearly every "it just doesn't work" case is a discovery, JWKS, or token-endpoint HTTP call you can't see.How can I help you explore Laravel packages today?