Skip to content
Closed
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 @@ -67,6 +67,10 @@ public function create(ContainerBuilder $container, string $id, array|string $co
$tokenHandlerDefinition->replaceArgument(1, (new ChildDefinition('security.access_token_handler.oidc.jwkset'))
->replaceArgument(0, $config['keyset']));

if (!empty($config['refresh_jwks_on_kid_mismatch'])) {
$tokenHandlerDefinition->addMethodCall('enableRefreshJwksOnKidMismatch', [true]);
}

if ($config['encryption']['enabled']) {
$algorithmManager = (new ChildDefinition('security.access_token_handler.oidc.encryption'))
->replaceArgument(0, $config['encryption']['algorithms']);
Expand Down Expand Up @@ -205,6 +209,10 @@ public function addConfiguration(NodeBuilder $node): void
->scalarNode('keyset')
->info('JSON-encoded JWKSet used to sign the token (must contain a list of valid public keys).')
->end()
->booleanNode('refresh_jwks_on_kid_mismatch')
->info('When true, automatically refreshes the JWKS if the token\'s key ID (kid) is not found in the cached set.')
->defaultFalse()
->end()
->arrayNode('encryption')
->canBeEnabled()
->children()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,7 @@
<xsd:attribute name="algorithm" type="xsd:string" />
<xsd:attribute name="key" type="xsd:string" />
<xsd:attribute name="keyset" type="xsd:string" />
<xsd:attribute name="refresh-jwks-on-kid-mismatch" type="xsd:boolean" />
</xsd:complexType>

<xsd:complexType name="oidc_encryption">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
'issuers' => ['https://www.example.com'],
'audience' => 'audience',
'keyset' => '{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}',
'refresh_jwks_on_kid_mismatch' => true,
'encryption' => [
'enabled' => true,
'keyset' => '{"keys":[{"kty":"RSA","n":"abc","e":"AQAB","d":"def"}]}',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<firewall name="firewall1" provider="default">
<access-token>
<token-handler>
<oidc audience="audience" keyset='{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}' >
<oidc audience="audience" keyset='{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}' refresh-jwks-on-kid-mismatch="true">
<issuer>https://www.example.com</issuer>
<algorithm>RS256</algorithm>
</oidc>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<firewall name="firewall1" provider="default">
<access-token>
<token-handler>
<oidc audience="audience" keyset='{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}' >
<oidc audience="audience" keyset='{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}' refresh-jwks-on-kid-mismatch="true">
<issuer>https://www.example.com</issuer>
<algorithm>RS256</algorithm>
<encryption enforce="true" keyset='{"keys":[{"kty":"RSA","n":"abc","e":"AQAB","d":"def"}]}' >
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,5 @@ security:
issuers: ['https://www.example.com']
audience: 'audience'
keyset: '{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}'
refresh_jwks_on_kid_mismatch: true

Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ security:
issuers: ['https://www.example.com']
audience: 'audience'
keyset: '{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}'
refresh_jwks_on_kid_mismatch: true
encryption:
enabled: true
keyset: '{"keys":[{"kty":"RSA","n":"abc","e":"AQAB","d":"def"}]}'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,31 @@ public function testOidcUserInfoTokenHandlerConfigurationWithDiscovery()
$this->assertEquals($expectedCalls, $container->getDefinition('security.access_token_handler.firewall1')->getMethodCalls());
}

public function testOidcTokenHandlerConfigurationWithRefreshJwksOnKidMismatch()
{
$container = new ContainerBuilder();
$config = [
'token_handler' => [
'oidc' => [
'algorithms' => ['RS256'],
'issuers' => ['https://www.example.com'],
'audience' => 'audience',
'keyset' => '{"keys":[{"kty":"RSA","n":"abc","e":"AQAB"}]}',
'refresh_jwks_on_kid_mismatch' => true,
],
],
];
$factory = new AccessTokenFactory($this->createTokenHandlerFactories());
$finalizedConfig = $this->processConfig($config, $factory);
$factory->createAuthenticator($container, 'firewall1', $finalizedConfig, 'userprovider');
$def = $container->getDefinition('security.access_token_handler.firewall1');
$calls = $def->getMethodCalls();
$this->assertTrue(
\in_array(['enableRefreshJwksOnKidMismatch', [true]], $calls, true),
'Expected enableRefreshJwksOnKidMismatch(true) to be called when refresh_jwks_on_kid_mismatch is enabled.'
);
}

public function testMultipleTokenHandlersSet()
{
$config = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ security:
algorithms: [ 'ES256' ]
# tip: use https://mkjwk.org/ to generate a JWK
keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}'
refresh_jwks_on_kid_mismatch: true
encryption:
enabled: true
algorithms: ['ECDH-ES', 'A128GCM']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ security:
algorithm: 'ES256'
# tip: use https://mkjwk.org/ to generate a JWK
keyset: '{"keys":[{"kty":"EC","d":"iA_TV2zvftni_9aFAQwFO_9aypfJFCSpcCyevDvz220","crv":"P-256","x":"0QEAsI1wGI-dmYatdUZoWSRWggLEpyzopuhwk-YUnA4","y":"KYl-qyZ26HobuYwlQh-r0iHX61thfP82qqEku7i0woo"}]}'
refresh_jwks_on_kid_mismatch: true
encryption:
enabled: true
enforce: true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ final class OidcTokenHandler implements AccessTokenHandlerInterface
*/
private array $discoveryClients = [];

private bool $refreshJwksOnKidMismatch = false;

public function __construct(
private Algorithm|AlgorithmManager $signatureAlgorithm,
private JWK|JWKSet|null $signatureKeyset,
Expand All @@ -81,6 +83,11 @@ public function enableJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $dec
$this->enforceEncryption = $enforceEncryption;
}

public function enableRefreshJwksOnKidMismatch(bool $refreshJwksOnKidMismatch): void
{
$this->refreshJwksOnKidMismatch = $refreshJwksOnKidMismatch;
}

/**
* @param HttpClientInterface|HttpClientInterface[] $client
*/
Expand All @@ -107,42 +114,8 @@ 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, function () {
return $this->doFetchOidcSigningKeys();
});

$jwkset = JWKSet::createFromKeyData(['keys' => $keys]);
Expand Down Expand Up @@ -180,7 +153,18 @@ private function loadAndVerifyJws(string $accessToken, JWKSet $jwkset): array
$serializerManager = new JWSSerializerManager([new JwsCompactSerializer()]);
$jws = $serializerManager->unserialize($accessToken);

// Verify the signature
// Rotation-safe: if discovery and kid verification are enabled, ensure the token's kid exists in the current JWK set.
if ($this->refreshJwksOnKidMismatch && $this->discoveryCache && $this->discoveryClients) {
$kid = $jws->getSignature(0)->getProtectedHeader()['kid'] ?? null;
if (null !== $kid && !$this->jwksetHasKid($jwkset, $kid)) {
$this->discoveryCache->delete($this->oidcConfigurationCacheKey);
$freshKeys = $this->discoveryCache->get($this->oidcConfigurationCacheKey, function () {
return $this->doFetchOidcSigningKeys();
});
$jwkset = JWKSet::createFromKeyData(['keys' => $freshKeys]);
}
}

if (!$jwsVerifier->verifyWithKeySet($jws, $jwkset, 0)) {
throw new InvalidSignatureException();
}
Expand Down Expand Up @@ -260,4 +244,58 @@ private function decryptIfNeeded(string $accessToken): string
return $accessToken;
}
}

