Lines 100.00% 59 / 59
Methods 100.00% 6 / 6
Classes 100.00% 1 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 buildAuthorizationUrl 100.00% 11 / 11 100.00% 1 / 1 1
 exchangeAuthorizationCode 100.00% 16 / 16 100.00% 1 / 1 4
 fetchUserInfo 100.00% 6 / 6 100.00% 1 / 1 1
 buildLogoutUrl 100.00% 10 / 10 100.00% 1 / 1 5
 decodeJsonResponse 100.00% 15 / 15 100.00% 1 / 1 5
28final readonly class OidcClient
29{
30    public function __construct(
31        private HttpClientInterface $httpClient,
32    ) {
33    }
34
35    public function buildAuthorizationUrl(
36        OidcProviderConfig $config,
37        OidcDiscoveryDocument $discoveryDocument,
38        string $state,
39        string $nonce,
40        string $codeChallenge,
41    ): string {
42        $query = http_build_query([
43            'response_type' => 'code',
44            'client_id' => $config->client->clientId,
45            'redirect_uri' => $config->client->redirectUri,
46            'scope' => $config->client->getScopesAsString(),
47            'state' => $state,
48            'nonce' => $nonce,
49            'code_challenge' => $codeChallenge,
50            'code_challenge_method' => 'S256',
51        ]);
52
53        return $discoveryDocument->authorizationEndpoint . '?' . $query;
54    }
55
56    /**
57     * @return array<string, mixed>
58     * @throws ExceptionInterface
59     */
60    public function exchangeAuthorizationCode(
61        OidcProviderConfig $config,
62        OidcDiscoveryDocument $discoveryDocument,
63        string $code,
64        #[SensitiveParameter]
65        string $codeVerifier,
66    ): array {
67        $response = $this->httpClient->request('POST', $discoveryDocument->tokenEndpoint, [
68            'body' => [
69                'grant_type' => 'authorization_code',
70                'code' => $code,
71                'client_id' => $config->client->clientId,
72                'client_secret' => $config->client->clientSecret,
73                'redirect_uri' => $config->client->redirectUri,
74                'code_verifier' => $codeVerifier,
75            ],
76        ]);
77
78        $payload = $this->decodeJsonResponse($response->getContent(false), $response->getStatusCode(), 'token');
79        if (
80            !array_key_exists('access_token', $payload)
81            || !is_string($payload['access_token'])
82            || $payload['access_token'] === ''
83        ) {
84            throw new RuntimeException('OIDC token response did not contain a valid access_token');
85        }
86
87        return $payload;
88    }
89
90    /**
91     * @return array<string, mixed>
92     * @throws ExceptionInterface
93     */
94    public function fetchUserInfo(
95        OidcDiscoveryDocument $discoveryDocument,
96        #[SensitiveParameter]
97        string $accessToken,
98    ): array {
99        $response = $this->httpClient->request('GET', $discoveryDocument->userInfoEndpoint, [
100            'headers' => [
101                'Authorization' => 'Bearer ' . $accessToken,
102            ],
103        ]);
104
105        return $this->decodeJsonResponse($response->getContent(false), $response->getStatusCode(), 'userinfo');
106    }
107
108    public function buildLogoutUrl(
109        OidcProviderConfig $config,
110        OidcDiscoveryDocument $discoveryDocument,
111        #[SensitiveParameter]
112        string $idTokenHint = '',
113    ): ?string {
114        if ($discoveryDocument->endSessionEndpoint === null || $discoveryDocument->endSessionEndpoint === '') {
115            return null;
116        }
117
118        $query = [
119            'client_id' => $config->client->clientId,
120        ];
121
122        if ($config->logoutRedirectUrl !== '') {
123            $query['post_logout_redirect_uri'] = $config->logoutRedirectUrl;
124        }
125
126        if ($idTokenHint !== '') {
127            $query['id_token_hint'] = $idTokenHint;
128        }
129
130        return $discoveryDocument->endSessionEndpoint . '?' . http_build_query($query);
131    }
132
133    /**
134     * @return array<string, mixed>
135     */
136    private function decodeJsonResponse(string $content, int $statusCode, string $context): array
137    {
138        if ($statusCode >= 400) {
139            throw new RuntimeException(sprintf('OIDC %s request failed with status %d', $context, $statusCode));
140        }
141
142        try {
143            $payload = json_decode($content, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
144        } catch (JsonException $exception) {
145            throw new RuntimeException(sprintf('OIDC %s response is not valid JSON', $context), previous: $exception);
146        }
147
148        if (!is_array($payload)) {
149            throw new RuntimeException(sprintf(
150                'OIDC %s response is not a JSON object/array, got %s',
151                $context,
152                gettype($payload),
153            ));
154        }
155
156        $normalizedPayload = [];
157        foreach ($payload as $payloadKey => $payloadValue) {
158            $normalizedPayload[(string) $payloadKey] = $payloadValue;
159        }
160
161        return $normalizedPayload;
162    }
163}