henderjon/php-oidc
Index GitHub

Package Oidc

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.

Quick Start Browse the Index

Installation

composer require henderjon/php-oidc

▾ Quick Start

A minimal authorization code flow: build a redirect, then complete it on the callback route.

<?php

use Oidc\OpenIDConnectClientConfig;
use Oidc\OpenIDConnectClientFactory;
use Oidc\IncomingAuthorizationResponse;

$config = new OpenIDConnectClientConfig(
    clientId: 'my-client-id',
    clientSecret: 'my-client-secret',
    redirectUrl: 'https://app.example.com/oidc/callback',
    providerUrl: 'https://idp.example.com',
);

$client = (new OpenIDConnectClientFactory())->make($psr16Cache);

// GET /oidc/login
$redirect = $client->buildAuthorizationCodeRedirect($config);
header("Location: {$redirect->url}");

// GET /oidc/callback
$response = new IncomingAuthorizationResponse($_GET);
$result   = $client->completeAuthorizationCodeFlow($config, $response);

$subject = $result->claims->get('sub');

See the example/ directory for runnable scripts covering PKCE, host and algorithm allowlisting, claim and audience validation, UserInfo, and refresh tokens - or example/pseudo/ for a shorter, non-runnable version of each one, shaped like a real app's login route, callback route, or background job instead of a mock harness. That directory also has two exception-handling examples with no runnable counterpart - one catch block per exception type, and the same failures narrowed from a single OpenIDConnectException catch instead.

Index

Actors

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.

AcronymStands forIn practice
RPRelying PartyYour application - the role this library plays for you.
OPOpenID ProviderThe identity provider you registered with (Google, Okta, Auth0, Azure AD, ...) - what providerUrl/issuer points at.
ASAuthorization ServerThe generic OAuth name for that same server, used when no identity is involved at all (e.g. the Client Credentials grant).
RSResource ServerWhatever API actually accepts the access token this library gets back - often the OP itself, sometimes a separate API.
UAUser AgentThe end user's browser, carrying the redirect back and forth for every interactive flow.
ROResource OwnerThe 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.

type OpenIDConnectClientFactory

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.

class OpenIDConnectClientFactory { public function __construct( HttpFetcherInterface $httpFetcher = new CurlHttpFetcher, Psr\Clock\ClockInterface $clock = new CurrentClock, Psr\Log\LoggerInterface $logger = new NullLogger, ) public function make( Psr\SimpleCache\CacheInterface $stateCache, string $cacheKeySuffix = "", ): OpenIDConnectClient }

make

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.

type OpenIDConnectClientConfig

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).

No TLS-verification toggle. Every network call this library makes always verifies certificates and hostnames. The one narrow, loudly-logged exception for local development lives on CurlHttpFetcher's own constructor instead - decided once per fetcher instance, never as a config value that could travel anywhere this config does.
final class OpenIDConnectClientConfig { public function __construct( string $clientId, string $clientSecret, string $redirectUrl, ?string $providerUrl = null, ?string $issuer = null, array $scopes = [], array|string|null $audience = null, array $endpointOverrides = [], array $extraAuthParams = [], PkceMode $pkce = PkceMode::Disabled, bool $allowInsecureSchemes = false, ?array $allowedHosts = null, array $allowedAlgorithms = ['RS256'], ?int $maxTokenLifetimeSeconds = null, bool $allowUntrustedAudiences = false, bool $allowAnyHost = false, ClientAuthMethod $clientAuthMethod = ClientAuthMethod::Basic, ) }

Parameters

