Skip to content

Repository files navigation

chubbyphp-oidc

CI Coverage Status Mutation testing badge Latest Stable Version Total Downloads Monthly Downloads

bugs code_smells coverage duplicated_lines_density ncloc sqale_rating alert_status reliability_rating security_rating sqale_index vulnerabilities

Description

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.

Requirements

Installation

Through Composer as chubbyphp/chubbyphp-oidc.

composer require chubbyphp/chubbyphp-oidc "^1.0"

Usage

<?php

use Chubbyphp\Oidc\Discovery\OidcConfigurationResolver;
use Chubbyphp\Oidc\Jwks\RemoteJwkSet;
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, 'allow_redirects' => false]); // 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),
        new RemoteJwkSet($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: audience is required and must match the aud claim 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+jwt header), pass typ: 'at+jwt' too.
  • Rejected requests: Without a valid token the handler is not called and a 401 with a RFC 6750 challenge is returned: WWW-Authenticate: Bearer realm="api" (missing token) or Bearer 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 (level info) 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 a 5xx.
  • Browser clients: Allow the Authorization request header and expose the WWW-Authenticate response header within your cors configuration (see chubbyphp/chubbyphp-cors).
  • Token in the request attribute: The oidc attribute carries the raw bearer token (token) next to the verified claims, so that handlers can forward it to downstream apis. Treat the attribute as sensitive: do not dump the request attributes into logs, error reports or responses.

Options

<?php

use Chubbyphp\Oidc\Clock\SystemClock;
use Chubbyphp\Oidc\Discovery\OidcConfigurationResolver;
use Chubbyphp\Oidc\Jwks\RemoteJwkSet;
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 (non-negative), default: 3600
    cooldown: 30, // seconds until a failed (re)fetch is retried (non-negative), default: 30
    allowInsecureIssuer: false, // accept a plain http issuer (local development only), default: false
    clock: new SystemClock(), // any PSR-20 clock, default: SystemClock
);

// fetches and caches the jwks of the resolved jwks_uri, lazily on first token verification
$remoteJwkSet = new RemoteJwkSet(
    $client, // any PSR-18 client, required
    $requestFactory, // any PSR-17 request factory, required
    maxAge: 600, // seconds a fetched jwks is cached (non-negative), default: 600
    cooldown: 30, // seconds until a failed jwks (re)fetch is retried, and between refetches for unknown key ids
    // (non-negative), default: 30
);

// verifies signature (via the issuer's JWKS), "iss", "aud", "exp", "nbf" and returns the claims
$tokenVerifier = new JwtTokenVerifier(
    $oidcConfigurationResolver,
    $remoteJwkSet,
    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 (non-negative), default: 0
    typ: 'at+jwt', // expected "typ" header, default: not checked
    requiredClaims: ['sub', 'iat', 'jti'], // additionally required claims, "iss", "aud" and "exp" always are
    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 issuer from the openid configuration (iss claim), https://issuer.example.com and https://issuer.example.com/ are not the same. Only absolute https urls are accepted by default: whoever can tamper with an unprotected discovery or jwks response can forge tokens your api accepts. A plain http issuer is only meant for local development and has to be opted in explicitly with allowInsecureIssuer: true, so an insecure deployment is a deliberate decision and not a copied example. A https issuer advertising a plain http jwks_uri is rejected in any case.
  • HTTP client: 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])). The https checks above apply to the configured issuer and the advertised jwks_uri only: a client following redirects on its own would silently follow a httpshttp redirect of the discovery or jwks fetch. Neither endpoint should redirect, so disable redirects (Guzzle: 'allow_redirects' => false) or restrict them to https (Guzzle: 'allow_redirects' => ['protocols' => ['https']]).
  • 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, including HS*, is rejected at construction time.
  • JWKS: Fetched (by the RemoteJwkSet) from the jwks_uri of the openid configuration and cached in memory for its maxAge, an unknown key id (key rotation) triggers a refetch, but at most once per its cooldown.
  • 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 the respective cooldown), 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. An invalid discovery / jwks response is reported as Chubbyphp\Oidc\Exception\OidcConfigurationException / Chubbyphp\Oidc\Exception\JwksException. 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 to Chubbyphp\Oidc\Clock\SystemClock.
  • Custom verifier: A TokenVerifierInterface is just verify(string $token): array. Throw an InvalidTokenException (Chubbyphp\Oidc\Exception\InvalidTokenException) to get the 401 response, any other error is rethrown.

Service factories (laminas-config)

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,
            // 'allowInsecureIssuer' => false,
            // '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).

Testing against a local OIDC provider

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-dev

Within 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>
// plain http is only accepted with the explicit opt-in, never do this in production
$oidcConfigurationResolver = new OidcConfigurationResolver(
    'http://localhost:8080/realms/test',
    $client,
    $requestFactory,
    allowInsecureIssuer: true
);

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:integration

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

Copyright

2026 Dominik Zogg

About

A minimal OIDC (OpenID Connect) resource server middleware for PSR 15.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages