Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
2 / 2
CRAP
100.00% covered (success)
100.00%
1 / 1
JwksEndpoint
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
2 / 2
4
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Auth;
6
7use LambdaTwelve\OneRecord\Auth\Jwt\Rs256Signer;
8use Psr\Http\Message\ResponseFactoryInterface;
9use Psr\Http\Message\ResponseInterface;
10use Psr\Http\Message\ServerRequestInterface;
11use Psr\Http\Message\StreamFactoryInterface;
12use Psr\Http\Server\RequestHandlerInterface;
13
14/**
15 * Serves the JWKS document for the keys this host signs with, so partners
16 * can verify its tokens with JwksKeyResolver and key rotation needs no
17 * out-of-band exchange. Mount it at /.well-known/jwks.json (or wherever the
18 * issuer's metadata says). Several signers mean several keys: the current
19 * one and the one being retired.
20 */
21final class JwksEndpoint implements RequestHandlerInterface
22{
23    /** @var list<Rs256Signer> */
24    private readonly array $signers;
25
26    /**
27     * @param int $maxAgeSeconds how long verifiers may cache the document; keep it shorter than a rotation overlap
28     */
29    public function __construct(
30        private readonly ResponseFactoryInterface $responses,
31        private readonly StreamFactoryInterface $streams,
32        private readonly int $maxAgeSeconds = 3600,
33        Rs256Signer ...$signers,
34    ) {
35        $this->signers = array_values($signers);
36    }
37
38    public function handle(ServerRequestInterface $request): ResponseInterface
39    {
40        if (!\in_array(strtoupper($request->getMethod()), ['GET', 'HEAD'], true)) {
41            return $this->responses->createResponse(405)->withHeader('Allow', 'GET, HEAD');
42        }
43        $document = ['keys' => array_map(static fn(Rs256Signer $signer): array => $signer->publicJwk(), $this->signers)];
44        $response = $this->responses->createResponse(200)
45            ->withHeader('Content-Type', 'application/json; charset=utf-8')
46            ->withHeader('Cache-Control', 'public, max-age=' . $this->maxAgeSeconds);
47
48        return strtoupper($request->getMethod()) === 'HEAD'
49            ? $response
50            : $response->withBody($this->streams->createStream(json_encode($document, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES)));
51    }
52}