Lines 96.29% 78 / 81
Methods 86.66% 13 / 15
Classes 0.00% 0 / 1
Covered by tests of size
Name Lines Methods CRAP
 __construct 100.00% 2 / 2 100.00% 1 / 1 1
 setJwksProvider 0.00% 0 / 2 0.00% 0 / 1 2
 errorMessage 100.00% 1 / 1 100.00% 1 / 1 1
 getOAuthToken 96.29% 26 / 27 0.00% 0 / 1 6
 refreshToken 100.00% 10 / 10 100.00% 1 / 1 1
 getToken 100.00% 1 / 1 100.00% 1 / 1 1
 setToken 100.00% 27 / 27 100.00% 1 / 1 9
 clearToken 100.00% 2 / 2 100.00% 1 / 1 1
 getEntraIdSession 100.00% 1 / 1 100.00% 1 / 1 1
 getRefreshToken 100.00% 1 / 1 100.00% 1 / 1 1
 setRefreshToken 100.00% 2 / 2 100.00% 1 / 1 1
 getAccessToken 100.00% 1 / 1 100.00% 1 / 1 1
 setAccessToken 100.00% 2 / 2 100.00% 1 / 1 1
 getName 100.00% 1 / 1 100.00% 1 / 1 1
 getMail 100.00% 1 / 1 100.00% 1 / 1 1
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        // The session cookie is SameSite=Strict, so the browser does not send it on the
86        // cross-site redirect back from Microsoft: fall back to the SameSite=Lax verifier cookie.
87        $codeVerifier = $this->entraIdSession->get(EntraIdSession::ENTRA_ID_OAUTH_VERIFIER);
88        if ($codeVerifier === null || $codeVerifier === '') {
89            $codeVerifier = $this->entraIdSession->getCookie(EntraIdSession::ENTRA_ID_OAUTH_VERIFIER);
90        }
91
92        $response = $this->httpClient->request('POST', $url, [
93            'body' => [
94                'grant_type' => 'authorization_code',
95                'client_id' => AAD_OAUTH_CLIENTID,
96                'redirect_uri' => $this->configuration->getDefaultUrl() . 'services/azure/callback.php',
97                'code' => $code,
98                'code_verifier' => $codeVerifier,
99                'client_secret' => AAD_OAUTH_SECRET,
100            ],
101        ]);
102
103        $content = $response->getContent(false);
104        $statusCode = $response->getStatusCode();
105
106        if ($statusCode >= 400) {
107            try {
108                /** @var stdClass $errorPayload */
109                $errorPayload = json_decode(json: $content, associative: null, depth: 512, flags: JSON_THROW_ON_ERROR);
110                $error = (string) ($errorPayload->error ?? 'oauth_error');
111                $description = (string) ($errorPayload->error_description ?? $content);
112                throw new \RuntimeException(sprintf('OAuth token exchange failed (%s): %s', $error, $description));
113            } catch (JsonException) {
114                throw new \RuntimeException(sprintf('OAuth token exchange failed: %s', $content));
115            }
116        }
117
118        $token = json_decode(json: $content, associative: null, depth: 512, flags: JSON_THROW_ON_ERROR);
119        if (!$token instanceof stdClass) {
120            throw new \RuntimeException('OAuth token exchange returned an unexpected payload.');
121        }
122
123        return $token;
124    }
125
126    /**
127     * @throws JsonException
128     * @throws TransportExceptionInterface
129     */
130    public function refreshToken(): mixed
131    {
132        $url = 'https://login.microsoftonline.com/' . AAD_OAUTH_TENANTID . '/oauth2/v2.0/token';
133
134        $response = $this->httpClient->request('POST', $url, [
135            'body' => [
136                'grant_type' => 'refresh_token',
137                'refresh_token' => $this->getRefreshToken(),
138                'client_id' => AAD_OAUTH_CLIENTID,
139                'scope' => AAD_OAUTH_SCOPE,
140            ],
141        ]);
142
143        return json_decode(json: $response->getContent(), associative: null, depth: 512, flags: JSON_THROW_ON_ERROR);
144    }
145
146    public function getToken(): stdClass
147    {
148        return $this->token ?? throw new \RuntimeException('No Entra ID token available.');
149    }
150
151    public function setToken(#[\SensitiveParameter] stdClass $token): OAuth
152    {
153        if ($this->jwksProvider === null) {
154            $this->clearToken();
155            return $this;
156        }
157
158        try {
159            $idTokenString = (string) ($token->id_token ?? '');
160            if ($idTokenString === '' || substr_count($idTokenString, needle: '.') !== 2) {
161                $this->clearToken();
162                return $this;
163            }
164
165            $keys = $this->jwksProvider->getKeys(AAD_OAUTH_TENANTID);
166            $decoded = JWT::decode($idTokenString, $keys);
167
168            $expectedIssuers = [
169                'https://login.microsoftonline.com/' . AAD_OAUTH_TENANTID . '/v2.0',
170                'https://sts.windows.net/' . AAD_OAUTH_TENANTID . '/',
171            ];
172
173            if (!property_exists($decoded, 'aud') || $decoded->aud !== AAD_OAUTH_CLIENTID) {
174                $this->clearToken();
175                return $this;
176            }
177
178            if (!property_exists($decoded, 'iss') || !in_array($decoded->iss, $expectedIssuers, strict: true)) {
179                $this->clearToken();
180                return $this;
181            }
182
183            $this->token = $decoded;
184            $this->entraIdSession->set(EntraIdSession::ENTRA_ID_JWT, json_encode(
185                value: $this->token,
186                flags: JSON_THROW_ON_ERROR,
187            ));
188        } catch (JsonException|Throwable) {
189            $this->clearToken();
190        }
191
192        return $this;
193    }
194
195    private function clearToken(): void
196    {
197        $this->token = new stdClass();
198        $this->entraIdSession->set(EntraIdSession::ENTRA_ID_JWT, '{}');
199    }
200
201    public function getEntraIdSession(): EntraIdSession
202    {
203        return $this->entraIdSession;
204    }
205
206    public function getRefreshToken(): ?string
207    {
208        return $this->refreshToken;
209    }
210
211    public function setRefreshToken(#[\SensitiveParameter] ?string $refreshToken): OAuth
212    {
213        $this->refreshToken = $refreshToken;
214        return $this;
215    }
216
217    public function getAccessToken(): ?string
218    {
219        return $this->accessToken;
220    }
221
222    public function setAccessToken(#[\SensitiveParameter] ?string $accessToken): OAuth
223    {
224        $this->accessToken = $accessToken;
225        return $this;
226    }
227
228    public function getName(): string
229    {
230        return (string) ($this->token->name ?? '');
231    }
232
233    public function getMail(): string
234    {
235        return (string) ($this->token->preferred_username ?? '');
236    }
237}