ParameterTypeDefaultNotes
clientIdstringrequiredSent as client_id on every request.
clientSecretstringrequiredEmpty string marks a public client - PKCE and client authentication both change behavior accordingly.
redirectUrlstringrequiredMust match the redirect_uri registered with the provider.
providerUrl?stringnullBase URL for .well-known/openid-configuration discovery, and the default issuer when that is not set.
issuer?stringnullExpected iss claim value. Falls back to providerUrl.
scopeslist<string>[]Merged with the always-included openid scope.
audiencelist<string>|string|nullnullExpected aud value(s) when it must differ from clientId. Also doubles as the full trusted set unless allowUntrustedAudiences opts out.
endpointOverridesarray<string,string>[]Known endpoint values (e.g. token_endpoint) that skip discovery for that value.
extraAuthParamsarray<string,string>[]Merged into the authorization request.
pkcePkceModeDisabledHow strictly RFC 7636 PKCE is enforced on the authorization code flow.
allowInsecureSchemesboolfalseAllows http:// endpoints. TLS certificate/hostname verification itself is never affected by this - see CurlHttpFetcher.
allowedHosts?list<string>nullBare hostnames every resolved endpoint must match. Null falls back to the host of issuer/providerUrl, unless allowAnyHost is set.
allowedAlgorithmslist<string>['RS256']ID token signing algorithms accepted. HS* is always rejected outright for a public client, regardless of this list.
maxTokenLifetimeSeconds?intnullOptional cap on exp - iat, independent of clock-skew leeway.
allowUntrustedAudiencesboolfalseOpts out of rejecting aud values outside the trusted set - keeps only the "expected value present" half of the check.
allowAnyHostboolfalseOpts out of the default provider-host restriction when allowedHosts is null - for a provider that splits endpoints across hosts.
clientAuthMethodClientAuthMethodBasicWhich OpenID Connect Core 1.0 §9 method to use for a confidential client. No effect on a public client.

Withers

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.

$config ->withScopes(['email']) // merges ->withAudience('https://api.example.com') // replaces ->withPkce(PkceMode::Required) ->withClientAuthMethod(ClientAuthMethod::Post);

↑ back to index

Logging

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:

LevelCallsFires when...
debug28The happy path of nearly every collaborator - what was sent, what came back, what was decided. The bulk of this library's log volume.
warning3A 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).
info1An 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.
error68Every 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.
alert3Reserved 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:

Classerror callssecurity_relevant: true
IdTokenVerifier204 - the "none" algorithm, a failed signature, an at_hash mismatch, a JWKS key type mismatch
ClaimsValidator181 - a nonce mismatch
OpenIDConnectClient120
ProviderMetadataResolver80
TokenEndpointClient40
CurlHttpFetcher30
AuthorizationStateStore20
TokenResult10

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.

KeyTypeMeaning
state?stringThe 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_idstringThe configured client_id - logged when a public client builds an authorization redirect with PKCE disabled.
error, error_description?stringThe error/error_description values a provider returned on the callback query string.
urlstringThe URL being fetched, or that failed the configured URL policy, or for which TLS verification was disabled.
endpointstringThe resolved token endpoint URL being contacted.
endpoint_keystringThe provider-metadata key being resolved (e.g. token_endpoint, jwks_uri).
jwks_uristringThe JWKS endpoint URL.
exceptionThrowableThe underlying caught exception - a network error, a JSON decode failure, a signature verification failure.
http_status?intHTTP 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?stringThe 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?stringThe error field from the token endpoint's JSON error body, when present.
max_response_bytesintThe configured response-size cap that was exceeded.
kid?stringThe ID token header's kid value being looked up in the JWKS.
available_kidslist<string>The kid values actually present in the fetched JWKS.
key_count, max_keysintNumber of keys in a fetched JWKS document vs. the configured maximum.
algstringThe ID token's alg header value.
allowed_algorithmslist<string>The configured algorithm allowlist the token's alg didn't match.
expected_kty, actual_ktystringThe JWK key type expected for the token's algorithm vs. the key type actually found.
expected_at_hash, actual_at_hashstringThe 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.
headerarrayThe ID token's decoded JOSE header.
segment_countintNumber of dot-separated segments found, where a JWT must have exactly three.
length, maxintAn oversized value's actual length vs. the configured or hard-coded maximum (ID token byte length, sub claim length).
exp, iatmixedThe raw exp/iat claim values that failed validation.
lifetime_seconds, max_lifetime_secondsintComputed token lifetime (exp - iat) vs. the configured maxTokenLifetimeSeconds.
expected, actualmixedGeneric 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.
audmixedThe raw aud claim value, when it contains a malformed (non-string) entry.
malformedlist<mixed>The non-string entries filtered out of a malformed aud claim.
untrustedlist<string>aud values present on the token but outside the configured trusted set.
invalid_fieldslist<string>Names of token-response fields that were missing or the wrong type.
invalid_field_valuesarray<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, keysstring, ?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_relevantboolPresent 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."

↑ back to index

type LogLevelFilterLogger

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.

final class LogLevelFilterLogger extends Psr\Log\AbstractLogger { public function __construct( Psr\Log\LoggerInterface $logger, array $levels, bool $allow = true, ) public static function all( Psr\Log\LoggerInterface $logger ): self }

