Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
OidcClient
100.00% covered (success)
100.00%
59 / 59
100.00% covered (success)
100.00%
6 / 6
17
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildAuthorizationUrl
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 exchangeAuthorizationCode
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 fetchUserInfo
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 buildLogoutUrl
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 decodeJsonResponse
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
5
1<?php
2
3/**
4 * OIDC HTTP client helper.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-04-18
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth\Oidc;
21
22use JsonException;
23use RuntimeException;
24use SensitiveParameter;
25use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
26use Symfony\Contracts\HttpClient\HttpClientInterface;
27
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}