Skip to content

Commit 3cd985c

Browse files
committed
Merge branch '7.4' into 8.0
* 7.4: [Console] ensure `SHELL_VERBOSITY` is always restored properly [Console] Add support for `Cursor` helper in invokable commands [MonologBridge] Improve error when HttpClient contract is installed but not the component simplify LogoutListenerTest forbid HTTP method override of GET, HEAD, CONNECT and TRACE [HttpClient] Add option `auto_upgrade_http_version` to control how the request HTTP version is handled in `HttplugClient` and `Psr18Client` [Security] Allow multiple OIDC discovery endpoints [AssetMapper] Fix links to propshaft Document BC break in AbstractController::render
2 parents c9b4cc2 + cbef3f5 commit 3cd985c

File tree

4 files changed

+232
-184
lines changed

4 files changed

+232
-184
lines changed

AccessToken/Oidc/OidcTokenHandler.php

Lines changed: 55 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,12 @@ final class OidcTokenHandler implements AccessTokenHandlerInterface
4646
private bool $enforceEncryption = false;
4747

4848
private ?CacheInterface $discoveryCache = null;
49-
private ?HttpClientInterface $discoveryClient = null;
5049
private ?string $oidcConfigurationCacheKey = null;
51-
private ?string $oidcJWKSetCacheKey = null;
50+
51+
/**
52+
* @var HttpClientInterface[]
53+
*/
54+
private array $discoveryClients = [];
5255

5356
public function __construct(
5457
private AlgorithmManager $signatureAlgorithm,
@@ -68,12 +71,18 @@ public function enableJweSupport(JWKSet $decryptionKeyset, AlgorithmManager $dec
6871
$this->enforceEncryption = $enforceEncryption;
6972
}
7073

71-
public function enableDiscovery(CacheInterface $cache, HttpClientInterface $client, string $oidcConfigurationCacheKey, string $oidcJWKSetCacheKey): void
74+
/**
75+
* @param HttpClientInterface|HttpClientInterface[] $client
76+
*/
77+
public function enableDiscovery(CacheInterface $cache, array|HttpClientInterface $client, string $oidcConfigurationCacheKey, ?string $oidcJWKSetCacheKey = null): void
7278
{
79+
if (null !== $oidcJWKSetCacheKey) {
80+
trigger_deprecation('symfony/security-http', '7.4', 'Passing $oidcJWKSetCacheKey parameter to "%s()" is deprecated.', __METHOD__);
81+
}
82+
7383
$this->discoveryCache = $cache;
74-
$this->discoveryClient = $client;
84+
$this->discoveryClients = \is_array($client) ? $client : [$client];
7585
$this->oidcConfigurationCacheKey = $oidcConfigurationCacheKey;
76-
$this->oidcJWKSetCacheKey = $oidcJWKSetCacheKey;
7786
}
7887

7988
public function getUserBadgeFrom(string $accessToken): UserBadge
@@ -82,45 +91,51 @@ public function getUserBadgeFrom(string $accessToken): UserBadge
8291
throw new \LogicException('You cannot use the "oidc" token handler since "web-token/jwt-signature" and "web-token/jwt-checker" are not installed. Try running "composer require web-token/jwt-signature web-token/jwt-checker".');
8392
}
8493

