diff --git a/AccessToken/Oidc/OidcTokenHandler.php b/AccessToken/Oidc/OidcTokenHandler.php index 58b1e4b8..849df3d9 100644 --- a/AccessToken/Oidc/OidcTokenHandler.php +++ b/AccessToken/Oidc/OidcTokenHandler.php @@ -33,6 +33,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; /** @@ -93,43 +94,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]); } @@ -159,6 +124,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) { + 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 diff --git a/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php b/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php index 208cae9f..0c1fc9df 100644 --- a/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php +++ b/Tests/AccessToken/Oidc/OidcTokenHandlerTest.php @@ -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); + } + + 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); + } }