private function jwksetHasKid(JWKSet $set, string $kid): bool
{
foreach ($set->all() as $jwk) {
if (($jwk->has('kid') ? $jwk->get('kid') : null) === $kid) {
return true;
}
}

return false;
}

/**
* @return array<int,array<string,mixed>>
*/
private function doFetchOidcSigningKeys(): 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 = [];
foreach ($jwkSetResponses as $response) {
foreach ($response->toArray()['keys'] as $key) {
if ('sig' === ($key['use'] ?? null)) {
$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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -301,14 +301,72 @@ public function testGetsUserIdentifierWithMultipleDiscoveryEndpoints()
$this->assertTrue($cache->hasItem('oidc_config'));
}

private static function buildJWSWithKey(string $payload, JWK $jwk): string
private static function buildJWSWithKey(string $payload, JWK $jwk, ?string $kid = null): string
{
$header = ['alg' => 'ES256'];
if ($kid) {
$header['kid'] = $kid;
}

return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([
new ES256(),
])))->create()
->withPayload($payload)
->addSignature($jwk, ['alg' => 'ES256'])
->addSignature($jwk, $header)
->build()
);
}

public function testRefreshesJwksOnKidMismatch()
{
$time = time();
// Simulate an old JWK (cached) and a new JWK (rotated)
$oldKey = array_merge(self::getJWK()->all(), ['use' => 'sig', 'kid' => 'old-key']);
$newKey = array_merge(self::getSecondJWK()->all(), ['use' => 'sig', 'kid' => 'new-key']);

// Mock sequential responses: discovery config, old keyset, then refreshed keyset
$httpClient = new MockHttpClient(function ($method, $url) use ($oldKey, $newKey) {
static $callCount = 0;
++$callCount;

return match ($callCount) {
1 => new JsonMockResponse(['jwks_uri' => 'https://www.example.com/.well-known/jwks.json']),
2 => new JsonMockResponse(['keys' => [$oldKey]]),
3 => new JsonMockResponse(['jwks_uri' => 'https://www.example.com/.well-known/jwks.json']),
4 => new JsonMockResponse(['keys' => [$newKey]]),
default => throw new \RuntimeException('Unexpected call #'.$callCount),
};
});

$cache = new ArrayAdapter();
$handler = new OidcTokenHandler(
new AlgorithmManager([new ES256()]),
null,
self::AUDIENCE,
['https://www.example.com']
);

$handler->enableDiscovery($cache, $httpClient, 'oidc_config');
$handler->enableRefreshJwksOnKidMismatch(true);

// Token signed with the new key (not present in cache)
$claims = [
'iat' => $time,
'nbf' => $time,
'exp' => $time + 3600,
'iss' => 'https://www.example.com',
'aud' => self::AUDIENCE,
'sub' => 'user-rotated',
'email' => 'user@example.com',
];
$token = self::buildJWSWithKey(json_encode($claims), self::getSecondJWK(), 'new-key');

$userBadge = $handler->getUserBadgeFrom($token);

$this->assertInstanceOf(UserBadge::class, $userBadge);
$this->assertSame('user-rotated', $userBadge->getUserIdentifier());

$cachedKeys = $cache->getItem('oidc_config')->get();
$this->assertSame('new-key', $cachedKeys[0]['kid']);
}
}
Loading