Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function create(ContainerBuilder $container, string $id, array|string $co
->replaceArgument(2, $config['audience'])
->replaceArgument(3, $config['issuers'])
->replaceArgument(4, $config['claim'])
->addTag('container.reversible')
);

if (!ContainerBuilder::willBeAvailable('web-token/jwt-library', Algorithm::class, ['symfony/security-bundle'])) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcUserInfoTokenHandler;
use Symfony\Component\Security\Http\AccessToken\QueryAccessTokenExtractor;
use Symfony\Component\Security\Http\Authenticator\AccessTokenAuthenticator;
use Symfony\Component\Security\Http\Command\OidcTokenGenerateCommand;
use Symfony\Contracts\HttpClient\HttpClientInterface;

return static function (ContainerConfigurator $container) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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]);
}
Expand Down Expand Up @@ -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
Copy link
Member

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 ?

Copy link
Contributor Author

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.

*/
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) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is broken IMO:

  • $client might be undefined (if the loop is empty)
  • if the clients are different, there is no guarantee that calling stream will work. Responses must be streamed with the client that created them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ali-HENDA up to send a PR fixing this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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
within the max-age window, it must be a cache hit.

}

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same reason as above

}
}