Composer package for configuring OpenID Connect via OpenID Connect Discovery document.
This library is made and tested for use with Azure AD B2C but should be usable for other OpenID Connect providers.
- OpenID Connect Core 1.0
- OpenID Connect Basic Client Implementer's Guide 1.0 — the authorization code flow
- OpenID Connect Implicit Client Implementer's Guide 1.0
— the current
response_typedefault, deprecated; see below - Azure Active Directory B2C documentation
- Web sign-in with OpenID Connect in Azure Active Directory B2C
If you are looking to use this in a Symfony or Drupal project you should use either:
- Symfony: itk-dev/openid-connect-bundle
- (Archived)
Drupal: itk-dev/itkdev_openid_connect_drupal
To install this library directly run
composer require itk-dev/openid-connectTo use the library you must provide a cache implementation of PSR-6: Caching Interface. Look to PHP Cache for documentation and implementations.
When a user wishes to authenticate themselves, we create an instance of
OpenIdConfigurationProvider and redirect them to the authorization url this
provides.
Here the user can authenticate and if successful be redirected back the
redirect uri provided. During verification of the response from the authorizer
we can extract information about the user from the id_token, depending on
which claims are supported.
To use the package import the namespace, create and configure a provider
require_once __DIR__.'/vendor/autoload.php';
use ItkDev\OpenIdConnect\Security\OpenIdConfigurationProvider;
$provider = new OpenIdConfigurationProvider([
'redirectUri' => 'https://app.example.org', // Absolute url to where the user is redirected after a successful login
'openIDConnectMetadataUrl' => 'https://provider.example.org/.well-known/openid-configuration', // url to OpenId Discovery document
'cacheItemPool' => $cacheItemPool, // A Psr\Cache\CacheItemPoolInterface instance, for caching the discovery document and the JWKS
'clientId' => 'client_id', // Client id assigned by authorizer
'clientSecret' => 'client_secret', // Client password assigned by authorizer
// optional values
'leeway' => 30, // Defaults to 10 (seconds)
'cacheDuration' => 3600, // Defaults to 86400 (seconds)
'allowHttp' => true, // Defaults to false. Allow OIDC urls with http scheme. Use only during development!
]);allowHttp governs every URL the client talks to, not just
openIDConnectMetadataUrl. The authorization_endpoint, token_endpoint,
userinfo_endpoint, end_session_endpoint and jwks_uri read from the IdP's
discovery document are held to the same policy: with allowHttp at its default
false, a document announcing any of them over plain http raises
IllegalSchemeException before the URL is used. Without that check, a tampered
or misconfigured document could have the client secret posted in plaintext
during the code exchange.
Set allowHttp when developing against an IdP without TLS — a local Keycloak,
say. Never in production.
This library extends league/oauth2-client, which uses Guzzle for HTTP. To
bound how long a request to the IdP can take (recommended for production),
pass timeout (seconds) in the constructor $options:
$provider = new OpenIdConfigurationProvider([
// ... required options ...
'timeout' => 5,
'proxy' => 'http://proxy.example.com:8080',
'verify' => true, // only consulted by Guzzle when proxy is set
]);league/oauth2-client whitelists exactly these three keys (timeout, proxy,
verify) and forwards them to the underlying Guzzle client. Other Guzzle
options (e.g. connect_timeout) are silently dropped.
Why Guzzle and not Symfony HttpClient?
league/oauth2-clienthard-types its HTTP client asGuzzleHttp\ClientInterface. Symfony HttpClient implements PSR-18 / HTTPlug, not Guzzle's interface, and there is no maintained adapter going Symfony → Guzzle. To plug in a non-Guzzle client you would need to write such an adapter yourself and pass it via$collaborators['httpClient']to the constructor.
To account for clock skew times between the signing and verifying servers, you can set a leeway when configuring the provider. It is recommended that leeway should not be bigger than a few minutes.
Defaults to 10 seconds
For more information see the following:
-
firebase/php-jwt Last entry in the example mentions the leeway option.
Non-authorized requests should be redirected to the authorization url.
To generate the authorization url you must supply "state" and "nonce":
State: "A value included in the request that's also returned in the token response. It can be a string of any content that you want. A randomly generated unique value is typically used for preventing cross-site request forgery attacks. The state is also used to encode information about the user's state in the application before the authentication request occurred, such as the page they were on."
Nonce: "A value included in the request (generated by the application) that is included in the resulting ID token as a claim. The application can then verify this value to mitigate token replay attacks. The value is typically a randomized unique string that can be used to identify the origin of the request."
See: Send authentication requests
You must persist these locally so that they can be used to validate the token when the user is redirected back to your application.
// Get "state" and "nonce"
$state = $provider->generateState();
$nonce = $provider->generateNonce();
// Save to session
$session->set('oauth2state', $state);
$session->set('oauth2nonce', $nonce);
$authUrl = $provider->getAuthorizationUrl(['state' => $state, 'nonce' => $nonce]);
// redirect to $authUrlThe snippet above is the contract, not just one way of writing it: the caller persists both values and compares against its own copy on the way back.
AbstractProvider also keeps the state on the provider object, so the inherited
getState() returns it — getAuthorizationUrl() writes that property whether or
not generateState() is used. Do not read the state back from the provider.
Where the provider is constructed per request that is merely redundant, but on a
shared instance — a long-running worker such as FrankenPHP or Swoole, or a
container that memoizes the service — the property holds whichever request wrote
it last, which may belong to a different user. generateNonce() stores nothing,
and the state is best treated as though it did the same.
getAuthorizationUrl() currently defaults to
'response_type' => 'id_token',
'response_mode' => 'query',which is the OIDC implicit flow with the ID token delivered in the query string.
Pass 'response_type' => 'code' and exchange the code with getIdToken()
instead. The default will become code in 6.0; passing it explicitly now is
both the recommended flow and forward-compatible.
Two reasons to move:
- OIDC Core §3.2.2.5
specifies that implicit-flow parameters are returned in the fragment. A
fragment never reaches the server, so the
response_mode => 'query'default exists to make the ID token readable server-side — a provider extension (Azure AD B2C supports it) rather than something the spec describes. - That puts a credential in the query string, where it reaches web server access logs and browser history.
RFC 9700 §2.1.2 recommends code
over response types that return tokens in the authorization response. Its
normative sentence names access tokens, so it does not literally cover a bare
id_token response — but code is the flow it points at, and the one
openid-connect-bundle uses.
PKCE is opt-in, and there is no configuration flag: passing a code_challenge
turns it on, omitting it changes nothing.
The verifier is a secret and belongs in the session alongside the state and the nonce. Only its challenge may reach the authorization request.
// Authorization request
$verifier = $provider->generatePkceVerifier();
$session->set('oauth2pkce', $verifier);
$authUrl = $provider->getAuthorizationUrl([
'state' => $state,
'nonce' => $nonce,
'response_type' => 'code',
'code_challenge' => $provider->getPkceChallenge($verifier),
]);code_challenge_method=S256 is filled in for you. Omitting it would make the
server assume plain (RFC 7636 §4.3) and treat the challenge as a value sent in
the clear, so it is not left to the caller to remember.
On the way back, hand the verifier to the code exchange:
$verifier = $session->get('oauth2pkce');
$session->remove('oauth2pkce');
$idToken = $provider->getIdToken($request->query->get('code'), $verifier);
$claims = $provider->validateIdToken($idToken, $session->get('oauth2nonce'));generatePkceVerifier() stores nothing on the provider, which is deliberate.
league/oauth2-client keeps its own verifier on the provider object; on an
instance shared between requests that would let one request's verifier be sent
for another request's exchange — the same hazard as reading getState() back.
The session is the only place the verifier should live.
The authorization service will redirect the user back to the redirectUri. This
should be an endpoint in your application where you validate the token and the
user.
Load the "state" and "nonce" from local storage and validate against the request values
// Validate that the request state and session state match
$sessionState = $this->session->get('oauth2state');
$this->session->remove('oauth2state');
if (!is_string($sessionState) || !hash_equals($sessionState, (string) $request->query->get('state'))) {
throw new ValidationException('Invalid state');
}
// Exchange the code for an id token, then validate it. Validation checks the
// signature against the keys published by the provider (Azure AD B2C), requires
// the "exp" and "iat" claims that OIDC Core §2 makes mandatory, and checks
// "aud", "iss" and "nonce". Any failure throws.
try {
$idToken = $provider->getIdToken($request->query->get('code'));
$claims = $provider->validateIdToken($idToken, $session->get('oauth2nonce'));
// Authentication successful
} catch (OpenIdConnectExceptionInterface $exception) {
// Handle failed authentication
} finally {
$this->session->remove('oauth2nonce');
}With PKCE, pass the stored verifier to getIdToken() as shown above. With the
deprecated id_token response type there is no code to exchange and the token
arrives in the query instead:
$claims = $provider->validateIdToken($request->query->get('id_token'), $session->get('oauth2nonce'));Every exception thrown from a public method of this library implements
\ItkDev\OpenIdConnect\Exception\OpenIdConnectExceptionInterface. Catch the
marker to handle any OIDC failure with a single block, or scope to a more
specific type when you need to discriminate:
use ItkDev\OpenIdConnect\Exception\OpenIdConnectExceptionInterface;
try {
$claims = $provider->validateIdToken($idToken, $nonce);
} catch (OpenIdConnectExceptionInterface $e) {
// Cause is preserved via $e->getPrevious()
}Concrete exception classes extend the SPL type that describes the failure
category, so a catch block scoped to that SPL type will also match:
| SPL parent | Concrete types | Category |
|---|---|---|
\RuntimeException |
CacheException, HttpException, JsonException, DecodeException, JwksException, CodeException, ValidationException, ClaimsException, MetadataException |
Network, cache, token validation, claims mismatch — transient or data-shape failures |
\LogicException |
BadUrlException, IllegalSchemeException, MissingParameterException |
Programmer/config bugs — should be fixed in code |
\InvalidArgumentException |
ConfigurationException, NegativeCacheDurationException, NegativeLeewayException |
Invalid input to the constructor / setters |
HttpException additionally implements PSR-18's
Psr\Http\Client\ClientExceptionInterface, so existing PSR-18-aware
consumers can keep catching on the standard PSR marker.
Every wrap site preserves the underlying cause via $previous, so
$e->getPrevious() walks back to the originating Guzzle, firebase/php-jwt
or PSR-6 cache exception.
Upgrading from 4.x: the concrete exceptions no longer extend the abstract
ItkOpenIdConnectException. Catches written ascatch (ItkOpenIdConnectException $e)will not match anything thrown by 5.0+ code — migrate tocatch (OpenIdConnectExceptionInterface $e). The abstract class itself is kept through 5.x as a documented alias (@deprecated); removal is scheduled for 6.0.
A docker-compose.yml file with a PHP 8.3+ image is included in this project.
A Taskfile is used to run common development tasks.
To set up the project:
task setupThis starts the Docker containers and installs Composer dependencies.
To run all checks locally (composer validation, coding standards, static analysis at the dependency ceiling and floor, the test matrix, and mutation testing):
task pr:actionstask test:coveragephpunit.xml.dist declares coverage reports, so the suite needs
XDEBUG_MODE=coverage; task test:coverage sets it. task test runs PHPUnit
without it and reports no executed tests.
Run the test suite across all supported PHP versions (8.3, 8.4, 8.5) with both lowest and stable dependencies, mirroring the CI matrix:
task test:matrixThis runs PHPUnit with coverage for each combination and prints a summary of pass/fail results.
The test suite uses Mockery to mock
public static methods
in 3rd party libraries like the JWT::decode method from firebase/php-jwt.
Line coverage shows which code the tests execute; mutation testing shows which code they actually verify. Infection applies small changes (mutants) to the source code — flipping a comparison, removing a method call — and runs the test suite against each one. If the tests still pass, the mutant "escaped": a potential bug the tests would not catch.
task test:mutationThe minimum mutation score (minMsi and minCoveredMsi, both 100) is defined
in infection.json5 and enforced both locally and in CI — no command line flags
needed. Mutants that no test could distinguish are excluded per mutator there,
with the reason recorded, so the threshold stays binding. CI
annotates escaped mutants inline on pull requests, and results for develop
are published to the
Stryker dashboard,
which also feeds the mutation score badge above. Detailed reports are written
to infection.log and infection.html on each run.
task analyze:phpphpstan.neon pins the analysis to the whole php ^8.3 range that
composer.json declares, so the result does not depend on the PHP version it
runs on. The dependency floor is a separate axis, covered by its own job:
task analyze:php:lowestThat lowers only the packages require names — leaving the dev tooling current
— analyses, and restores the ceiling afterwards.
Check all coding standards:
task lintFix PHP coding standards (php-cs-fixer):
task lint:php:fixFix Markdown files:
task lint:markdown:fixFix YAML files:
task lint:yaml:fixRun task --list to see all available tasks.
GitHub Actions are used to run the test suite and code style checks on all PRs.
We use SemVer for versioning. For the versions available, see the tags on this repository.
This project is licensed under the MIT License - see the LICENSE.md file for details