Skip to content

Commit 981d813

Browse files
committed
[Security] Set OIDC JWKS cache TTL from provider headers
1 parent e56a9c9 commit 981d813

File tree

2 files changed

+149
-37
lines changed

2 files changed

+149
-37
lines changed

src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php

Lines changed: 70 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader;
3636
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
3737
use Symfony\Contracts\Cache\CacheInterface;
38+
use Symfony\Contracts\Cache\ItemInterface;
3839
use Symfony\Contracts\HttpClient\HttpClientInterface;
3940

4041
/**
@@ -107,43 +108,7 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
107108

108109
$jwkset = $this->signatureKeyset;
109110
if ($this->discoveryClients) {
110-
$clients = $this->discoveryClients;
111-
$logger = $this->logger;
112-
$keys = $this->discoveryCache->get($this->oidcConfigurationCacheKey, static function () use ($clients, $logger): array {
113-
try {
114-
$configResponses = [];
115-
foreach ($clients as $client) {
116-
$configResponses[] = $client->request('GET', '.well-known/openid-configuration', [
117-
'user_data' => $client,
118-
]);
119-
}
120-
121-
$jwkSetResponses = [];
122-
foreach ($client->stream($configResponses) as $response => $chunk) {
123-
if ($chunk->isLast()) {
124-
$jwkSetResponses[] = $response->getInfo('user_data')->request('GET', $response->toArray()['jwks_uri']);
125-
}
126-
}
127-
128-
$keys = [];
129-
foreach ($jwkSetResponses as $response) {
130-
foreach ($response->toArray()['keys'] as $key) {
131-
if ('sig' === $key['use']) {
132-
$keys[] = $key;
133-
}
134-
}
135-
}
136-
137-
return $keys;
138-
} catch (\Exception $e) {
139-
$logger?->error('An error occurred while requesting OIDC certs.', [
140-
'error' => $e->getMessage(),
141-
'trace' => $e->getTraceAsString(),
142-
]);
143-
144-
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
145-
}
146-
});
111+
$keys = $this->fetchDiscoveryKeys();
147112

148113
$jwkset = JWKSet::createFromKeyData(['keys' => $keys]);
149114
}
@@ -260,4 +225,72 @@ private function decryptIfNeeded(string $accessToken): string
260225
return $accessToken;
261226
}
262227
}
228+
229+
/**
230+
* Fetches the JWKS from all configured OIDC discovery endpoints.
231+
*
232+
* The cache entry lifetime is automatically adjusted based on the lowest TTL
233+
* advertised by the providers (via "Cache-Control: max-age" or "Expires" headers).
234+
*/
235+
private function fetchDiscoveryKeys(): array
236+
{
237+
$clients = $this->discoveryClients;
238+
$logger = $this->logger;
239+
$keys = $this->discoveryCache->get($this->oidcConfigurationCacheKey, static function (ItemInterface $item) use ($clients, $logger): array {
240+
try {
241+
$configResponses = [];
242+
foreach ($clients as $client) {
243+
$configResponses[] = $client->request('GET', '.well-known/openid-configuration', [
244+
'user_data' => $client,
245+
]);
246+
}
247+
$jwkSetResponses = [];
248+
foreach ($client->stream($configResponses) as $response => $chunk) {
249+
if ($chunk->isLast()) {
250+
$jwkSetResponses[] = $response->getInfo('user_data')->request('GET', $response->toArray()['jwks_uri']);
251+
}
252+
}
253+
$keys = [];
254+
$minTtl = null;
255+
foreach ($jwkSetResponses as $response) {
256+
$currentTtl = null;
257+
$headers = $response->getHeaders();
258+
if (preg_match('/max-age=(\d+)/', $headers['cache-control'][0] ?? '', $m)) {
259+
$currentTtl = (int) $m[1];
260+
} elseif (($headers['expires'][0] ?? '') !== '') {
261+
if (0 < $ttl = strtotime($headers['expires'][0]) - time()) {
262+
$currentTtl = $ttl;
263+
}
264+
}
265+
266+
// Apply the lowest TTL found to ensure all keys in the set are still valid
267+
if (null !== $currentTtl && (null === $minTtl || $currentTtl < $minTtl)) {
268+
$minTtl = $currentTtl;
269+
}
270+
271+
foreach ($response->toArray()['keys'] as $key) {
272+
if ('sig' === $key['use']) {
273+
$keys[] = $key;
274+
}
275+
}
276+
}
277+
278+
if (null !== $minTtl && $minTtl > 0) {
279+
// Cap the TTL to 30 days to avoid keeping JWKS indefinitely
280+
$ttl = min($minTtl, 30 * 24 * 60 * 60);
281+
$item->expiresAfter($ttl);
282+
}
283+
284+
return $keys;
285+
} catch (\Exception $e) {
286+
$logger?->error('An error occurred while requesting OIDC certs.', [
287+
'error' => $e->getMessage(),
288+
'trace' => $e->getTraceAsString(),
289+
]);
290+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
291+
}
292+
});
293+
294+
return $keys;
295+
}
263296
}

