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
The spec-standard names for who's who in an OIDC/OAuth exchange, and what each one actually is in practice. Here because the author(s) often forgets what all the jargon means.
| Acronym | Stands for | In practice |
|---|---|---|
RP | Relying Party | Your application - the role this library plays for you. |
OP | OpenID Provider | The identity provider you registered with (Google, Okta, Auth0, Azure AD, ...) - what providerUrl/issuer points at. |
AS | Authorization Server | The generic OAuth name for that same server, used when no identity is involved at all (e.g. the Client Credentials grant). |
RS | Resource Server | Whatever API actually accepts the access token this library gets back - often the OP itself, sometimes a separate API. |
UA | User Agent | The end user's browser, carrying the redirect back and forth for every interactive flow. |
RO | Resource Owner | The end user - OAuth's generic term for whoever is actually granting access. |
| End-User | (OIDC's own term) | The same person as the Resource Owner - OIDC's identity-flavored name for them, used throughout the spec text quoted in this document. |
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.
Deliberately not final, unlike most classes in this library - a consumer that
wants a different wiring (an extra collaborator, a different assembly order) can subclass
it rather than reimplementing make() from scratch.
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.
This library's logging is deliberately, sometimes irritatingly, noisy at the
debug level - most collaborators trace their own happy path in addition to
every failure, by design, so a request can be followed step by step without anything
having failed at all. That is a lot of log volume to hand a caller's own logger
unfiltered. LogLevelFilterLogger exists
specifically to mediate it - wrap your own logger in one to receive only the levels you
actually want, rather than reconfiguring or replacing your logger just to cope with the
noise.
This library assumes the logger passed in never throws. Nothing here catches an
exception a logger raises - it propagates like any other exception and interrupts
whichever call was in progress, including one already past the point of no return. In
AuthorizationStateStore::consume() specifically, the cache entry backing an
attempt is deleted before that method logs anything, so a throwing logger there aborts an
otherwise-successful login and leaves the attempt unrecoverable - the same callback
cannot be retried, since the entry it depended on is already gone. A logger that might
throw (a remote log shipper timing out, a full disk) should be wrapped in one that
catches its own exceptions before being passed to this library, if that failure mode
matters to the caller.
The levels this library actually emits, for choosing what to pass into
LogLevelFilterLogger's $levels:
| Level | Calls | Fires when... |
|---|---|---|
debug | 28 | The happy path of nearly every collaborator - what was sent, what came back, what was decided. The bulk of this library's log volume. |
warning | 3 | A loose config choice actually producing a happy path at runtime, not merely configured as possible - an Optional PKCE flow completing with no code_verifier, or allowUntrustedAudiences silently dropping a malformed aud entry (ClaimsValidator) - or a runtime event worth a human's attention that is not itself a configuration choice - a callback whose state matched no pending authorization attempt at all (AuthorizationStateStore; see that method's own docblock for the several distinct things a miss there could mean). |
info | 1 | An outcome PSR-16's own cache interface cannot distinguish from a real failure - AuthorizationStateStore::consume(), when the cache backend reports a delete as possibly not having cleared the entry. |
error | 68 | Every validation, fetch, or parse failure across every collaborator - by far the largest share of non-debug volume. Always paired with the exception this library is about to throw. Every call at this level also carries a security_relevant boolean - see below. |
alert | 3 | Reserved for a configuration choice worth a developer's own review, not a runtime event: TLS verification actively disabled for a request (CurlHttpFetcher), an untrusted aud value let through by explicit config opt-out (ClaimsValidator), or a public client building an authorization redirect with PKCE disabled (OpenIDConnectClient). |
notice, critical, and emergency are never used -
there is nothing to opt into at those levels. Counts are call sites in src/,
not runtime volume - one debug call on a hot path can still outnumber every
error call combined in an actual log stream, which is exactly why
LogLevelFilterLogger exists.
error is by far the largest category, so it is worth breaking down further.
The table below groups its 68 call sites by the collaborator that raises them, alongside
how many of each are security_relevant: true - see below for what that means:
| Class | error calls | security_relevant: true |
|---|---|---|
IdTokenVerifier | 20 | 4 - the "none" algorithm, a failed signature, an at_hash mismatch, a JWKS key type mismatch |
ClaimsValidator | 18 | 1 - a nonce mismatch |
OpenIDConnectClient | 12 | 0 |
ProviderMetadataResolver | 8 | 0 |
TokenEndpointClient | 4 | 0 |
CurlHttpFetcher | 3 | 0 |
AuthorizationStateStore | 2 | 0 |
TokenResult | 1 | 0 |
The concentration is not an accident: IdTokenVerifier and
ClaimsValidator are the two collaborators that sit closest to the token
itself - decoding it, checking its signature, checking its claims - so nearly every shape
an attacker could actually manipulate lands in one of those two classes. Every other
collaborator's failures are further from the token's own content: transport, discovery,
endpoint shape - which is why none of their error calls are currently marked
security_relevant: true. That is a statement about where a curated,
high-confidence indicator was found so far, not a claim that those classes can never see
one - see "How this could evolve" below.
Every log call passes a context array alongside the message. Which keys show
up varies by call site - each one logs whatever is actually diagnostic for its own failure,
not a single fixed envelope - but related call sites agree on the same names and shapes for
the same kind of failure, and a caller writing a log processor or a PSR-3 formatter that
pulls specific fields out of context can rely on these names wherever they
appear. state is by far the most common of them, showing up on nearly every
failure that happens within an authorization flow - it is the closest thing to a
transaction ID this library logs, letting every log line from one redirect/callback pair be
correlated together across every class that touched that flow. Every exception this
library throws carries the same state value too, via
getState() - see Exceptions - so a caller catching
one directly gets the same correlation id without having to opt into a logger at all.
Every error-level call also carries a security_relevant boolean,
orthogonal to the level itself - it does not change what error, warning,
or alert mean, only whether this particular failure is one this library can call a
likely attack indicator with real confidence. It is true on a small, deliberately
narrow set of call sites where the failure is essentially unexplainable except as tampering or
forgery: an ID token declaring the "none" algorithm, a signature that fails
verification, an at_hash that does not match the access token, a JWKS key type that
does not match the token's algorithm, and a nonce that does not match the one this
client generated. Every other error call - the other 63, as of this writing - carries
security_relevant: false. That does not mean "confirmed benign" - a
misconfigured provider, a network blip, and a genuine attack that happens not to fit one of the
five curated shapes above all log false identically. It means only "not in this small
curated set," not "safe to ignore." A caller wanting a lightweight tamper/attack signal can filter
on this key without having to enumerate message strings or guess at severity; a caller wanting
every failure still gets every failure, since the key never suppresses anything, it only adds a
flag alongside.
Why a flat boolean, not a graded scale. An earlier draft of this feature
considered a confidence gradient - a string enum, or an integer scale from "not an issue"
through "ambiguous" to "security issue" - to capture the calls that sit between the five
curated true sites and the rest (an audience mismatch, say, is not nothing,
but is far more often a client misconfiguration than an attack). That was deliberately
rejected: grading every one of the 63 false calls onto a scale would mean
assigning each one a confidence level with no real usage data to justify a specific cut
point, which just relocates the same judgment call into more categories without resolving
it. A flat boolean, applied only where the evidence is genuinely unambiguous, says exactly
as much as this library can currently stand behind, and no more.
How this could evolve. The true set is deliberately small
today because it is curated by inspection, not by data - five call sites a maintainer
could argue for individually, not five call sites production log volume had already
picked out. The shape most likely to justify a real change here is usage data itself:
if a caller aggregating this key across many deployments found that a specific
false call site (the audience-mismatch or issuer-mismatch shapes in
ClaimsValidator are the most plausible candidates, given how directly they
touch token content) correlated with confirmed attacks far more often than the rest of
the false population, that would be a concrete argument for moving it to
true - or, symmetrically, for moving one of today's five back to
false if it turned out to fire routinely for a benign reason nobody had
anticipated (clock skew producing spurious signature failures at a provider that rotates
keys unusually fast, say). A category dimension - not a confidence grade, but a label like
"replay", "forgery", or "algorithm-confusion" on the
five (and any future) true sites - is a more likely next step than a graded
scale, since it adds information without asking this library to rank attacks it has no
real basis for ranking. Any such change belongs on top of the flat boolean, which callers
already filter on, rather than replacing it - a caller that only ever checked
security_relevant === true should keep working exactly as before.
The three classes that fetch and validate an HTTP response - TokenEndpointClient,
ProviderMetadataResolver, and IdTokenVerifier::fetchJwks() - log the
same shape for every failure along that path: a URL identifier, http_status,
content_type, and state, checking status before content type before
JSON validity. http_status/content_type are logged as null
rather than omitted when the failure happened before a response was ever received. A log query
keyed on these fields reads the same shape regardless of which of the three fetches actually
failed.
| Key | Type | Meaning |
|---|---|---|
state | ?string | The authorization flow's state value, tying the log line to one specific redirect/callback pair. Null when a callback omitted it entirely. Truncated by AuthorizationStateStore before logging. |
client_id | string | The configured client_id - logged when a public client builds an authorization redirect with PKCE disabled. |
error, error_description | ?string | The error/error_description values a provider returned on the callback query string. |
url | string | The URL being fetched, or that failed the configured URL policy, or for which TLS verification was disabled. |
endpoint | string | The resolved token endpoint URL being contacted. |
endpoint_key | string | The provider-metadata key being resolved (e.g. token_endpoint, jwks_uri). |
jwks_uri | string | The JWKS endpoint URL. |
exception | Throwable | The underlying caught exception - a network error, a JSON decode failure, a signature verification failure. |
http_status | ?int | HTTP status code of the response, logged on every fetch failure against the token endpoint, provider discovery, or JWKS endpoint regardless of which check actually failed. Null when no response was ever received (a transport failure). |
content_type | ?string | The response's Content-Type header, logged alongside http_status on every fetch failure against those same three endpoints, not only when the content type itself was the problem. Null when no response was ever received (a transport failure). |
provider_error | ?string | The error field from the token endpoint's JSON error body, when present. |
max_response_bytes | int | The configured response-size cap that was exceeded. |
kid | ?string | The ID token header's kid value being looked up in the JWKS. |
available_kids | list<string> | The kid values actually present in the fetched JWKS. |
key_count, max_keys | int | Number of keys in a fetched JWKS document vs. the configured maximum. |
alg | string | The ID token's alg header value. |
allowed_algorithms | list<string> | The configured algorithm allowlist the token's alg didn't match. |
expected_kty, actual_kty | string | The JWK key type expected for the token's algorithm vs. the key type actually found. |
expected_at_hash, actual_at_hash | string | The at_hash this client computed from the access token vs. the value the ID token actually carried. Safe to log - both are one-way digests, not the access token itself. |
header | array | The ID token's decoded JOSE header. |
segment_count | int | Number of dot-separated segments found, where a JWT must have exactly three. |
length, max | int | An oversized value's actual length vs. the configured or hard-coded maximum (ID token byte length, sub claim length). |
exp, iat | mixed | The raw exp/iat claim values that failed validation. |
lifetime_seconds, max_lifetime_seconds | int | Computed token lifetime (exp - iat) vs. the configured maxTokenLifetimeSeconds. |
expected, actual | mixed | Generic pair used for every claim-mismatch check (issuer, audience, nonce, subject, auth_time, discovered issuer vs. providerUrl) - what this client expected vs. what was actually present. |
aud | mixed | The raw aud claim value, when it contains a malformed (non-string) entry. |
malformed | list<mixed> | The non-string entries filtered out of a malformed aud claim. |
untrusted | list<string> | aud values present on the token but outside the configured trusted set. |
invalid_fields | list<string> | Names of token-response fields that were missing or the wrong type. |
invalid_field_values | array<string,mixed> | Each name in invalid_fields mapped to what it actually contained (null when entirely missing). Safe to log verbatim - a field only lands here for having the wrong shape, never for holding a validly-typed value, so nothing here can be a real access/refresh/id token. |
type, keys | string, ?list<string> | The actual PHP type (get_debug_type()) and, if it was an array, the keys of a cached authorization flow entry that didn't have the expected shape. |
security_relevant | bool | Present on every error-level call. true only on the small curated set of likely attack indicators described above; false everywhere else, which means "not in that set," not "confirmed benign." |
A PSR-3 decorator that forwards a log call to the wrapped logger only when its level is
in an explicit allow-list, dropping every other level outright. A conventional "minimum
severity" filter cannot express this on its own: set at debug, it lets
everything through, since debug is already PSR-3's lowest severity - there
is no threshold that means "debug only." Wrap your own logger in one of these to route,
say, only this library's debug-level tracing somewhere separate (a different file, a
different verbosity, temporarily on during troubleshooting) without also receiving, or
reconfiguring, everything else your logger already handles.
$levels and $allow together express four distinct patterns,
not just one allow-list:
| Pattern | Usage | Meaning |
|---|---|---|
| none | new LogLevelFilterLogger($logger, []) | Nothing passes. $levels is empty and $allow defaults true, so no level can ever satisfy the membership test. |
| none, except | new LogLevelFilterLogger($logger, [ LogLevel::DEBUG ]) | The ordinary allow-list case: nothing passes except the levels named. |
| all | new LogLevelFilterLogger($logger, [], allow: false)Sugar: LogLevelFilterLogger::all($logger) | Everything passes, including a level neither PSR-3 nor the caller has defined yet. |
| all, except | new LogLevelFilterLogger($logger, [ LogLevel::DEBUG ], allow: false) | The deny-list case: everything passes except the levels named, including one that does not exist yet. The mirror image of "none, except." |
The default, opt-in posture - "none, except" - is the same reason this class exists at
all (see Logging): the default stance toward this library's noise
is to filter all of it, not to let everything through until told otherwise. Pass an
empty array with the default $allow and every log call is dropped,
including a level a caller might assume always gets through, such as error
- nothing passes unless its exact level is named.
$levels is a discrete set, not a range, in either the allow-list or the
deny-list case - any combination is valid, including combinations no linear severity
ordering could express together (debug and critical alone,
with everything between them excluded). This class never validates a level against
PSR-3's own eight constants (Psr\Log\LogLevel) - it is a plain string
comparison, so $levels can equally well hold PSR-3's own levels, a caller's
own custom level, or a mix of both. Pass it wherever this library expects a
Psr\Log\LoggerInterface, e.g. OpenIDConnectClientFactory:
$logger = new LogLevelFilterLogger($yourAppLogger, [ LogLevel::DEBUG ]); $factory = new OpenIDConnectClientFactory(logger: $logger);
"Every level, without having to name any of them" - the one case not reachable just by
listing levels, custom or not, since it does not require knowing in advance which levels
might ever be logged - is technically reachable directly as an empty deny-list
(new LogLevelFilterLogger($logger, [], allow: false)), but that is not
obviously what it means at a glance. Use the all() named constructor
instead:
$logger = LogLevelFilterLogger::all($yourAppLogger);
The default, $allow: true, fails closed - a level nobody named
gets dropped, including one this library or a wrapped logger adds later.
$allow: false fails open - a level nobody named gets forwarded
instead. That is a deliberate trade for the specific case it exists for (skip this
library's debug tracing, forward every other level unconditionally), not a
reason to prefer it generally over naming an allow-list directly. It is also why
$levels is not called $allowedLevels or
$excludedLevels: it holds an allow-list when $allow is true
and a deny-list when it is false, and a name tied to one mode would be wrong in the
other.
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).
RefreshTokenClientInterface).
Both are supported here anyway because they're exactly what an app already using OIDC for
login typically needs elsewhere too - a background service calling its own API, or keeping
a session alive past its access token's lifetime - not because either belongs to the spec
this library is named after.
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.
One method below exists only here, not on any interface - keep this concrete type in scope where you specifically need it; everything else should still type-hint narrower.
Same as buildImplicitFlowRedirect(), but requests
response_type=id_token token instead of the bare
id_token - an access token issued directly from the authorization endpoint,
no token endpoint round trip. Deliberately not on
AuthorizationFlowClientInterface: RFC 9700 recommends against Implicit
entirely, and the one case this variant used to serve - a browser-only app needing an
access token with no backend to exchange a code - is better served today by Authorization
Code plus PKCE, which never puts a token in a redirect URL at all. Reach for this only
for the rare case that still needs it (e.g. RP conformance certification).
completeImplicitFlow() needs no equivalent variant - it already validates
at_hash and returns the access token whenever the provider includes one,
regardless of which method built the original redirect.
throws AuthorizationStateExceptionthrows ProviderDiscoveryExceptionresponse_type combinations such as code id_token) isn't
supported at all. It inherits Implicit's URL-fragment exposure for whichever tokens come
back immediately, while needing a whole mechanism this library doesn't otherwise have: an
ID Token returned alongside a code must carry a c_hash claim binding it to
that code (OpenID Connect Core 1.0 §3.3.2.11), the same way at_hash binds one
to an access token - and nothing here validates c_hash. Implicit's
at_hash machinery was already required for id_token token, and
the RP conformance suite specifically drives it; Hybrid RP is its own separate, optional
certification profile with no equivalent forcing function, on top of being rarer in
practice than Implicit itself.
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.
OpenIDConnectException also carries getState() - the authorization
flow's state value the failure happened within, when one was available. This is
the same correlation id described in the Logging section above, now
reachable without opting into a logger at all: catch the exception and call
getState() directly. It's null whenever there was no flow to correlate with yet
(building a redirect before one exists) or none in scope at all (UserInfoRequestException
specifically - fetchUserInfo() isn't scoped to a stored flow the way the
authorization/token/JWKS paths are).
getState() - getIdToken(),
getHttpStatus(), and getRawBody() - each scoped to the specific
exception(s) that actually have something to attach. See each one's own section just below,
and the table's "Carries" column for which exception(s) carry which.
Only on AuthenticationFailedException. The raw ID token this failure happened
against, when one was actually obtained - null for a failure with no token yet in hand (a
state/nonce/PKCE mismatch, a missing authorization code, a provider-returned error, or a
token response missing id_token entirely - that last case IS the failure, so
there is nothing to attach). Signature/claims validation is fail-fast, so the log for
whichever check trips first never shows the others; decoding this (it need not be
signature-valid to decode) is the only way to see every claim the token carried at once.
On TokenRequestException and UserInfoRequestException. The
response's HTTP status, when a response was actually received - null for a transport
failure that never reached the server.
On TokenRequestException and UserInfoRequestException. The
response's raw body, when a response was actually received - null under the same condition
as getHttpStatus() above, and always the original, undecoded response text even
when that body turns out not to be valid JSON. For a signed (application/jwt)
UserInfo response that failed further verification or claims validation, this is the JWT
itself, not a JSON body.
| Exception | Thrown when | Carries |
|---|---|---|
OpenIDConnectException | Base class for every exception below. Never thrown directly. | getState() |
AuthenticationFailedException | A callback carries a provider error, an invalid or expired state/nonce, or an ID token that fails signature or claims validation. | getState(), getIdToken() |
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. | getState() |
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. | getState() |
ProviderDiscoveryException | A provider's .well-known/openid-configuration or JWKS document cannot be fetched, parsed, or is missing a required endpoint. | getState() |
TokenRequestException | A token, introspection, revocation, or dynamic client registration request fails or returns an unusable response. | getState(), getHttpStatus(), getRawBody() |
UserInfoRequestException | The UserInfo endpoint cannot be reached or returns an unusable response. getState() is always null here - see above. | getState(), getHttpStatus(), getRawBody() |
$exception->getMessage().