85-
if (!$this->discoveryCache && !$this->signatureKeyset) {
94+
if (!$this->discoveryClients && !$this->signatureKeyset) {
8695
throw new \LogicException('You cannot use the "oidc" token handler without JWKSet nor "discovery". Please configure JWKSet in the constructor, or call "enableDiscovery" method.');
8796
}
8897

8998
$jwkset = $this->signatureKeyset;
90-
if ($this->discoveryCache) {
91-
try {
92-
$oidcConfiguration = json_decode($this->discoveryCache->get($this->oidcConfigurationCacheKey, function (): string {
93-
$response = $this->discoveryClient->request('GET', '.well-known/openid-configuration');
94-
95-
return $response->getContent();
96-
}), true, 512, \JSON_THROW_ON_ERROR);
97-
} catch (\Throwable $e) {
98-
$this->logger?->error('An error occurred while requesting OIDC configuration.', [
99-
'error' => $e->getMessage(),
100-
'trace' => $e->getTraceAsString(),
101-
]);
102-
103-
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
104-
}
105-
106-
try {
107-
$jwkset = JWKSet::createFromJson(
108-
$this->discoveryCache->get($this->oidcJWKSetCacheKey, function () use ($oidcConfiguration): string {
109-
$response = $this->discoveryClient->request('GET', $oidcConfiguration['jwks_uri']);
110-
// we only need signature key
111-
$keys = array_filter($response->toArray()['keys'], static fn (array $key) => 'sig' === $key['use']);
112-
113-
return json_encode(['keys' => $keys]);
114-
})
115-
);
116-
} catch (\Throwable $e) {
117-
$this->logger?->error('An error occurred while requesting OIDC certs.', [
118-
'error' => $e->getMessage(),
119-
'trace' => $e->getTraceAsString(),
120-
]);
121-
122-
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
123-
}
99+
if ($this->discoveryClients) {
100+
$clients = $this->discoveryClients;
101+
$logger = $this->logger;
102+
$keys = $this->discoveryCache->get($this->oidcConfigurationCacheKey, static function () use ($clients, $logger): array {
103+
try {
104+
$configResponses = [];
105+
foreach ($clients as $client) {
106+
$configResponses[] = $client->request('GET', '.well-known/openid-configuration', [
107+
'user_data' => $client,
108+
]);
109+
}
110+
111+
$jwkSetResponses = [];
112+
foreach ($client->stream($configResponses) as $response => $chunk) {
113+
if ($chunk->isLast()) {
114+
$jwkSetResponses[] = $response->getInfo('user_data')->request('GET', $response->toArray()['jwks_uri']);
115+
}
116+
}
117+
118+
$keys = [];
119+
foreach ($jwkSetResponses as $response) {
120+
foreach ($response->toArray()['keys'] as $key) {
121+
if ('sig' === $key['use']) {
122+
$keys[] = $key;
123+
}
124+
}
125+
}
126+
127+
return $keys;
128+
} catch (\Exception $e) {
129+
$logger?->error('An error occurred while requesting OIDC certs.', [
130+
'error' => $e->getMessage(),
131+
'trace' => $e->getTraceAsString(),
132+
]);
133+
134+
throw new BadCredentialsException('Invalid credentials.', $e->getCode(), $e);
135+
}
136+
});
137+
138+
$jwkset = JWKSet::createFromKeyData(['keys' => $keys]);
124139
}
125140

126141
try {

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ CHANGELOG
7272
* Allow subclassing `#[IsGranted]`
7373
* Add `$tokenSource` argument to `#[IsCsrfTokenValid]` to support reading tokens from the query string or headers
7474
* Deprecate `RememberMeDetails::getUserFqcn()`, the user FQCN will be removed from the remember-me cookie in 8.0
75+
* Allow configuring multiple OIDC discovery base URIs
7576

7677
7.3
7778
---

Tests/AccessToken/Oidc/OidcTokenHandlerTest.php

Lines changed: 125 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
use PHPUnit\Framework\Attributes\RequiresPhpExtension;
2222
use PHPUnit\Framework\TestCase;
2323
use Psr\Log\LoggerInterface;
24+
use Symfony\Component\Cache\Adapter\ArrayAdapter;
25+
use Symfony\Component\HttpClient\MockHttpClient;
26+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
2427
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
2528
use Symfony\Component\Security\Core\User\OidcUser;
2629
use Symfony\Component\Security\Http\AccessToken\Oidc\OidcTokenHandler;
@@ -44,15 +47,15 @@ public function testGetsUserIdentifierFromSignedToken(string $claim, string $exp
4447
'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f',
4548
'email' => 'foo@example.com',
4649
];
47-
$token = $this->buildJWS(json_encode($claims));
50+
$token = self::buildJWS(json_encode($claims));
4851
$expectedUser = new OidcUser(...$claims, userIdentifier: $claims[$claim]);
4952

5053
$loggerMock = $this->createMock(LoggerInterface::class);
5154
$loggerMock->expects($this->never())->method('error');
5255

5356
$userBadge = (new OidcTokenHandler(
5457
new AlgorithmManager([new ES256()]),
55-
$this->getJWKSet(),
58+
self::getJWKSet(),
5659
self::AUDIENCE,
5760
['https://www.example.com'],
5861
$claim,
@@ -86,7 +89,7 @@ public function testThrowsAnErrorIfTokenIsInvalid(string $token)
8689

8790
(new OidcTokenHandler(
8891
new AlgorithmManager([new ES256()]),
89-
$this->getJWKSet(),
92+
self::getJWKSet(),
9093
self::AUDIENCE,
9194
['https://www.example.com'],
9295
'sub',
@@ -176,6 +179,17 @@ private static function getJWK(): JWK
176179
]);
177180
}
178181

182+
private static function getSecondJWK(): JWK
183+
{
184+
return new JWK([
185+
'kty' => 'EC',
186+
'd' => '0LCBSOYvrksazPnC0pzwY0P5MWEESUhEzbc2zJEnOsc',
187+
'crv' => 'P-256',
188+
'x' => 'N1aUu8Pd2WdClkpCQ4QCPnGjYe_bTmDgEaSoxy5LhTw',
189+
'y' => 'Yr1v-tCNxE8QgAGlartrJAi343bI8VlAaNvgCOp8Azs',
190+
]);
191+
}
192+
179193
private static function getJWKSet(): JWKSet
180194
{
181195
return new JWKSet([
@@ -189,4 +203,112 @@ private static function getJWKSet(): JWKSet
189203
self::getJWK(),
190204
]);
191205
}
206+
207+
public function testGetsUserIdentifierWithSingleDiscoveryEndpoint()
208+
{
209+
$time = time();
210+
$claims = [
211+
'iat' => $time,
212+
'nbf' => $time,
213+
'exp' => $time + 3600,
214+
'iss' => 'https://www.example.com',
215+
'aud' => self::AUDIENCE,
216+
'sub' => 'e21bf182-1538-406e-8ccb-e25a17aba39f',
217+
'email' => 'foo@example.com',
218+
];
219+
$token = $this->buildJWS(json_encode($claims));
220+
221+
$httpClient = new MockHttpClient([
222+
new JsonMockResponse(['jwks_uri' => 'https://www.example.com/.well-known/jwks.json']),
223+
new JsonMockResponse(['keys' => [array_merge(self::getJWK()->all(), ['use' => 'sig'])]]),
224+
]);
225+
226+
$cache = new ArrayAdapter();
227+
$handler = new OidcTokenHandler(
228+
new AlgorithmManager([new ES256()]),
229+
null,
230+
self::AUDIENCE,
231+
['https://www.example.com']
232+
);
233+
$handler->enableDiscovery($cache, $httpClient, 'oidc_config');
234+
235+
$userBadge = $handler->getUserBadgeFrom($token);
236+
237+
$this->assertInstanceOf(UserBadge::class, $userBadge);
238+
$this->assertSame('e21bf182-1538-406e-8ccb-e25a17aba39f', $userBadge->getUserIdentifier());
239+
}
240+
241+
public function testGetsUserIdentifierWithMultipleDiscoveryEndpoints()
242+
{
243+
$time = time();
244+
245+
$httpClient1 = new MockHttpClient(function ($method, $url) {
246+
if (str_contains($url, 'openid-configuration')) {
247+
return new JsonMockResponse(['jwks_uri' => 'https://provider1.example.com/.well-known/jwks.json']);
248+
}
249+
250+
return new JsonMockResponse(['keys' => [array_merge(self::getJWK()->all(), ['use' => 'sig'])]]);
251+
});
252+
253+
$httpClient2 = new MockHttpClient(function ($method, $url) {
254+
if (str_contains($url, 'openid-configuration')) {
255+
return new JsonMockResponse(['jwks_uri' => 'https://provider2.example.com/.well-known/jwks.json']);
256+
}
257+
258+
return new JsonMockResponse(['keys' => [array_merge(self::getSecondJWK()->all(), ['use' => 'sig'])]]);
259+
});
260+
261+
$cache = new ArrayAdapter();
262+
263+
$handler = new OidcTokenHandler(
264+
new AlgorithmManager([new ES256()]),
265+
null,
266+
self::AUDIENCE,
267+
['https://www.example.com']
268+
);
269+
$handler->enableDiscovery($cache, [$httpClient1, $httpClient2], 'oidc_config');
270+
271+
$claims1 = [
272+
'iat' => $time,
273+
'nbf' => $time,
274+
'exp' => $time + 3600,
275+
'iss' => 'https://www.example.com',
276+
'aud' => self::AUDIENCE,
277+
'sub' => 'user-from-provider1',
278+
'email' => 'user1@example.com',
279+
];
280+
$token1 = self::buildJWSWithKey(json_encode($claims1), self::getJWK());
281+
$userBadge1 = $handler->getUserBadgeFrom($token1);
282+
283+
$this->assertInstanceOf(UserBadge::class, $userBadge1);
284+
$this->assertSame('user-from-provider1', $userBadge1->getUserIdentifier());
285+
286+
$claims2 = [
287+
'iat' => $time,
288+
'nbf' => $time,
289+
'exp' => $time + 3600,
290+
'iss' => 'https://www.example.com',
291+
'aud' => self::AUDIENCE,
292+
'sub' => 'user-from-provider2',
293+
'email' => 'user2@example.com',
294+
];
295+
$token2 = self::buildJWSWithKey(json_encode($claims2), self::getSecondJWK());
296+
$userBadge2 = $handler->getUserBadgeFrom($token2);
297+
298+
$this->assertInstanceOf(UserBadge::class, $userBadge2);
299+
$this->assertSame('user-from-provider2', $userBadge2->getUserIdentifier());
300+
301+
$this->assertTrue($cache->hasItem('oidc_config'));
302+
}
303+
304+
private static function buildJWSWithKey(string $payload, JWK $jwk): string
305+
{
306+
return (new CompactSerializer())->serialize((new JWSBuilder(new AlgorithmManager([
307+
new ES256(),
308+
])))->create()
309+
->withPayload($payload)
310+
->addSignature($jwk, ['alg' => 'ES256'])
311+
->build()
312+
);
313+
}
192314
}

0 commit comments

Comments
 (0)