Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
72.73% covered (warning)
72.73%
144 / 198
41.67% covered (danger)
41.67%
5 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
KeycloakAuthenticationController
72.73% covered (warning)
72.73%
144 / 198
41.67% covered (danger)
41.67%
5 / 12
122.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setCurrentUserFactory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setUserFactory
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 authorize
57.69% covered (warning)
57.69%
15 / 26
0.00% covered (danger)
0.00%
0 / 1
5.21
 logout
71.43% covered (warning)
71.43%
10 / 14
0.00% covered (danger)
0.00%
0 / 1
4.37
 callback
80.43% covered (success)
80.43%
74 / 92
0.00% covered (danger)
0.00%
0 / 1
19.16
 resolveLocalLogin
40.00% covered (danger)
40.00%
10 / 25
0.00% covered (danger)
0.00%
0 / 1
37.14
 maskLogin
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
2.03
 synchronizeKeycloakSubject
88.89% covered (success)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 resolveLogoutIdToken
66.67% covered (warning)
66.67%
8 / 12
0.00% covered (danger)
0.00%
0 / 1
5.93
 getCurrentUserService
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 createUser
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2
3/**
4 * Authentication Controller for Keycloak.
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\Controller\Frontend;
21
22use Closure;
23use Exception;
24use JsonException;
25use phpMyFAQ\Auth\AuthKeycloak;
26use phpMyFAQ\Auth\Keycloak\KeycloakProviderConfigFactory;
27use phpMyFAQ\Auth\Oidc\OidcClient;
28use phpMyFAQ\Auth\Oidc\OidcDiscoveryService;
29use phpMyFAQ\Auth\Oidc\OidcIdTokenValidator;
30use phpMyFAQ\Auth\Oidc\OidcPkceGenerator;
31use phpMyFAQ\Auth\Oidc\OidcSession;
32use phpMyFAQ\Enums\AuthenticationSourceType;
33use phpMyFAQ\Filter;
34use phpMyFAQ\User;
35use phpMyFAQ\User\CurrentUser;
36use Symfony\Component\HttpFoundation\RedirectResponse;
37use Symfony\Component\HttpFoundation\Request;
38use Symfony\Component\HttpFoundation\Response;
39use Symfony\Component\Routing\Attribute\Route;
40
41final class KeycloakAuthenticationController extends AbstractFrontController
42{
43    private ?Closure $currentUserFactory = null;
44    private ?Closure $userFactory = null;
45
46    public function __construct(
47        private readonly KeycloakProviderConfigFactory $providerConfigFactory,
48        private readonly OidcDiscoveryService $discoveryService,
49        private readonly OidcPkceGenerator $pkceGenerator,
50        private readonly OidcSession $oidcSession,
51        private readonly OidcClient $oidcClient,
52        private readonly OidcIdTokenValidator $idTokenValidator,
53    ) {
54        parent::__construct();
55    }
56
57    public function setCurrentUserFactory(?Closure $currentUserFactory): self
58    {
59        $this->currentUserFactory = $currentUserFactory;
60        return $this;
61    }
62
63    public function setUserFactory(?Closure $userFactory): self
64    {
65        $this->userFactory = $userFactory;
66        return $this;
67    }
68
69    #[Route(path: '/auth/keycloak/authorize', name: 'public.keycloak.authorize', methods: ['GET'])]
70    public function authorize(): RedirectResponse
71    {
72        try {
73            $providerConfig = $this->providerConfigFactory->create();
74            if (!$providerConfig->enabled || $providerConfig->discoveryUrl === '') {
75                return new RedirectResponse($this->configuration->getDefaultUrl());
76            }
77
78            $discoveryDocument = $this->discoveryService->discover($providerConfig);
79            $state = bin2hex(random_bytes(16));
80            $nonce = bin2hex(random_bytes(16));
81            $verifier = $this->pkceGenerator->generateVerifier();
82            $challenge = $this->pkceGenerator->generateChallenge($verifier);
83
84            $this->oidcSession->setAuthorizationState($state, $nonce, $verifier);
85
86            return new RedirectResponse($this->oidcClient->buildAuthorizationUrl(
87                $providerConfig,
88                $discoveryDocument,
89                $state,
90                $nonce,
91                $challenge,
92            ));
93        } catch (Exception $exception) {
94            $this->configuration
95                ->getLogger()
96                ->info(sprintf(
97                    'Keycloak login failed: %s at line %d at %s',
98                    $exception->getMessage(),
99                    $exception->getLine(),
100                    $exception->getFile(),
101                ));
102
103            return new RedirectResponse($this->configuration->getDefaultUrl());
104        }
105    }
106
107    #[Route(path: '/auth/keycloak/logout', name: 'public.keycloak.logout', methods: ['GET'])]
108    public function logout(): RedirectResponse
109    {
110        $user = $this->getCurrentUserService();
111
112        try {
113            $providerConfig = $this->providerConfigFactory->create();
114            $idToken = $this->resolveLogoutIdToken($user);
115
116            $user->deleteFromSession();
117            $this->oidcSession->clearIdToken();
118
119            if (!$providerConfig->enabled || $providerConfig->discoveryUrl === '') {
120                return new RedirectResponse($this->configuration->getDefaultUrl());
121            }
122
123            $discoveryDocument = $this->discoveryService->discover($providerConfig);
124            $logoutUrl = $this->oidcClient->buildLogoutUrl($providerConfig, $discoveryDocument, $idToken);
125
126            return new RedirectResponse($logoutUrl ?? $this->configuration->getDefaultUrl());
127        } catch (Exception) {
128            $user->deleteFromSession();
129            $this->oidcSession->clearIdToken();
130            return new RedirectResponse($this->configuration->getDefaultUrl());
131        }
132    }
133
134    /**
135     * @throws Exception
136     */
137    #[Route(path: '/auth/keycloak/callback', name: 'public.keycloak.callback', methods: ['GET'])]
138    public function callback(Request $request): Response
139    {
140        $providerConfig = $this->providerConfigFactory->create();
141        if (!$providerConfig->enabled || $providerConfig->discoveryUrl === '') {
142            return new RedirectResponse($this->configuration->getDefaultUrl());
143        }
144
145        $code = Filter::filterVar($request->query->get('code'), FILTER_SANITIZE_SPECIAL_CHARS, '');
146        $state = Filter::filterVar($request->query->get('state'), FILTER_SANITIZE_SPECIAL_CHARS, '');
147        $error = Filter::filterVar($request->query->get('error_description'), FILTER_SANITIZE_SPECIAL_CHARS, '');
148
149        if ($error !== '') {
150            $this->configuration->getLogger()->warning(sprintf('Keycloak callback error: %s', $error));
151            return new RedirectResponse($this->configuration->getDefaultUrl());
152        }
153
154        $redirect = new RedirectResponse($this->configuration->getDefaultUrl());
155        $authorizationState = $this->oidcSession->getAuthorizationState();
156
157        if (
158            $code === ''
159            || $state === ''
160            || $authorizationState['state'] === ''
161            || !hash_equals($authorizationState['state'], $state)
162        ) {
163            $this->oidcSession->clearAuthorizationState();
164            return $redirect;
165        }
166
167        try {
168            $discoveryDocument = $this->discoveryService->discover($providerConfig);
169            $token = $this->oidcClient->exchangeAuthorizationCode(
170                $providerConfig,
171                $discoveryDocument,
172                $code,
173                $authorizationState['verifier'],
174            );
175            $idTokenClaims = $this->idTokenValidator->validate(
176                (string) ($token['id_token'] ?? ''),
177                $discoveryDocument,
178                $providerConfig->client->clientId,
179                $authorizationState['nonce'],
180            );
181            $claims = $this->oidcClient->fetchUserInfo($discoveryDocument, (string) $token['access_token']);
182
183            $idTokenSub = (string) ($idTokenClaims['sub'] ?? '');
184            $userInfoSub = (string) ($claims['sub'] ?? '');
185            if ($idTokenSub === '' || $userInfoSub === '' || !hash_equals($idTokenSub, $userInfoSub)) {
186                $this->configuration
187                    ->getLogger()
188                    ->warning('Keycloak subject mismatch between ID token and UserInfo; aborting login.');
189                $this->oidcSession->clearAuthorizationState();
190                return $redirect;
191            }
192
193            $login = $this->resolveLocalLogin($claims);
194            if ($login === '') {
195                $this->oidcSession->clearAuthorizationState();
196                return $redirect;
197            }
198            $auth = new AuthKeycloak($this->configuration, $providerConfig, $claims, $login, $this->userFactory);
199
200            if (!$auth->isValidLogin($login)) {
201                $this->configuration
202                    ->getLogger()
203                    ->warning(sprintf('Keycloak login not valid for user: %s', $this->maskLogin($login)));
204                $this->oidcSession->clearAuthorizationState();
205                return $redirect;
206            }
207
208            if (!$auth->checkCredentials($login, '')) {
209                $this->configuration
210                    ->getLogger()
211                    ->warning(sprintf('Keycloak credentials not valid for user: %s', $this->maskLogin($login)));
212                $this->oidcSession->clearAuthorizationState();
213                return $redirect;
214            }
215
216            $user = $this->getCurrentUserService();
217            if (!$user->getUserByLogin($login)) {
218                $this->configuration
219                    ->getLogger()
220                    ->warning(sprintf('Keycloak user lookup failed for login: %s', $this->maskLogin($login)));
221                $this->oidcSession->clearAuthorizationState();
222                return $redirect;
223            }
224
225            if (!$this->synchronizeKeycloakSubject($user, $claims)) {
226                $this->configuration
227                    ->getLogger()
228                    ->warning(sprintf('Keycloak subject mismatch for user: %s', $this->maskLogin($login)));
229                $this->oidcSession->clearAuthorizationState();
230                return $redirect;
231            }
232
233            $user->setLoggedIn(true);
234            $user->setAuthSource(AuthenticationSourceType::AUTH_KEYCLOAK->value);
235            $user->updateSessionId(true);
236            $user->saveToSession();
237            $user->setTokenData([
238                'refresh_token' => (string) ($token['refresh_token'] ?? ''),
239                'access_token' => (string) $token['access_token'],
240                'code_verifier' => $authorizationState['verifier'],
241                'jwt' => [
242                    'id_token' => (string) ($token['id_token'] ?? ''),
243                    'userinfo' => $claims,
244                ],
245            ]);
246            $user->setSuccess(true);
247            $this->oidcSession->clearAuthorizationState();
248            $this->oidcSession->setIdToken((string) ($token['id_token'] ?? ''));
249
250            return $redirect;
251        } catch (Exception $exception) {
252            $this->configuration->getLogger()->error(sprintf('Keycloak login failed: %s', $exception->getMessage()), [
253                'exception' => $exception,
254            ]);
255            $this->oidcSession->clearAuthorizationState();
256
257            return new RedirectResponse($this->configuration->getDefaultUrl());
258        }
259    }
260
261    /** @param array<string, mixed> $claims */
262    private function resolveLocalLogin(array $claims): string
263    {
264        $preferredUsername = trim((string) ($claims['preferred_username'] ?? ''));
265        $email = trim((string) ($claims['email'] ?? ''));
266        $subject = trim((string) ($claims['sub'] ?? ''));
267
268        if ($subject !== '') {
269            $user = $this->createUser();
270            $userId = $user->getUserIdByKeycloakSub($subject);
271            if ($userId > 0 && $user->getUserById($userId)) {
272                return $user->getLogin();
273            }
274        }
275
276        if ($preferredUsername !== '' && $this->createUser()->getUserByLogin($preferredUsername, false)) {
277            return $preferredUsername;
278        }
279
280        if ($email !== '') {
281            $user = $this->createUser();
282            $userId = $user->getUserIdByEmail($email);
283            if ($userId > 0 && $user->getUserById($userId)) {
284                return $user->getLogin();
285            }
286        }
287
288        if ($preferredUsername !== '') {
289            return $preferredUsername;
290        }
291
292        if ($email !== '') {
293            return $email;
294        }
295
296        $this->configuration
297            ->getLogger()
298            ->warning(
299                'Keycloak login rejected: claims are missing both preferred_username and email; refusing to auto-provision the sub.',
300            );
301
302        return '';
303    }
304
305    private function maskLogin(string $login): string
306    {
307        $login = trim($login);
308        if ($login === '') {
309            return '<empty>';
310        }
311
312        $secret = (string) $this->configuration->get('security.salt');
313
314        return 'hmac:' . substr(hash_hmac('sha256', $login, $secret), offset: 0, length: 12);
315    }
316
317    /** @param array<string, mixed> $claims */
318    private function synchronizeKeycloakSubject(CurrentUser $user, array $claims): bool
319    {
320        $subject = trim((string) ($claims['sub'] ?? ''));
321        if ($subject === '') {
322            return true;
323        }
324
325        $linkedSubject = trim((string) $user->getUserData('keycloak_sub'));
326        if ($linkedSubject !== '' && !hash_equals($linkedSubject, $subject)) {
327            return false;
328        }
329
330        if ($linkedSubject === '') {
331            return $user->setUserData(['keycloak_sub' => $subject]);
332        }
333
334        return true;
335    }
336
337    private function resolveLogoutIdToken(CurrentUser $user): string
338    {
339        $idToken = trim($this->oidcSession->getIdToken());
340        if ($idToken !== '') {
341            return $idToken;
342        }
343
344        $jwtPayload = trim((string) $user->getUserData('jwt'));
345        if ($jwtPayload === '') {
346            return '';
347        }
348
349        try {
350            $jwt = json_decode($jwtPayload, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
351        } catch (JsonException) {
352            return '';
353        }
354
355        if (!is_array($jwt)) {
356            return '';
357        }
358
359        return trim((string) ($jwt['id_token'] ?? ''));
360    }
361
362    private function getCurrentUserService(): CurrentUser
363    {
364        if ($this->currentUserFactory instanceof Closure) {
365            $currentUser = ($this->currentUserFactory)();
366            if ($currentUser instanceof CurrentUser) {
367                return $currentUser;
368            }
369        }
370
371        return $this->currentUser;
372    }
373
374    private function createUser(): User
375    {
376        if ($this->userFactory instanceof Closure) {
377            $user = ($this->userFactory)();
378            if ($user instanceof User) {
379                return $user;
380            }
381        }
382
383        return new User($this->configuration);
384    }
385}