Skip to content

Commit 8c595fd

Browse files
bug #62369 [Security] Set OIDC JWKS cache TTL from provider headers (Ali-HENDA)
This PR was merged into the 7.4 branch. Discussion ---------- [Security] Set OIDC JWKS cache TTL from provider headers | Q | A | ------------- | --- | Branch? | 7.4 | Bug fix? | yes | New feature? | no | Deprecations? | no | Issues | Fix #62340 | License | MIT This PR aligns the OIDC JWKS discovery cache with OpenID Connect best practices by making it dynamic and respecting provider cache headers. **Before** - The JWKS was cached with a fixed lifetime, ignoring the OIDC provider’s cache policy. **After** - The cache TTL is now automatically determined from the provider response: - Prefer `Cache-Control: max-age` - Fallback to `Expires` - When multiple providers are configured, the lowest TTL is applied. Commits ------- 61acbf15475 [Security] Set OIDC JWKS cache TTL from provider headers
2 parents b883fe5 + 46803a0 commit 8c595fd

File tree

2 files changed

+145
-37
lines changed

2 files changed

+145
-37
lines changed

AccessToken/Oidc/OidcTokenHandler.php

Lines changed: 66 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->discoveryCache->get($this->oidcConfigurationCacheKey, [$this, 'computeDiscoveryKeys']);
147112

148113
$jwkset = JWKSet::createFromKeyData(['keys' => $keys]);
149114
}
@@ -173,6 +138,70 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
173138
}
174139
}
175140

141+
/**
142+
* Computes the JWKS and sets the cache item TTL from provider headers.
143+
*
144+
* The cache entry lifetime is automatically adjusted based on the lowest TTL
145+
* advertised by the providers (via "Cache-Control: max-age" or "Expires" headers).
146+
*
147+
* @internal this method is public to enable async offline cache population
148+
*/
149+
public function computeDiscoveryKeys(ItemInterface $item): array
150+
{
151+
$clients = $this->discoveryClients;
152+
$logger = $this->logger;
153+
154+
try {
155+
$configResponses = [];
156+
foreach ($clients as $client) {
157+
$configResponses[] = $client->request('GET', '.well-known/openid-configuration', [
158+
'user_data' => $client,
159+
]);
160+
}
161+
162+
$jwkSetResponses = [];
163+
foreach ($client->stream($configResponses) as $response => $chunk) {
164+
if ($chunk->isLast()) {
165+
$jwkSetResponses[] = $response->getInfo('user_data')->request('GET', $response->toArray()['jwks_uri']);
166+
}
167+
}
168+
$keys = [];
169+
$minTtl = null;
170+
foreach ($jwkSetResponses as $response) {
171+
$headers = $response->getHeaders();
172+
if (preg_match('/max-age=(\d+)/', $headers['cache-control'][0] ?? '', $m)) {
173+
$currentTtl = (int) $m[1];
174+
} elseif (0 >= $currentTtl = strtotime($headers['expires'][0] ?? '@0') - time()) {
175+
$currentTtl = null;
176+
}
177+
178+
// Apply the lowest TTL found to ensure all keys in the set are still valid
179+
if (null !== $currentTtl && (null === $minTtl || $currentTtl < $minTtl)) {
180+
$minTtl = $currentTtl;
181+
}
182+
183+
foreach ($response->toArray()['keys'] as $key) {
184+
if ('sig' === $key['use']) {
185+
$keys[] = $key;
186+
}
187+
}
188+
}
189+
190+
if (0 < ($minTtl ?? -1)) {
191+
// Cap the TTL to 30 days to avoid keeping JWKS indefinitely
192+
$item->expiresAfter(min($minTtl, 30 * 24 * 60 * 60));
193+
}
194+
195+
return $keys;
196+
} catch (\Exception $e) {
197+
$logger?->error('An error occurred while requesting OIDC certs.', [
198+
'error' => $e->getMessage(),
199+
'trace' => $e->getTraceAsString(),
200+
]);
201+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
202+
}
203+
}
204+
176205
private function loadAndVerifyJws(string $accessToken, JWKSet $jwkset): array
177206
{
178207
// Decode the token

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)