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

Options

<?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 issuer from the openid configuration (iss claim), https://issuer.example.com and https://issuer.example.com/ are not the same. Use https in production, plain http is 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, including HS*, is rejected at construction time.
  • JWKS: Fetched from the jwks_uri of the openid configuration and cached in memory for jwksMaxAge, an unknown key id (key rotation) triggers a refetch, but at most once per jwksCooldown.
  • 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 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,
            // '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>
$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: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.

Framework integration

Copyright

2026 Dominik Zogg

About

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

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages