A minimal OIDC (OpenID Connect) resource server middleware for PSR 15: resolves the issuer's openid configuration, verifies JWT bearer tokens against its JWKS and passes the verified claims to the handler via request attributes.
- php: ^8.3
- psr/clock: ^1.0
- psr/http-client: ^1.0.3
- psr/http-factory: ^1.1
- psr/http-message: ^1.1|^2.0
- psr/http-server-middleware: ^1.0.2
- psr/log: ^3.0.2
- web-token/jwt-library: ^4.1.7
Through Composer as chubbyphp/chubbyphp-oidc.
composer require chubbyphp/chubbyphp-oidc "^1.0"<?php
use Chubbyphp\Oidc\Discovery\OidcConfigurationResolver;
use Chubbyphp\Oidc\Middleware\OidcAuthenticationMiddleware;
use Chubbyphp\Oidc\Token\BearerTokenExtractor;
use Chubbyphp\Oidc\Token\JwtTokenVerifier;
use GuzzleHttp\Client as HttpClient;
use Slim\Psr7\Factory\RequestFactory;
use Slim\Psr7\Factory\ResponseFactory;
use Slim\Psr7\Factory\ServerRequestFactory;
$client = new HttpClient(['timeout' => 5]); // any PSR-18 client
$requestFactory = new RequestFactory(); // any PSR-17 request factory
$responseFactory = new ResponseFactory(); // any PSR-17 response factory
$oidcAuthenticationMiddleware = new OidcAuthenticationMiddleware(
$responseFactory,
new BearerTokenExtractor(),
new JwtTokenVerifier(
new OidcConfigurationResolver('https://issuer.example.com', $client, $requestFactory),
$client,
$requestFactory,
'https://api.example.com'
),
'api'
);
// add the middleware to the routes you want to protect
$request = (new ServerRequestFactory())->createServerRequest('GET', 'https://api.example.com/pets');
$response = $oidcAuthenticationMiddleware->process($request, $handler);Within the handler:
<?php
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\RequestHandlerInterface;
final class Handler implements RequestHandlerInterface
{
public function handle(ServerRequestInterface $request): ResponseInterface
{
// the middleware guarantees the "oidc" attribute for every handler behind it
/** @var array{token: string, claims: array<string, mixed>} $oidc */
$oidc = $request->getAttribute('oidc');
$sub = $oidc['claims']['sub'] ?? null;
...
}
}- Audience:
audienceis required and must match theaudclaim your authorization server puts into access tokens for your API, otherwise any token of the issuer (even for other APIs, or ID tokens) would be accepted. If your server issues RFC 9068 access tokens (typ: at+jwtheader), passtyp: 'at+jwt'too. - Rejected requests: Without a valid token the handler is not called and a
401with a RFC 6750 challenge is returned:WWW-Authenticate: Bearer realm="api"(missing token) orBearer realm="api", error="invalid_token", error_description="The access token is invalid or expired"(invalid token). The actual reason (expired, wrong signature, ...) is only logged (levelinfo) via the optional logger, never sent to the client. Errors not related to the token (unreachable issuer, ...) are rethrown, so your error handling responds with a5xx. - Browser clients: Allow the
Authorizationrequest header and expose theWWW-Authenticateresponse header within your cors configuration (see chubbyphp/chubbyphp-cors).
<?php
use Chubbyphp\Oidc\Clock\SystemClock;
use Chubbyphp\Oidc\Discovery\OidcConfigurationResolver;
use Chubbyphp\Oidc\Middleware\OidcAuthenticationMiddleware;
use Chubbyphp\Oidc\Token\BearerTokenExtractor;
use Chubbyphp\Oidc\Token\JwtTokenVerifier;
// resolves and caches {issuer}/.well-known/openid-configuration, lazily on first token verification
$oidcConfigurationResolver = new OidcConfigurationResolver(
'https://issuer.example.com',
$client, // any PSR-18 client, required
$requestFactory, // any PSR-17 request factory, required
maxAge: 3600, // seconds a resolved configuration is cached, default: 3600
cooldown: 30, // seconds until a failed (re)fetch is retried, default: 30
clock: new SystemClock(), // any PSR-20 clock, default: SystemClock
);
// verifies signature (via the issuer's JWKS), "iss", "aud", "exp", "nbf" and returns the claims
$tokenVerifier = new JwtTokenVerifier(
$oidcConfigurationResolver,
$client, // any PSR-18 client, required
$requestFactory, // any PSR-17 request factory, required
audience: 'https://api.example.com', // string | array<string>, required (non-empty, enforced at runtime)
algorithms: ['RS256'], // default: any asymmetric algorithm supported by web-token/jwt-library
clockTolerance: 5, // seconds, default: 0
typ: 'at+jwt', // expected "typ" header, default: not checked
requiredClaims: ['sub', 'iat', 'jti'], // additionally required claims, "iss", "aud" and "exp" always are
jwksMaxAge: 600, // seconds a fetched jwks is cached, default: 600
jwksCooldown: 30, // seconds until a failed jwks (re)fetch is retried, and between refetches for unknown key
// ids, default: 30
clock: new SystemClock(), // any PSR-20 clock, default: SystemClock
);
$oidcAuthenticationMiddleware = new OidcAuthenticationMiddleware(
$responseFactory, // any PSR-17 response factory, required
new BearerTokenExtractor(), // reads the "Authorization: Bearer <token>" header
$tokenVerifier,
'api', // realm within the challenge, optional
$logger, // PSR-3 compatible logger, optional, default: no-op (NullLogger)
);- Issuer: Must be exactly the
issuerfrom the openid configuration (issclaim),https://issuer.example.comandhttps://issuer.example.com/are not the same. Usehttpsin production, plainhttpis only meant for local development. - Timeouts: PSR-18 has no per-request timeout concept, configure connect/request timeouts on your HTTP client
(e.g.
new GuzzleHttp\Client(['timeout' => 5, 'connect_timeout' => 2])). - Algorithms: Only asymmetric signature algorithms are supported
(
EdDSA,ES256,ES384,ES512,PS256,PS384,PS512,RS256,RS384,RS512): a public (jwks) key must never be usable as a hmac secret (algorithm confusion). Anything else, includingHS*, is rejected at construction time. - JWKS: Fetched from the
jwks_uriof the openid configuration and cached in memory forjwksMaxAge, an unknown key id (key rotation) triggers a refetch, but at most once perjwksCooldown. - Outages: If the issuer is unreachable while the cached configuration or jwks is expired, the last known one
keeps being used (a refetch is retried after
cooldown/jwksCooldown), so a temporary issuer outage does not take your api down. Only if there never was a successful fetch the error is thrown (5xx), within the cooldown immediately without hitting the issuer again. In a classic php-fpm setup the in-memory cache lives per request; use a long-running runtime (roadrunner, swoole, workerman, frankenphp) to benefit from it. - Clock: Every time based check (
exp,nbf, configuration and jwks cache expiry) uses the injected PSR-20 clock, which defaults toChubbyphp\Oidc\Clock\SystemClock. - Custom verifier: A
TokenVerifierInterfaceis justverify(string $token): array. Throw anInvalidTokenException(Chubbyphp\Oidc\Exception\InvalidTokenException) to get the401response, any other error is rethrown.
The package ships chubbyphp-laminas-config
factories within Chubbyphp\Oidc\ServiceFactory:
<?php
use Chubbyphp\Oidc\Middleware\OidcAuthenticationMiddleware;
use Chubbyphp\Oidc\ServiceFactory\OidcAuthenticationMiddlewareFactory;
return [
'chubbyphp' => [
'oidc' => [
'issuer' => 'https://issuer.example.com', // required
'audience' => 'https://api.example.com', // required
'realm' => 'api',
// 'maxAge' => 3600,
// 'cooldown' => 30,
// 'algorithms' => ['RS256'],
// 'clockTolerance' => 5,
// 'typ' => 'at+jwt',
// 'requiredClaims' => ['sub', 'iat', 'jti'],
// 'jwksMaxAge' => 600,
// 'jwksCooldown' => 30,
],
],
'dependencies' => [
'factories' => [
OidcAuthenticationMiddleware::class => OidcAuthenticationMiddlewareFactory::class,
],
],
];The container has to provide Psr\Http\Client\ClientInterface, Psr\Http\Message\RequestFactoryInterface and
Psr\Http\Message\ResponseFactoryInterface (Psr\Log\LoggerInterface is optional).
Keycloak as a docker container is the easiest way to test manually:
docker run --rm -p 8080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.7 start-devWithin the admin console at http://localhost:8080 (admin/admin) create a realm test
and a client api with Client authentication and Service accounts roles enabled, then:
curl -X POST http://localhost:8080/realms/test/protocol/openid-connect/token \
-d grant_type=client_credentials -d client_id=api -d client_secret=<client-secret>$oidcConfigurationResolver = new OidcConfigurationResolver('http://localhost:8080/realms/test', $client, $requestFactory);Keycloak specifics: access tokens contain aud: "account" until you add an audience mapper, have the header
typ: "JWT" (not at+jwt) and the iss claim matches the URL the token was requested through, so use the same
host for the resolver and the token request (or pin it, e.g. KC_HOSTNAME=http://keycloak:8080 in docker
compose).
For automated tests mock-oauth2-server is a lightweight
alternative which issues tokens without any setup. This repository's integration tests start it via
testcontainers (docker compatible daemon
required, set MOCK_OAUTH2_SERVER_URL to reuse a running one):
composer test:integrationThis works on a machine with php and docker as well as within a container which has the docker socket mounted
(the ci runs composer test within a docker image): if the tests themselves run within a container, the
mock-oauth2-server joins the docker network of that container and is used through its container ip instead of a
port published on the docker host.
2026 Dominik Zogg