$levels and $allow together express four distinct patterns, not just one allow-list:

PatternUsageMeaning
nonenew LogLevelFilterLogger($logger, [])Nothing passes. $levels is empty and $allow defaults true, so no level can ever satisfy the membership test.
none, exceptnew LogLevelFilterLogger($logger, [ LogLevel::DEBUG ])The ordinary allow-list case: nothing passes except the levels named.
allnew LogLevelFilterLogger($logger, [], allow: false)
Sugar: LogLevelFilterLogger::all($logger)
Everything passes, including a level neither PSR-3 nor the caller has defined yet.
all, exceptnew 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.

↑ back to index

enum PkceMode

How strictly RFC 7636 PKCE is enforced on the authorization code flow.

enum PkceMode { case Disabled; case Optional; case Required; }
Disabled
Never generates a verifier - no code_challenge is sent, and none is sent back at token exchange.
Optional
Sends a code_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.
Required
Same as Optional, but a missing verifier at completion fails closed with AuthenticationFailedException before the token endpoint is ever contacted.
A public client (empty 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.

↑ back to index

enum ClientAuthMethod

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.

enum ClientAuthMethod { case Basic; case Post; }
Basic
HTTP Basic (RFC 6749 §2.3.1) - the spec default when no method is registered.
Post
Client credentials in the request body instead of the Authorization header.

client_secret_jwt and private_key_jwt are not implemented yet - both need a signed JWT assertion.

↑ back to index

interface AuthorizationFlowClientInterface

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.

buildAuthorizationCodeRedirect

public function buildAuthorizationCodeRedirect( OpenIDConnectClientConfig $config, ): AuthorizationRedirect

Starts an authorization code flow attempt and returns the URL to send the user-agent to.

completeAuthorizationCodeFlow

public function completeAuthorizationCodeFlow( OpenIDConnectClientConfig $config, IncomingAuthorizationResponse $response, ): AuthenticationResult

Exchanges the callback's authorization code for tokens, verifies the returned ID token's signature and claims, and returns the result.

buildImplicitFlowRedirect

public function buildImplicitFlowRedirect( OpenIDConnectClientConfig $config, ): AuthorizationRedirect

Same as buildAuthorizationCodeRedirect(), but for the implicit flow (response_type=id_token).

completeImplicitFlow

public function completeImplicitFlow( OpenIDConnectClientConfig $config, IncomingAuthorizationResponse $response, ): AuthenticationResult

Verifies 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).

↑ back to index

interface UserInfoClientInterface

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.

fetchUserInfo

public function fetchUserInfo( OpenIDConnectClientConfig $config, string $accessToken, string $expectedSubject, ): Claims

$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.

↑ back to index

interface TokenGrantClientInterface

Non-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.

requestClientCredentialsToken

public function requestClientCredentialsToken( OpenIDConnectClientConfig $config, array $scopes = [], array $extraParams = [], ): TokenResult

$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).

Client Credentials and refresh tokens are not, themselves, OIDC. Client Credentials is a plain OAuth 2.0 grant (RFC 6749 §4.4) - no end user, no ID Token, nothing OpenID Connect Core 1.0 ever defines a rule for. Refreshing is likewise an OAuth mechanism (RFC 6749 §6); OIDC only adds rules for the ID Token half of a refresh response (§12.2, see 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.

↑ back to index

interface RefreshTokenClientInterface

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).

refresh

public function refresh( OpenIDConnectClientConfig $config, string $refreshToken, string $originalIdToken, Claims $originalClaims, ): AuthenticationResult

$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.

A rotated refresh_token in the response is exactly what the application must persist going forward - do not keep reusing the old one after a rotation.

↑ back to index

type OpenIDConnectClient

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.

buildImplicitFlowRedirectWithAccessToken

public function buildImplicitFlowRedirectWithAccessToken( OpenIDConnectClientConfig $config, ): AuthorizationRedirect

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.

No Hybrid Flow support. Unlike Implicit, Hybrid (response_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.

↑ back to index

type AuthorizationRedirect

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.

final class AuthorizationRedirect { public function __construct( public readonly string $url, ) }

↑ back to index

type IncomingAuthorizationResponse

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.

final class IncomingAuthorizationResponse { public readonly ?string $code; public readonly ?string $idToken; public readonly ?string $accessToken; public readonly ?string $state; public readonly ?string $error; public readonly ?string $errorDescription; public function __construct(array $params) // $_GET or $_POST public function hasError(): bool public function errorSummary(): ?string }

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());
}

