hvatum / oauth2-openid-connect-client
Generic OpenID Connect Provider for The PHP League OAuth2 Client with PAR, DPoP, and private_key_jwt support
Package info
github.com/hvatum/oauth2-openid-connect-client
pkg:composer/hvatum/oauth2-openid-connect-client
Requires
- php: ^8.2
- ext-json: *
- ext-openssl: *
- guzzlehttp/guzzle: ^6.5.8 || ^7.4.5
- league/oauth2-client: ^2.7
- psr/log: ^3.0
- psr/simple-cache: ^3.0
- web-token/jwt-library: ^4.1
Requires (Dev)
- mockery/mockery: ^1.5
- phpunit/phpunit: ^9.5 || ^10.0
Suggests
- ext-bcmath: Alternative to GMP for RSA key performance
- ext-gmp: Recommended for RSA key performance (PS256, RS256)
README
A generic OpenID Connect provider for The PHP League's OAuth2 Client, with built-in support for modern OAuth 2.0 security features:
- OpenID Connect Discovery — Automatic endpoint configuration via
.well-known/openid-configuration - PAR (Pushed Authorization Requests) — RFC 9126
- PKCE (Proof Key for Code Exchange) — RFC 7636 with S256
- DPoP (Demonstrating Proof of Possession) — RFC 9449
- Private Key JWT client authentication — RFC 7523
- ID Token validation — Signature verification, claim validation, nonce checking
- Rich Authorization Requests — RFC 9396 parameter transport with extension hooks
- RFC 9207 — Authorization Server Issuer Identification (mix-up attack protection)
Disclaimer
OAuth2 and its related standards are complex topics to understand and to get right. This library strives to be correct but mistakes can be made. There is NO WARRANTY, use at your own risk, and please leave a bug report or a pull request if you find something that seems off.
Requirements
- PHP 8.2 or later
ext-jsonext-openssl
Installation
composer require hvatum/oauth2-openid-connect-client
Basic Usage
The simplest setup — just point to the issuer:
use Hvatum\OpenIDConnect\Client\Provider\OpenIDConnectProvider; $provider = new OpenIDConnectProvider([ 'clientId' => 'your-client-id', 'clientSecret' => 'your-client-secret', 'redirectUri' => 'https://your-app.example/callback', 'issuer' => 'https://your-idp.example', ]);
All endpoints (authorization, token, userinfo, JWKS, PAR) are automatically discovered
from {issuer}/.well-known/openid-configuration.
Authorization Code Flow
// Step 1: Redirect user to authorization endpoint if (!isset($_GET['code'])) { $authUrl = $provider->getAuthorizationUrl([ 'scope' => ['openid', 'profile', 'email'], ]); // Store state and nonce in session for validation $_SESSION['oauth2_state'] = $provider->getState(); $_SESSION['oauth2_nonce'] = $provider->getNonce(); $_SESSION['oauth2_pkce'] = $provider->getPkceCode(); header('Location: ' . $authUrl); exit; } // Step 2: Handle callback if ($_GET['state'] !== $_SESSION['oauth2_state']) { throw new \RuntimeException('Invalid state'); } // Restore state from session $provider->setNonce($_SESSION['oauth2_nonce']); // Exchange code for tokens (iss is used for RFC 9207 mix-up attack protection) $token = $provider->getAccessToken('authorization_code', [ 'code' => $_GET['code'], 'code_verifier' => $_SESSION['oauth2_pkce'], 'iss' => $_GET['iss'] ?? null, ]); // Get user info (ID token claims merged with userinfo endpoint) $user = $provider->getResourceOwner($token); echo $user->getName(); echo $user->getEmail();
Advanced Usage
Private Key JWT Authentication (RFC 7523)
Use private_key_jwt instead of client_secret for client authentication:
$provider = new OpenIDConnectProvider([ 'clientId' => 'your-client-id', 'redirectUri' => 'https://your-app.example/callback', 'issuer' => 'https://your-idp.example', 'privateKeyPath' => '/path/to/private-key.pem', // or .jwk 'keyId' => 'your-key-id', // optional if in JWK file ]);
Supports EC (ES256/ES384/ES512) and RSA (RS256/RS384/RS512, PS256/PS384/PS512) keys in both PEM and JWK formats.
Loading keys from environment variables
For 12-factor / Kubernetes-style deployments where the key is injected via an
environment variable rather than mounted on disk, use privateKey to pass the
raw PEM or JWK JSON content directly:
$provider = new OpenIDConnectProvider([ 'clientId' => 'your-client-id', 'issuer' => 'https://your-idp.example', 'privateKey' => getenv('OIDC_CLIENT_PRIVATE_KEY'), // raw PEM or JWK JSON 'keyId' => 'your-key-id', ]);
privateKey (raw content) and privateKeyPath (filesystem path) are mutually
exclusive — setting both throws an InvalidArgumentException at construction.
An empty string is treated as unset, so an unset env var bound to '' falls
back to a configured privateKeyPath rather than silently disabling client
assertion.
DPoP Token Binding (RFC 9449)
Bind access tokens to a cryptographic key pair to prevent token theft:
$provider = new OpenIDConnectProvider([ 'clientId' => 'your-client-id', 'redirectUri' => 'https://your-app.example/callback', 'issuer' => 'https://your-idp.example', 'privateKeyPath' => '/path/to/client-key.pem', 'dpopPrivateKeyPath' => '/path/to/dpop-private.pem', 'dpopPublicKeyPath' => '/path/to/dpop-public.pem', ]); // DPoP proofs are automatically included in token requests. // For API calls with DPoP-bound tokens: $response = $provider->makeDPopRequest('GET', 'https://api.example/resource', $token->getToken());
DPoP keys follow the same contract as privateKey / privateKeyPath:
dpopPrivateKey and dpopPublicKey accept raw PEM/JWK content for env-var
deployments, while dpopPrivateKeyPath and dpopPublicKeyPath accept
filesystem paths. Each *Key option is mutually exclusive with its *KeyPath
counterpart. The public key may be omitted entirely — it is derived from the
private key.
ID Token Validation
ID tokens are automatically validated when fetching resource owner details. You can also validate manually:
$claims = $provider->validateIdToken($idTokenJwt, $expectedNonce);
Validates: signature (ES256/384/512, RS256/384/512, PS256/384/512), issuer, audience, expiration, nonce, and more.
UserInfo Responses
getResourceOwner() accepts both plain JSON userinfo responses and signed
JWT responses (OIDC Core §5.3.2, content-type application/jwt). Signed
responses are verified against the provider JWKS with the same algorithm
allow-listing as ID tokens (iss, aud and sub required, time claims
checked when present, an unaccepted azp rejected (see Authorized party
validation) — though per OIDC Core
§5.3.2 multiple audiences are accepted without azp, unlike ID
tokens), and the JWT envelope and ID-token transport claims are stripped
before the claims are merged with the ID token. Accepted algorithms come
from userinfo_signing_alg_values_supported in discovery, falling back to
the id_token algorithms — override getUserinfoSigningAlgValuesSupported()
in a subclass for servers that sign userinfo with algorithms they do not
advertise. Encrypted (JWE) userinfo responses are not supported.
Caching
Well-known configuration and JWKS keys are cached using PSR-16 (SimpleCache). TTL is managed by the cache implementation, so expiry works correctly across PHP-FPM requests.
By default, a built-in filesystem cache is used. You can customize the directory and TTLs:
$provider = new OpenIDConnectProvider([ // ... 'cacheDir' => '/path/to/cache', // default: sys_get_temp_dir()/oauth2-oidc/<user-hash> 'wellKnownCacheTtl' => 86400, // default: 86400 (24 hours) 'jwksCacheTtl' => 3600, // default: 3600 (1 hour) ]);
Or provide your own PSR-16 cache implementation (e.g. Redis, Memcached):
$provider = new OpenIDConnectProvider([ // ... ], [ 'cache' => $yourPsr16Cache, // Must implement Psr\SimpleCache\CacheInterface ]);
PSR-3 Logging
Pass a PSR-3 logger for debug output:
$provider = new OpenIDConnectProvider([ // ... ], [ 'logger' => $yourPsrLogger, ]);
Key Generation
EC Key Pair (for DPoP or client assertion)
openssl ecparam -name prime256v1 -genkey -noout -out private.pem openssl ec -in private.pem -pubout -out public.pem
RSA Key Pair (for client assertion)
openssl genrsa -out private.pem 2048 openssl rsa -in private.pem -pubout -out public.pem
Extending for Specific Providers
This package is designed to be extended for provider-specific requirements:
use Hvatum\OpenIDConnect\Client\Provider\OpenIDConnectProvider; class MyProvider extends OpenIDConnectProvider { public const CLIENT_ASSERTION_TTL = 10; // Override default TTL protected function getDefaultScopes(): array { return ['openid', 'profile', 'my-custom-scope']; } protected function createResourceOwner(array $response, AccessToken $token) { return new MyResourceOwner($response); } }
Authorized party (azp) validation
An unaccepted azp claim fails validation on both ID tokens and signed
userinfo responses; by default only this client's own ID is accepted. For
deployments where a foreign azp is legitimate — e.g. Google-style hybrid
app flows, where azp names the native app while aud names this client —
pass the allow-list as an option:
new MyProvider([ // ... 'allowedAuthorizedParties' => ['native-app-client-id'], ]);
or override isAcceptedAuthorizedParty() for policies a static list cannot
express. An allow-listed foreign azp is only accepted on single-audience
JWTs, and multi-audience ID tokens always require azp to name this client
itself — the allow-list does not loosen either rule.
Client assertion audience
The client assertion aud claim defaults to the issuer identifier, as required by
draft-ietf-oauth-rfc7523bis §4
(updating RFC 7523 §3), which mandates the issuer identifier as the sole audience value and
forbids the token endpoint URL. The draft — not yet an RFC, but in the RFC Editor queue —
tightens the original RFC 7523 §3 rule (which allowed the token endpoint) and OpenID Connect
Core §9 (which recommended it).
Legacy servers that only accept the token endpoint URL are still supported by overriding
getClientAssertionAudience():
class MyProvider extends OpenIDConnectProvider { protected function getClientAssertionAudience(): string { return $this->tokenUrl; } }
Authorization Details (RFC 9396) and Profile Hooks
By default, authorization_details follows RFC 9396 parameter transport:
- Authorization request / PAR request: sent as JSON string parameter
- Token request: sent as JSON string parameter
- No default embedding into
client_assertionclaims
If a provider profile requires embedding authorization_details in client_assertion, override these hooks:
class MyProvider extends OpenIDConnectProvider { protected function getAuthorizationDetailsForClientAssertion(array $params, ?array $authorizationDetails): ?array { if (($params['grant_type'] ?? '') !== 'client_credentials') { return null; } return $authorizationDetails; } protected function shouldSendAuthorizationDetailsInTokenRequestBody(array $params, ?array $authorizationDetails): bool { return false; } }
Supported RFCs
| RFC | Feature | Status |
|---|---|---|
| RFC 6749 | OAuth 2.0 Authorization Framework | Supported (via League) |
| RFC 7517 | JSON Web Key (JWK) | Supported |
| RFC 7523 | JWT Bearer Client Authentication | Supported |
| RFC 7636 | PKCE (S256) | Supported |
| RFC 7638 | JWK Thumbprint | Supported |
| RFC 9126 | Pushed Authorization Requests (PAR) | Supported |
| RFC 9207 | Authorization Server Issuer Identification | Supported |
| RFC 9396 | Rich Authorization Requests | Supported |
| RFC 9449 | DPoP (Demonstrating Proof of Possession) | Supported |
License
MIT License. See LICENSE for details.