Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.06% covered (success)
95.06%
77 / 81
86.67% covered (success)
86.67%
13 / 15
CRAP
0.00% covered (danger)
0.00%
0 / 1
OAuth
95.06% covered (success)
95.06%
77 / 81
86.67% covered (success)
86.67%
13 / 15
27
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setJwksProvider
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 errorMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getOAuthToken
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
5.01
 refreshToken
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 getToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setToken
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 clearToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getEntraIdSession
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRefreshToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRefreshToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getAccessToken
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAccessToken
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getMail
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3/**
4 * OAuth class for Entra ID.
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 2022-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     2022-09-09
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Auth\EntraId;
21
22use Firebase\JWT\JWT;
23use JsonException;
24use phpMyFAQ\Configuration;
25use stdClass;
26use Symfony\Component\HttpClient\HttpClient;
27use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
28use Symfony\Contracts\HttpClient\HttpClientInterface;
29use Throwable;
30
31/**
32 * Class OAuth
33 *
34 * @package phpMyFAQ\Auth\Azure
35 */
36class OAuth
37{
38    private HttpClientInterface $httpClient;
39
40    /** @var stdClass|null JWT */
41    private ?stdClass $token = null;
42
43    private ?string $refreshToken = null;
44
45    private ?string $accessToken = null;
46
47    private ?JwksProvider $jwksProvider;
48
49    /**
50     * Constructor.
51     */
52    public function __construct(
53        private readonly Configuration $configuration,
54        private readonly EntraIdSession $entraIdSession,
55        ?JwksProvider $jwksProvider = null,
56    ) {
57        $this->httpClient = HttpClient::create();
58        $this->jwksProvider = $jwksProvider;
59    }
60
61    public function setJwksProvider(?JwksProvider $jwksProvider): OAuth
62    {
63        $this->jwksProvider = $jwksProvider;
64        return $this;
65    }
66
67    /**
68     * Returns the error message.
69     */
70    public function errorMessage(string $message): string
71    {
72        return $message;
73    }
74
75    /**
76     * Returns the Authorization Code from Entra ID.
77     *
78     * @throws JsonException
79     * @throws TransportExceptionInterface
80     */
81    public function getOAuthToken(string $code): stdClass
82    {
83        $url = 'https://login.microsoftonline.com/' . AAD_OAUTH_TENANTID . '/oauth2/v2.0/token';
84
85        $codeVerifier = $this->entraIdSession->getCookie(EntraIdSession::ENTRA_ID_OAUTH_VERIFIER);
86        if ($this->entraIdSession->get(EntraIdSession::ENTRA_ID_OAUTH_VERIFIER) !== '') {
87            $codeVerifier = $this->entraIdSession->get(EntraIdSession::ENTRA_ID_OAUTH_VERIFIER);
88        }
89
90        $response = $this->httpClient->request('POST', $url, [
91            'body' => [
92                'grant_type' => 'authorization_code',
93                'client_id' => AAD_OAUTH_CLIENTID,
94                'redirect_uri' => $this->configuration->getDefaultUrl() . 'services/azure/callback.php',
95                'code' => $code,
96                'code_verifier' => $codeVerifier,
97                'client_secret' => AAD_OAUTH_SECRET,
98            ],
99        ]);
100
101        $content = $response->getContent(false);
102        $statusCode = $response->getStatusCode();
103
104        if ($statusCode >= 400) {
105            try {
106                /** @var stdClass $errorPayload */
107                $errorPayload = json_decode(json: $content, associative: null, depth: 512, flags: JSON_THROW_ON_ERROR);
108                $error = (string) ($errorPayload->error ?? 'oauth_error');
109                $description = (string) ($errorPayload->error_description ?? $content);
110                throw new \RuntimeException(sprintf('OAuth token exchange failed (%s): %s', $error, $description));
111            } catch (JsonException) {
112                throw new \RuntimeException(sprintf('OAuth token exchange failed: %s', $content));
113            }
114        }
115
116        $token = json_decode(json: $content, associative: null, depth: 512, flags: JSON_THROW_ON_ERROR);
117        if (!$token instanceof stdClass) {
118            throw new \RuntimeException('OAuth token exchange returned an unexpected payload.');
119        }
120
121        return $token;
122    }
123
124    /**
125     * @throws JsonException
126     * @throws TransportExceptionInterface
127     */
128    public function refreshToken(): mixed
129    {
130        $url = 'https://login.microsoftonline.com/' . AAD_OAUTH_TENANTID . '/oauth2/v2.0/token';
131
132        $response = $this->httpClient->request('POST', $url, [
133            'body' => [
134                'grant_type' => 'refresh_token',
135                'refresh_token' => $this->getRefreshToken(),
136                'client_id' => AAD_OAUTH_CLIENTID,
137                'scope' => AAD_OAUTH_SCOPE,
138            ],
139        ]);
140
141        return json_decode(json: $response->getContent(), associative: null, depth: 512, flags: JSON_THROW_ON_ERROR);
142    }
143
144    public function getToken(): stdClass
145    {
146        return $this->token ?? throw new \RuntimeException('No Entra ID token available.');
147    }
148
149    public function setToken(#[\SensitiveParameter] stdClass $token): OAuth
150    {
151        if ($this->jwksProvider === null) {
152            $this->clearToken();
153            return $this;
154        }
155
156        try {
157            $idTokenString = (string) ($token->id_token ?? '');
158            if ($idTokenString === '' || substr_count($idTokenString, needle: '.') !== 2) {
159                $this->clearToken();
160                return $this;
161            }
162
163            $keys = $this->jwksProvider->getKeys(AAD_OAUTH_TENANTID);
164            $decoded = JWT::decode($idTokenString, $keys);
165
166            $expectedIssuers = [
167                'https://login.microsoftonline.com/' . AAD_OAUTH_TENANTID . '/v2.0',
168                'https://sts.windows.net/' . AAD_OAUTH_TENANTID . '/',
169            ];
170
171            if (!property_exists($decoded, 'aud') || $decoded->aud !== AAD_OAUTH_CLIENTID) {
172                $this->clearToken();
173                return $this;
174            }
175
176            if (!property_exists($decoded, 'iss') || !in_array($decoded->iss, $expectedIssuers, strict: true)) {
177                $this->clearToken();
178                return $this;
179            }
180
181            $this->token = $decoded;
182            $this->entraIdSession->set(EntraIdSession::ENTRA_ID_JWT, json_encode(
183                value: $this->token,
184                flags: JSON_THROW_ON_ERROR,
185            ));
186        } catch (JsonException|Throwable) {
187            $this->clearToken();
188        }
189
190        return $this;
191    }
192
193    private function clearToken(): void
194    {
195        $this->token = new stdClass();
196        $this->entraIdSession->set(EntraIdSession::ENTRA_ID_JWT, '{}');
197    }
198
199    public function getEntraIdSession(): EntraIdSession
200    {
201        return $this->entraIdSession;
202    }
203
204    public function getRefreshToken(): ?string
205    {
206        return $this->refreshToken;
207    }
208
209    public function setRefreshToken(#[\SensitiveParameter] ?string $refreshToken): OAuth
210    {
211        $this->refreshToken = $refreshToken;
212        return $this;
213    }
214
215    public function getAccessToken(): ?string
216    {
217        return $this->accessToken;
218    }
219
220    public function setAccessToken(#[\SensitiveParameter] ?string $accessToken): OAuth
221    {
222        $this->accessToken = $accessToken;
223        return $this;
224    }
225
226    public function getName(): string
227    {
228        return (string) ($this->token->name ?? '');
229    }
230
231    public function getMail(): string
232    {
233        return (string) ($this->token->preferred_username ?? '');
234    }
235}