↑ back to index

type AuthenticationResult

The outcome of completing an authorization code or implicit flow, or of refreshing one.

final class AuthenticationResult { public function __construct( public readonly string $idToken, public readonly Claims $claims, public readonly ?string $accessToken = null, public readonly ?string $refreshToken = null, public readonly ?int $expiresIn = null, ) }
$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.

↑ back to index

type TokenResult

A token endpoint response - shared by the authorization code exchange and the client credentials grant.

final class TokenResult { public readonly string $accessToken; public readonly string $tokenType; // defaults to "Bearer" public readonly ?int $expiresIn; public readonly ?string $refreshToken; public readonly ?string $idToken; public readonly ?string $scope; }

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.

↑ back to index

type Claims

A bag of decoded claims - from a verified ID token or a UserInfo response. One shape for both.

final class Claims { public function __construct(\stdClass|array $claims) public function get(string $key, mixed $default = null): mixed public function has(string $key): bool public function all(): array }
$sub   = $result->claims->get('sub');
$email = $result->claims->get('email', default: null);

↑ back to index

interface HttpFetcherInterface

The seam every collaborator talks through instead of calling curl directly. Implement this to plug in an existing HTTP client instead of CurlHttpFetcher.

interface HttpFetcherInterface { public function fetch( string $url, ?string $body, array $headers = [], ): FetchResponse }

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.

↑ back to index

type CurlHttpFetcher

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.

final class CurlHttpFetcher implements HttpFetcherInterface { public function __construct( int $timeoutSeconds = 30, int $maxResponseBytes = 5 * 1024 * 1024, bool $disableTlsVerificationForLocalDevelopmentOnly = false, Psr\Log\LoggerInterface $logger = new NullLogger, ) }
Never set $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.

↑ back to index

type FetchResponse

final class FetchResponse { public function __construct( public readonly string $body, public readonly int $status, public readonly ?string $contentType = null, ) }

↑ back to index

type CurrentClock

The default clock for OpenIDConnectClientFactory - wraps the system clock via PSR-20's ClockInterface, so tests can inject a fixed one instead.

final class CurrentClock implements Psr\Clock\ClockInterface { public function now(): \DateTimeImmutable }

↑ back to index

type MockOpenIDConnectClient

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.

class MockOpenIDConnectClient implements TokenGrantClientInterface, UserInfoClientInterface, RefreshTokenClientInterface { public function __construct( public string $redirectUrl = 'https://example.com/mock-authorize', public AuthenticationResult $authenticationResult = ..., public ?AuthenticationFailedException $authenticationException = null, public TokenResult $tokenResult = ..., public Claims $userInfo = ..., ) }
$client = new MockOpenIDConnectClient(
    authenticationException: new AuthenticationFailedException('expired state'),
);

// Exercise a controller that type-hints AuthorizationFlowClientInterface
// and expect it to handle the failure path.

↑ back to index

namespace Oidc\Exceptions

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

public function getState(): ?string
Three more discrete getters exist beyond 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.

getIdToken

public function getIdToken(): ?string

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.

getHttpStatus

public function getHttpStatus(): ?int

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.

getRawBody

public function getRawBody(): ?string

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.

ExceptionThrown whenCarries
OpenIDConnectExceptionBase class for every exception below. Never thrown directly.getState()
AuthenticationFailedExceptionA callback carries a provider error, an invalid or expired state/nonce, or an ID token that fails signature or claims validation.getState(), getIdToken()
AuthorizationStateExceptionAuthorizationStateStore 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()
HttpTransportExceptionThe HTTP transport itself fails (connection failure, timeout), below the level of any specific OIDC operation. Rewrapped by callers into whichever domain exception fits.getState()
ProviderDiscoveryExceptionA provider's .well-known/openid-configuration or JWKS document cannot be fetched, parsed, or is missing a required endpoint.getState()
TokenRequestExceptionA token, introspection, revocation, or dynamic client registration request fails or returns an unusable response.getState(), getHttpStatus(), getRawBody()
UserInfoRequestExceptionThe UserInfo endpoint cannot be reached or returns an unusable response. getState() is always null here - see above.getState(), getHttpStatus(), getRawBody()
Exception messages in this library are safe to log but are not written for end users. Present a generic, application-chosen message to a user instead of $exception->getMessage().

↑ back to index