Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.12% covered (success)
94.12%
64 / 68
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
JwksKeyResolver
94.12% covered (success)
94.12%
64 / 68
50.00% covered (danger)
50.00%
2 / 4
42.36
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 publicKeys
85.00% covered (warning)
85.00%
17 / 20
0.00% covered (danger)
0.00%
0 / 1
13.57
 unusable
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
11
 load
97.06% covered (success)
97.06%
33 / 34
0.00% covered (danger)
0.00%
0 / 1
17
1<?php
2
3declare(strict_types=1);
4
5namespace LambdaTwelve\OneRecord\Auth\Jwt;
6
7use InvalidArgumentException;
8use Psr\Http\Client\ClientExceptionInterface;
9use Psr\Http\Client\ClientInterface;
10use Psr\Http\Message\RequestFactoryInterface;
11use Psr\Log\LoggerInterface;
12use Psr\Log\NullLogger;
13use Psr\SimpleCache\CacheInterface;
14use Throwable;
15
16/**
17 * Public keys from an identity provider's JWKS document, as the spec's
18 * security section describes: each trusted issuer maps to a JWKS URL (or the
19 * well-known location under the issuer), documents are cached, and a token
20 * naming a key id the cache does not know triggers one refresh so key
21 * rotation needs no restart. Without a cache every token costs a fetch; a
22 * cache that fails is logged and bypassed.
23 */
24final class JwksKeyResolver implements KeyResolver
25{
26    /**
27     * @param array<string, string|null> $issuers issuer => JWKS URL, or null for {issuer}/.well-known/jwks.json
28     */
29    public function __construct(
30        private readonly array $issuers,
31        private readonly ClientInterface $http,
32        private readonly RequestFactoryInterface $requests,
33        private readonly ?CacheInterface $cache = null,
34        private readonly int $ttlSeconds = 3600,
35        private readonly LoggerInterface $logger = new NullLogger(),
36        private readonly int $refreshCooldownSeconds = 60,
37    ) {}
38
39    /** @var array<string, float> issuer => when an unknown key id last forced a refresh */
40    private array $refreshedAt = [];
41
42    public function publicKeys(string $issuer, ?string $keyId): array
43    {
44        if (!\array_key_exists($issuer, $this->issuers)) {
45            return [];
46        }
47        $keys = $this->load($issuer, refresh: false);
48        if ($keyId !== null && !isset($keys[$keyId])) {
49            // Rotation needs one refresh; a stream of forged key ids must not become a stream of
50            // fetches against the issuer (AR-025). One refresh per issuer per cooldown.
51            // The cooldown is recorded in the shared cache too: a resolver built per request would
52            // otherwise forget it (R2-010). Suppressing concurrent refreshes needs a lock the host provides.
53            $now = microtime(true);
54            $cooldownKey = 'one-record.jwks-refresh.' . hash('sha256', $issuer);
55            $cooling = isset($this->refreshedAt[$issuer]) && $now - $this->refreshedAt[$issuer] < $this->refreshCooldownSeconds;
56            if (!$cooling && $this->cache !== null) {
57                try {
58                    $cooling = $this->cache->get($cooldownKey) !== null;
59                } catch (Throwable) {
60                    $cooling = false;
61                }
62            }
63            if (!$cooling) {
64                $this->refreshedAt[$issuer] = $now;
65                if ($this->cache !== null) {
66                    try {
67                        $this->cache->set($cooldownKey, $now, max(1, $this->refreshCooldownSeconds));
68                    } catch (Throwable) {
69                    }
70                }
71                $keys = $this->load($issuer, refresh: true);
72            }
73        }
74        if ($keyId !== null && isset($keys[$keyId])) {
75            return [$keys[$keyId]];
76        }
77
78        return array_values($keys);
79    }
80
81    /**
82     * Why a JWK is not used to verify tokens, or null when it is: only keys the issuer publishes for
83     * signature verification are taken, RSA, `use` absent or "sig", `alg` absent or "RS256", `key_ops`
84     * absent or a list of distinct operation strings including "verify" (RFC 7517 sections 4.2 to 4.4).
85     * A member that is present but malformed, null included, fails closed: a key published for
86     * encryption only never verifies a token, whichever member says so (D14-001, R15-001).
87     *
88     * @param array<array-key, mixed> $jwk
89     */
90    private static function unusable(array $jwk): ?string
91    {
92        if (($jwk['kty'] ?? null) !== 'RSA') {
93            return 'not an RSA key';
94        }
95        if (\array_key_exists('use', $jwk) && $jwk['use'] !== 'sig') {
96            return 'use is not sig';
97        }
98        if (\array_key_exists('alg', $jwk) && $jwk['alg'] !== 'RS256') {
99            return 'alg is not RS256';
100        }
101        if (\array_key_exists('key_ops', $jwk)) {
102            $ops = $jwk['key_ops'];
103            if (!\is_array($ops) || !array_is_list($ops) || $ops !== array_values(array_unique(array_filter($ops, 'is_string')))) {
104                return 'key_ops is not a list of distinct operation names';
105            }
106            if (!\in_array('verify', $ops, true)) {
107                return 'key_ops does not include verify';
108            }
109        }
110
111        return null;
112    }
113
114    /**
115     * @return array<string, string> kid (or ordinal) => PEM
116     */
117    private function load(string $issuer, bool $refresh): array
118    {
119        $cacheKey = 'one-record.jwks.' . hash('sha256', $issuer);
120        if (!$refresh && $this->cache !== null) {
121            try {
122                $cached = $this->cache->get($cacheKey);
123            } catch (Throwable $e) {
124                // A broken cache must not refuse tokens; it only costs a fetch.
125                $this->logger->warning('JWKS cache read failed', ['issuer' => $issuer, 'error' => $e->getMessage()]);
126                $cached = null;
127            }
128            if (\is_array($cached)) {
129                /** @var array<string, string> $cached */
130                return $cached;
131            }
132        }
133
134        $url = $this->issuers[$issuer] ?? rtrim($issuer, '/') . '/.well-known/jwks.json';
135        try {
136            $response = $this->http->sendRequest($this->requests->createRequest('GET', $url)->withHeader('Accept', 'application/json'));
137        } catch (ClientExceptionInterface $e) {
138            $this->logger->warning('JWKS fetch failed', ['issuer' => $issuer, 'error' => $e->getMessage()]);
139
140            return [];
141        }
142        if ($response->getStatusCode() !== 200) {
143            $this->logger->warning('JWKS fetch returned an error', ['issuer' => $issuer, 'status' => $response->getStatusCode()]);
144
145            return [];
146        }
147        $document = json_decode((string) $response->getBody(), true);
148        $keys = [];
149        if (\is_array($document) && \is_array($document['keys'] ?? null)) {
150            foreach ($document['keys'] as $index => $jwk) {
151                if (!\is_array($jwk)) {
152                    continue;
153                }
154                $reason = self::unusable($jwk);
155                if ($reason !== null) {
156                    $this->logger->info('JWKS key skipped', ['issuer' => $issuer, 'kid' => \is_string($jwk['kid'] ?? null) ? $jwk['kid'] : null, 'reason' => $reason]);
157                    continue;
158                }
159                try {
160                    /** @var array<string, mixed> $jwk */
161                    $keys[\is_string($jwk['kid'] ?? null) ? $jwk['kid'] : 'k' . $index] = Jwk::rsaToPem($jwk);
162                } catch (InvalidArgumentException $e) {
163                    $this->logger->warning('JWKS contains an unusable key', ['issuer' => $issuer, 'error' => $e->getMessage()]);
164                }
165            }
166        }
167        if ($this->cache !== null) {
168            try {
169                $this->cache->set($cacheKey, $keys, $this->ttlSeconds);
170            } catch (Throwable $e) {
171                $this->logger->warning('JWKS cache write failed', ['issuer' => $issuer, 'error' => $e->getMessage()]);
172            }
173        }
174
175        return $keys;
176    }
177}