A small, dependency-light OpenID Connect client for PHP. It covers the Authorization Code flow (with optional PKCE), the Implicit flow, the Client Credentials grant, refresh tokens, and UserInfo - each verified against OpenID Connect Core 1.0 rather than trusting a provider to have gotten it right.
Configuration is a single immutable OpenIDConnectClientConfig
value rather than a chain of setter calls, so the same shape covers both a statically-known
integration and a multi-tenant one resolved per request. Cache, HTTP transport, and clock are
all injectable - OpenIDConnectClientFactory assembles the rest.
composer require henderjon/php-oidc
Assembles an OpenIDConnectClient and every
collaborator it is composed of, from a shared HTTP fetcher, clock, and logger. A caller
wiring up one client per integration never reaches for new on the client or its
internal collaborators (state store, provider metadata resolver, ID token verifier, claims
validator, token endpoint client) itself - those are not part of this library's public API.
Builds one OpenIDConnectClient. $stateCache is a PSR-16 cache used
to persist authorization state, nonce, and the PKCE code verifier
between the redirect and the callback - session storage is fine for a single-server app; a
load-balanced deployment needs something shared, like Memcache or Redis, injected here
instead. $cacheKeySuffix namespaces cache keys when one process builds more than
one client (e.g. multi-tenant).
The logger defaults to a no-op - passing one is opt-in. It only ever receives detail behind a failure that already produces a deliberately generic exception.
Everything needed to talk to one OpenID Connect provider for one call - a single immutable value instead of a chain of setter calls. The same shape covers both a statically-known integration (provider URL, issuer, and credentials fixed at boot) and a multi-tenant one (issuer/credentials resolved per request from a database row).
CurlHttpFetcher's own
constructor instead - decided once per fetcher instance, never as a config value that could
travel anywhere this config does.
| Parameter | Type | Default | Notes |
|---|---|---|---|
clientId | string | required | Sent as client_id on every request. |
clientSecret | string | required | Empty string marks a public client - PKCE and client authentication both change behavior accordingly. |
redirectUrl | string | required | Must match the redirect_uri registered with the provider. |
providerUrl | ?string | null | Base URL for .well-known/openid-configuration discovery, and the default issuer when that is not set. |
issuer | ?string | null | Expected iss claim value. Falls back to providerUrl. |
scopes | list<string> | [] | Merged with the always-included openid scope. |
audience | list<string>|string|null | null | Expected aud value(s) when it must differ from clientId. Also doubles as the full trusted set unless allowUntrustedAudiences opts out. |
endpointOverrides | array<string,string> | [] | Known endpoint values (e.g. token_endpoint) that skip discovery for that value. |
extraAuthParams | array<string,string> | [] | Merged into the authorization request. |
pkce | PkceMode | Disabled | How strictly RFC 7636 PKCE is enforced on the authorization code flow. |
allowInsecureSchemes | bool | false | Allows http:// endpoints. TLS certificate/hostname verification itself is never affected by this - see CurlHttpFetcher. |
allowedHosts | ?list<string> | null | Bare hostnames every resolved endpoint must match. Null falls back to the host of issuer/providerUrl, unless allowAnyHost is set. |
allowedAlgorithms | list<string> | ['RS256'] | ID token signing algorithms accepted. HS* is always rejected outright for a public client, regardless of this list. |
maxTokenLifetimeSeconds | ?int | null | Optional cap on exp - iat, independent of clock-skew leeway. |
allowUntrustedAudiences | bool | false | Opts out of rejecting aud values outside the trusted set - keeps only the "expected value present" half of the check. |
allowAnyHost | bool | false | Opts out of the default provider-host restriction when allowedHosts is null - for a provider that splits endpoints across hosts. |
clientAuthMethod | ClientAuthMethod | Basic | Which OpenID Connect Core 1.0 §9 method to use for a confidential client. No effect on a public client. |
Every property has a matching with*() method returning a new instance - the
original is never mutated. withScopes(), withEndpointOverrides(),
and withExtraAuthParams() merge with the existing value;
withAudience(), withAllowedHosts(), and
withAllowedAlgorithms() replace it, since those narrow a
security boundary rather than add to a list of extras.
How strictly RFC 7636 PKCE is enforced on the authorization code flow.
Disabledcode_challenge is sent, and none is sent back at token exchange.Optionalcode_challenge on every redirect. If the verifier is missing by completion (evicted from cache, TTL expired, or mismatched configs), proceeds without one and lets the token endpoint decide.RequiredOptional, but a missing verifier at completion fails closed with AuthenticationFailedException before the token endpoint is ever contacted.clientSecret) has nothing else proving it is who it
claims to be - RFC 9700 treats PKCE as effectively mandatory for that client class.
This library does not force it on; deciding that is the application's job.
Which OpenID Connect Core 1.0 §9 Client Authentication method the library uses for a
confidential client. Has no effect on a public client - there is no secret to authenticate
with under either method, so that case always identifies via a bare client_id
in the request body.
BasicPostAuthorization header.client_secret_jwt and private_key_jwt are not implemented yet - both need a signed JWT assertion.
Interactive login: the authorization code flow (Clever, Google, Azure AD) and the implicit flow (e.g. LTI 1.3). Building a redirect never emits a response itself - it returns the URL and persists state/nonce as a side effect; the caller decides how to redirect.
Starts an authorization code flow attempt and returns the URL to send the user-agent to.
throws AuthorizationStateExceptionthrows ProviderDiscoveryExceptionExchanges the callback's authorization code for tokens, verifies the returned ID token's signature and claims, and returns the result.
throws AuthenticationFailedExceptionthrows ProviderDiscoveryExceptionSame as buildAuthorizationCodeRedirect(), but for the implicit flow (response_type=id_token).
throws AuthorizationStateExceptionthrows ProviderDiscoveryExceptionVerifies the ID token returned directly on the callback fragment/params. Requires at_hash when an access token accompanies it (OpenID Connect Core 1.0 §3.2.2.10).
throws AuthenticationFailedExceptionthrows ProviderDiscoveryException
Extends AuthorizationFlowClientInterface
rather than standing alone, since nothing fetches UserInfo without having done the
authorization flow first. An integration that never fetches UserInfo (e.g. LTI) can
type-hint the narrower interface instead.
$expectedSubject must be the sub claim from the authenticated ID
token (AuthenticationResult::$claims->get('sub')) - OpenID Connect Core 1.0
§5.3.2 requires the UserInfo response's sub to be verified against it, to guard
against token substitution. When the response is a signed JWT, its iss and
aud are also validated; those two checks do not apply to a plain JSON response.
throws ProviderDiscoveryExceptionthrows UserInfoRequestExceptionNon-interactive token acquisition via the client credentials grant - e.g. a service-to-service integration that needs its own access token without a user in the loop.
$extraParams passes provider-specific extensions straight through on the
request body - not for anything this library already models explicitly. A string value is
sent as-is (a single audience, or a provider's space-separated convention); a
list value is sent as that key repeated bare (resource=a&resource=b, the
RFC 8707 convention for resource).
throws ProviderDiscoveryExceptionthrows TokenRequestException
Stands alone rather than extending AuthorizationFlowClientInterface: redeeming
a refresh token does not require having just completed an interactive flow in the same
process - a background job holding a refresh token loaded from a database has no
state/nonce, nothing this method needs beyond the refresh token and the original ID token's
claims to validate a new one against (OpenID Connect Core 1.0 §12.2).
$originalIdToken and $originalClaims are the ID token and claims
from the authentication this refresh token came from
(AuthenticationResult::$idToken / ::$claims). The refresh response
might not contain a new id_token at all - when it does not, the returned
AuthenticationResult carries the original ID token and claims forward
unchanged, alongside the new access/refresh tokens. When it does, the new ID token's
iss, sub, and aud are validated against the original's,
auth_time (if present) must still reflect the original authentication, and
nonce (if present) must match the original's.
refresh_token in the response is exactly what the application must
persist going forward - do not keep reusing the old one after a rotation.
throws AuthenticationFailedExceptionthrows ProviderDiscoveryExceptionthrows TokenRequestException
The object returned by OpenIDConnectClientFactory::make().
Implements every capability interface above -
TokenGrantClientInterface,
UserInfoClientInterface (which itself extends
AuthorizationFlowClientInterface), and
RefreshTokenClientInterface. Construct it only via
the factory or from test code - never with new directly in application code.
A consuming application typically type-hints the narrowest interface it actually needs (for
example, AuthorizationFlowClientInterface for an integration that never fetches
UserInfo) rather than this concrete class, so a test double such as
MockOpenIDConnectClient can stand in for it.
A URL to send the user-agent to next - a login redirect. The caller decides how to issue the redirect; this library never emits a response itself.
What a provider sent back to the redirect URL - an authorization code (code flow), an ID token (implicit flow), or an error - parsed from a plain params array instead of reading superglobals directly.
errorSummary() returns a single ready-to-log string combining error and error_description when present, or null when there is no error. error/error_description are truncated to 255 characters - the callback endpoint is public and unauthenticated, so these reach this class straight from the query string with no prior validation.
$response = new IncomingAuthorizationResponse($_GET);
if ($response->hasError()) {
log_error($response->errorSummary());
}
The outcome of completing an authorization code or implicit flow, or of refreshing one.
$expiresIn is the access token's own lifetime in seconds (RFC 6749 §5.1's
expires_in) - not the ID token's exp claim; two unrelated values,
for two unrelated tokens. A caller intending to hold onto the access token across requests
should convert it to an absolute timestamp at the moment it is received
(time() + $expiresIn), not store the relative seconds - those decay the instant
time passes.
A token endpoint response - shared by the authorization code exchange and the client credentials grant.
Constructed internally from the decoded JSON token endpoint response; a malformed response (missing or wrongly-typed access_token) throws TokenRequestException. Other malformed fields are logged and ignored rather than failing the whole response.
A bag of decoded claims - from a verified ID token or a UserInfo response. One shape for both.
$sub = $result->claims->get('sub');
$email = $result->claims->get('email', default: null);
The seam every collaborator talks through instead of calling curl directly. Implement this
to plug in an existing HTTP client instead of CurlHttpFetcher.
TLS verification is not a per-request concern: an implementation talking to real sockets is
expected to always verify certificates and hostnames, full stop. $headers maps
header name to value - no raw "Name: value" formatting.
throws HttpTransportException when the request cannot be completed at all (connection failure, timeout).
The library's own HttpFetcherInterface
implementation, and the default used by OpenIDConnectClientFactory.
Reuses a single curl handle across calls, never follows redirects (several calls here carry
an Authorization header), and bounds the response body by
$maxResponseBytes regardless of connection speed.
$disableTlsVerificationForLocalDevelopmentOnly outside local
development. There is no per-request way to disable TLS verification - only this
constructor argument, decided once for the instance's whole lifetime, with a name that
cannot be mistaken for a normal setting. Every single request made while it is active logs
an alert-level diagnostic, because for as long as it is on, every request this
instance makes is actively unauthenticated - including ones carrying bearer credentials.
The default clock for OpenIDConnectClientFactory - wraps the system clock via
PSR-20's ClockInterface, so tests can inject a fixed one instead.
A hand-written fake for a consuming application's own controller tests - no network, no
cache, no real cryptography. Every canned result is a public property so a test can override
just the ones it cares about before exercising the controller. Defaults to a successful
outcome everywhere; set the relevant *Exception property to simulate a failure
instead.
$client = new MockOpenIDConnectClient(
authenticationException: new AuthenticationFailedException('expired state'),
);
// Exercise a controller that type-hints AuthorizationFlowClientInterface
// and expect it to handle the failure path.
Every exception this library throws extends OpenIDConnectException (itself a plain \RuntimeException), so a caller that only wants a single catch clause for "something in this library failed" always has one available.
| Exception | Thrown when |
|---|---|
OpenIDConnectException | Base class for every exception below. Never thrown directly. |
AuthenticationFailedException | A callback carries a provider error, an invalid or expired state/nonce, or an ID token that fails signature or claims validation. |
AuthorizationStateException | AuthorizationStateStore cannot persist a new authorization attempt because the underlying cache write itself failed - distinct from a clean miss on lookup (a forged, expired, or already-consumed state), which is a normal outcome, not a failure. |
HttpTransportException | The HTTP transport itself fails (connection failure, timeout), below the level of any specific OIDC operation. Rewrapped by callers into whichever domain exception fits. |
ProviderDiscoveryException | A provider's .well-known/openid-configuration or JWKS document cannot be fetched, parsed, or is missing a required endpoint. |
TokenRequestException | A token, introspection, revocation, or dynamic client registration request fails or returns an unusable response. Carries getHttpStatus() and getRawBody(), both null for a transport failure that never reached the server. |
UserInfoRequestException | The UserInfo endpoint cannot be reached or returns an unusable response. |
$exception->getMessage().