Skip to content

Commit cf49d44

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

File tree

2 files changed

+155
-37
lines changed

2 files changed

+155
-37
lines changed

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

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

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)