Repository: symfony/symfony
File: src/Symfony/Component/Security/Http/AccessToken/Oidc/OidcTokenHandler.php (PHP)

Code before:
                } 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);
                }
            });

            $jwkset = JWKSet::createFromKeyData(['keys' => $keys]);
        }

        try {
            $accessToken = $this->decryptIfNeeded($accessToken);
            $claims = $this->loadAndVerifyJws($accessToken, $jwkset);
            $this->verifyClaims($claims);

            if (empty($claims[$this->claim])) {
                throw new MissingClaimException(\sprintf('"%s" claim not found.', $this->claim));
            }

            // UserLoader argument can be overridden by a UserProvider on AccessTokenAuthenticator::authenticate
            return new UserBadge($claims[$this->claim], new FallbackUserLoader(function () use ($claims) {
                $claims['user_identifier'] = $claims[$this->claim];

                return $this->createUser($claims);
            }), $claims);
        } catch (\Exception $e) {
            $this->logger?->error('An error occurred while decoding and validating the token.', [
                '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
        $jwsVerifier = new JWSVerifier($this->signatureAlgorithm);
        $serializerManager = new JWSSerializerManager([new JwsCompactSerializer()]);
        $jws = $serializerManager->unserialize($accessToken);

        // Verify the signature
        if (!$jwsVerifier->verifyWithKeySet($jws, $jwkset, 0)) {
            throw new InvalidSignatureException();
        }

        $headerCheckerManager = new Checker\HeaderCheckerManager([

Review comment (bug):
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.

Code after:
        }
    }

    /**
     * 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;
                    }
                }
            }