-
-
Notifications
You must be signed in to change notification settings - Fork 9.8k
[Security] Set OIDC JWKS cache TTL from provider headers #62369
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -35,6 +35,7 @@ | |
| use Symfony\Component\Security\Http\Authenticator\FallbackUserLoader; | ||
| use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; | ||
| use Symfony\Contracts\Cache\CacheInterface; | ||
| use Symfony\Contracts\Cache\ItemInterface; | ||
| use Symfony\Contracts\HttpClient\HttpClientInterface; | ||
|
|
||
| /** | ||
|
|
@@ -107,43 +108,7 @@ public function getUserBadgeFrom(string $accessToken): UserBadge | |
|
|
||
| $jwkset = $this->signatureKeyset; | ||
| if ($this->discoveryClients) { | ||
| $clients = $this->discoveryClients; | ||
| $logger = $this->logger; | ||
| $keys = $this->discoveryCache->get($this->oidcConfigurationCacheKey, static function () use ($clients, $logger): array { | ||
| try { | ||
| $configResponses = []; | ||
| foreach ($clients as $client) { | ||
| $configResponses[] = $client->request('GET', '.well-known/openid-configuration', [ | ||
| 'user_data' => $client, | ||
| ]); | ||
| } | ||
|
|
||
| $jwkSetResponses = []; | ||
| foreach ($client->stream($configResponses) as $response => $chunk) { | ||
| if ($chunk->isLast()) { | ||
| $jwkSetResponses[] = $response->getInfo('user_data')->request('GET', $response->toArray()['jwks_uri']); | ||
| } | ||
| } | ||
|
|
||
| $keys = []; | ||
| foreach ($jwkSetResponses as $response) { | ||
| foreach ($response->toArray()['keys'] as $key) { | ||
| if ('sig' === $key['use']) { | ||
| $keys[] = $key; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return $keys; | ||
| } catch (\Exception $e) { | ||
| $logger?->error('An error occurred while requesting OIDC certs.', [ | ||
| 'error' => $e->getMessage(), | ||
| 'trace' => $e->getTraceAsString(), | ||
| ]); | ||
|
|
||
| throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); | ||
| } | ||
| }); | ||
| $keys = $this->discoveryCache->get($this->oidcConfigurationCacheKey, [$this, 'computeDiscoveryKeys']); | ||
|
|
||
| $jwkset = JWKSet::createFromKeyData(['keys' => $keys]); | ||
| } | ||
|
|
@@ -173,6 +138,70 @@ public function getUserBadgeFrom(string $accessToken): UserBadge | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * Computes the JWKS and sets the cache item TTL from provider headers. | ||
| * | ||
| * The cache entry lifetime is automatically adjusted based on the lowest TTL | ||
| * advertised by the providers (via "Cache-Control: max-age" or "Expires" headers). | ||
| * | ||
| * @internal this method is public to enable async offline cache population | ||
| */ | ||
| public function computeDiscoveryKeys(ItemInterface $item): array | ||
| { | ||
| $clients = $this->discoveryClients; | ||
| $logger = $this->logger; | ||
|
|
||
| try { | ||
| $configResponses = []; | ||
| foreach ($clients as $client) { | ||
| $configResponses[] = $client->request('GET', '.well-known/openid-configuration', [ | ||
| 'user_data' => $client, | ||
| ]); | ||
| } | ||
|
|
||
| $jwkSetResponses = []; | ||
| foreach ($client->stream($configResponses) as $response => $chunk) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is broken IMO:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After checking, this issue was already present before my patch. I didn't notice it when working on the cache handling. You're absolutely right to point it out.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Ali-HENDA up to send a PR fixing this?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sure, I’ll open a PR for this. |
||
| if ($chunk->isLast()) { | ||
| $jwkSetResponses[] = $response->getInfo('user_data')->request('GET', $response->toArray()['jwks_uri']); | ||
| } | ||
| } | ||
| $keys = []; | ||
| $minTtl = null; | ||
| foreach ($jwkSetResponses as $response) { | ||
| $headers = $response->getHeaders(); | ||
| if (preg_match('/max-age=(\d+)/', $headers['cache-control'][0] ?? '', $m)) { | ||
| $currentTtl = (int) $m[1]; | ||
| } elseif (0 >= $currentTtl = strtotime($headers['expires'][0] ?? '@0') - time()) { | ||
| $currentTtl = null; | ||
| } | ||
|
|
||
| // Apply the lowest TTL found to ensure all keys in the set are still valid | ||
| if (null !== $currentTtl && (null === $minTtl || $currentTtl < $minTtl)) { | ||
| $minTtl = $currentTtl; | ||
| } | ||
|
|
||
| foreach ($response->toArray()['keys'] as $key) { | ||
| if ('sig' === $key['use']) { | ||
| $keys[] = $key; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (0 < ($minTtl ?? -1)) { | ||
| // Cap the TTL to 30 days to avoid keeping JWKS indefinitely | ||
| $item->expiresAfter(min($minTtl, 30 * 24 * 60 * 60)); | ||
| } | ||
|
|
||
| return $keys; | ||
| } catch (\Exception $e) { | ||
| $logger?->error('An error occurred while requesting OIDC certs.', [ | ||
| 'error' => $e->getMessage(), | ||
| 'trace' => $e->getTraceAsString(), | ||
| ]); | ||
| throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e); | ||
| } | ||
| } | ||
|
|
||
| private function loadAndVerifyJws(string $accessToken, JWKSet $jwkset): array | ||
| { | ||
| // Decode the token | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -311,4 +311,83 @@ private static function buildJWSWithKey(string $payload, JWK $jwk): string | |
| ->build() | ||
| ); | ||
| } | ||
|
|
||
| public function testDiscoveryCachesJwksAccordingToCacheControl() | ||
| { | ||
| $time = time(); | ||
| $claims = [ | ||
| 'iat' => $time, 'nbf' => $time, | ||
| 'exp' => $time + 3600, | ||
| 'iss' => 'https://www.example.com', | ||
| 'aud' => self::AUDIENCE, | ||
| 'sub' => 'user-cache-control', | ||
| ]; | ||
| $token = self::buildJWS(json_encode($claims)); | ||
|
|
||
| $requestCount = 0; | ||
| $httpClient = new MockHttpClient(function ($method, $url) use (&$requestCount) { | ||
| ++$requestCount; | ||
| if (str_contains($url, 'openid-configuration')) { | ||
| return new JsonMockResponse(['jwks_uri' => 'https://www.example.com/jwks.json']); | ||
| } | ||
|
|
||
| return new JsonMockResponse( | ||
| ['keys' => [array_merge(self::getJWK()->all(), ['use' => 'sig'])]], | ||
| ['response_headers' => ['Cache-Control' => 'public, max-age=120']] | ||
| ); | ||
| }); | ||
|
|
||
| $cache = new ArrayAdapter(); | ||
| $handler = new OidcTokenHandler( | ||
| new AlgorithmManager([new ES256()]), | ||
| null, | ||
| self::AUDIENCE, | ||
| ['https://www.example.com'] | ||
| ); | ||
| $handler->enableDiscovery($cache, $httpClient, 'oidc_ttl_cc'); | ||
| $this->assertSame('user-cache-control', $handler->getUserBadgeFrom($token)->getUserIdentifier()); | ||
| $this->assertSame(2, $requestCount); | ||
| $this->assertSame('user-cache-control', $handler->getUserBadgeFrom($token)->getUserIdentifier()); | ||
| $this->assertSame(2, $requestCount); | ||
|
Comment on lines
+348
to
+351
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are these lines duplicated? And does this test actually test that the cache-control header is used as cache TTL?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The lines are duplicated because the first call fills the cache (2 requests) and the second call verifies that the cached JWKS is reused (request count stays at 2). The TTL is tested implicitly: since the second call happens |
||
| } | ||
|
|
||
| public function testDiscoveryCachesJwksAccordingToExpires() | ||
| { | ||
| $time = time(); | ||
| $claims = [ | ||
| 'iat' => $time, 'nbf' => $time, | ||
| 'exp' => $time + 3600, | ||
| 'iss' => 'https://www.example.com', | ||
| 'aud' => self::AUDIENCE, | ||
| 'sub' => 'user-expires', | ||
| ]; | ||
|
|
||
| $token = self::buildJWS(json_encode($claims)); | ||
|
|
||
| $requestCount = 0; | ||
| $httpClient = new MockHttpClient(function ($method, $url) use (&$requestCount) { | ||
| ++$requestCount; | ||
| if (str_contains($url, 'openid-configuration')) { | ||
| return new JsonMockResponse(['jwks_uri' => 'https://www.example.com/jwks.json']); | ||
| } | ||
|
|
||
| return new JsonMockResponse( | ||
| ['keys' => [array_merge(self::getJWK()->all(), ['use' => 'sig'])]], | ||
| ['response_headers' => ['Expires' => gmdate('D, d M Y H:i:s \G\M\T', time() + 60)]] | ||
| ); | ||
| }); | ||
|
|
||
| $cache = new ArrayAdapter(); | ||
| $handler = new OidcTokenHandler( | ||
| new AlgorithmManager([new ES256()]), | ||
| null, | ||
| self::AUDIENCE, | ||
| ['https://www.example.com'] | ||
| ); | ||
| $handler->enableDiscovery($cache, $httpClient, 'oidc_ttl_expires'); | ||
| $this->assertSame('user-expires', $handler->getUserBadgeFrom($token)->getUserIdentifier()); | ||
| $this->assertSame(2, $requestCount); | ||
| $this->assertSame('user-expires', $handler->getUserBadgeFrom($token)->getUserIdentifier()); | ||
| $this->assertSame(2, $requestCount); | ||
|
Comment on lines
+388
to
+391
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same here.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same reason as above |
||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where is this method called publicly ?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because per PR #30572, async recomputation via Messenger only works with callables of the form [$service, 'publicMethod'], so the callback must be public.