src/Symfony/Component/Security/Http/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,83 @@ private static function buildJWSWithKey(string $payload, JWK $jwk): string
311311
->build()
312312
);
313313
}
314+
315+
public function testDiscoveryCachesJwksAccordingToCacheControl()
316+
{
317+
$time = time();
318+
$claims = [
319+
'iat' => $time, 'nbf' => $time,
320+
'exp' => $time + 3600,
321+
'iss' => 'https://www.example.com',
322+
'aud' => self::AUDIENCE,
323+
'sub' => 'user-cache-control',
324+
];
325+
$token = self::buildJWS(json_encode($claims));
326+
327+
$requestCount = 0;
328+
$httpClient = new MockHttpClient(function ($method, $url) use (&$requestCount) {
329+
++$requestCount;
330+
if (str_contains($url, 'openid-configuration')) {
331+
return new JsonMockResponse(['jwks_uri' => 'https://www.example.com/jwks.json']);
332+
}
333+
334+
return new JsonMockResponse(
335+
['keys' => [array_merge(self::getJWK()->all(), ['use' => 'sig'])]],
336+
['response_headers' => ['Cache-Control' => 'public, max-age=120']]
337+
);
338+
});
339+
340+
$cache = new ArrayAdapter();
341+
$handler = new OidcTokenHandler(
342+
new AlgorithmManager([new ES256()]),
343+
null,
344+
self::AUDIENCE,
345+
['https://www.example.com']
346+
);
347+
$handler->enableDiscovery($cache, $httpClient, 'oidc_ttl_cc');
348+
$this->assertSame('user-cache-control', $handler->getUserBadgeFrom($token)->getUserIdentifier());
349+
$this->assertSame(2, $requestCount);
350+
$this->assertSame('user-cache-control', $handler->getUserBadgeFrom($token)->getUserIdentifier());
351+
$this->assertSame(2, $requestCount);
352+
}
353+
354+
public function testDiscoveryCachesJwksAccordingToExpires()
355+
{
356+
$time = time();
357+
$claims = [
358+
'iat' => $time, 'nbf' => $time,
359+
'exp' => $time + 3600,
360+
'iss' => 'https://www.example.com',
361+
'aud' => self::AUDIENCE,
362+
'sub' => 'user-expires',
363+
];
364+
365+
$token = self::buildJWS(json_encode($claims));
366+
367+
$requestCount = 0;
368+
$httpClient = new MockHttpClient(function ($method, $url) use (&$requestCount) {
369+
++$requestCount;
370+
if (str_contains($url, 'openid-configuration')) {
371+
return new JsonMockResponse(['jwks_uri' => 'https://www.example.com/jwks.json']);
372+
}
373+
374+
return new JsonMockResponse(
375+
['keys' => [array_merge(self::getJWK()->all(), ['use' => 'sig'])]],
376+
['response_headers' => ['Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 60)]]
377+
);
378+
});
379+
380+
$cache = new ArrayAdapter();
381+
$handler = new OidcTokenHandler(
382+
new AlgorithmManager([new ES256()]),
383+
null,
384+
self::AUDIENCE,
385+
['https://www.example.com']
386+
);
387+
$handler->enableDiscovery($cache, $httpClient, 'oidc_ttl_expires');
388+
$this->assertSame('user-expires', $handler->getUserBadgeFrom($token)->getUserIdentifier());
389+
$this->assertSame(2, $requestCount);
390+
$this->assertSame('user-expires', $handler->getUserBadgeFrom($token)->getUserIdentifier());
391+
$this->assertSame(2, $requestCount);
392+
}
314393
}

0 commit comments

Comments
 (0)