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.

Index

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.

final 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

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

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

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

ExceptionThrown when
OpenIDConnectExceptionBase class for every exception below. Never thrown directly.
AuthenticationFailedExceptionA callback carries a provider error, an invalid or expired state/nonce, or an ID token that fails signature or claims validation.
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.
HttpTransportExceptionThe HTTP transport itself fails (connection failure, timeout), below the level of any specific OIDC operation. Rewrapped by callers into whichever domain exception fits.
ProviderDiscoveryExceptionA provider's .well-known/openid-configuration or JWKS document cannot be fetched, parsed, or is missing a required endpoint.
TokenRequestExceptionA 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.
UserInfoRequestExceptionThe UserInfo endpoint cannot be reached or returns an unusable response.
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