Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
92.21% covered (success)
92.21%
142 / 154
73.68% covered (warning)
73.68%
14 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
AuthKeycloak
92.21% covered (success)
92.21%
142 / 154
73.68% covered (warning)
73.68%
14 / 19
67.00
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
 create
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
6
 update
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 delete
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 checkCredentials
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 isValidLogin
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 getDisplayName
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getEmail
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getSubject
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 findUser
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 shouldAssignGroups
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 assignUserToGroups
86.11% covered (success)
86.11%
31 / 36
0.00% covered (danger)
0.00%
0 / 1
12.39
 extractRoleNames
87.50% covered (success)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
10.20
 getGroupMapping
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
6.56
 toBool
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 shouldSynchronizeGroupsOnLogin
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 redactIdentifier
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 createUser
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 createMediumPermission
80.00% covered (success)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
1<?php
2
3/**
4 * Manages user authentication with Keycloak via OIDC.
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;
21
22use Closure;
23use phpMyFAQ\Auth;
24use phpMyFAQ\Auth\Oidc\OidcProviderConfig;
25use phpMyFAQ\Configuration;
26use phpMyFAQ\Core\Exception;
27use phpMyFAQ\Enums\AuthenticationSourceType;
28use phpMyFAQ\Permission\MediumPermission;
29use phpMyFAQ\User;
30use SensitiveParameter;
31
32class AuthKeycloak extends Auth implements AuthDriverInterface
33{
34    /** @param array<string, mixed> $claims */
35    public function __construct(
36        Configuration $configuration,
37        private readonly OidcProviderConfig $providerConfig,
38        private readonly array $claims,
39        private readonly string $resolvedLogin,
40        private readonly ?Closure $userFactory = null,
41        private readonly ?Closure $mediumPermissionFactory = null,
42    ) {
43        parent::__construct($configuration);
44    }
45
46    /**
47     * @throws Exception
48     */
49    public function create(string $login, #[SensitiveParameter] string $password, string $domain = ''): bool
50    {
51        $user = $this->createUser();
52
53        try {
54            $result = $user->createUser($login, '', $domain);
55        } catch (\Exception $exception) {
56            $this->configuration
57                ->getLogger()
58                ->error(sprintf(
59                    'Keycloak user creation failed for "%s": %s',
60                    $this->redactIdentifier($login),
61                    $exception->getMessage(),
62                ));
63            return false;
64        }
65
66        if (!$result) {
67            return false;
68        }
69
70        try {
71            $saved = $user->setUserData([
72                'display_name' => $this->getDisplayName(),
73                'email' => $this->getEmail(),
74                'keycloak_sub' => $this->getSubject(),
75            ]);
76        } catch (\Exception $exception) {
77            $this->configuration
78                ->getLogger()
79                ->error(sprintf(
80                    'Keycloak user data persistence failed for "%s": %s',
81                    $this->redactIdentifier($login),
82                    $exception->getMessage(),
83                ));
84            return false;
85        }
86
87        if (!$saved) {
88            $this->configuration
89                ->getLogger()
90                ->error(sprintf(
91                    'Keycloak user data persistence returned false for "%s"',
92                    $this->redactIdentifier($login),
93                ));
94            return false;
95        }
96
97        $user->setStatus('active');
98        $user->setAuthSource(AuthenticationSourceType::AUTH_KEYCLOAK->value);
99
100        if ($this->shouldAssignGroups()) {
101            $this->assignUserToGroups($user->getUserId());
102        }
103
104        return true;
105    }
106
107    public function update(string $login, #[SensitiveParameter] string $password): bool
108    {
109        return true;
110    }
111
112    public function delete(string $login): bool
113    {
114        return true;
115    }
116
117    /**
118     * @throws Exception
119     */
120    public function checkCredentials(
121        string $login,
122        #[SensitiveParameter]
123        string $password,
124        ?array $optionalData = null,
125    ): bool {
126        if ($login !== $this->resolvedLogin) {
127            return false;
128        }
129
130        $existingUser = $this->findUser($login);
131        if ($existingUser instanceof User) {
132            if ($this->shouldSynchronizeGroupsOnLogin()) {
133                $this->assignUserToGroups($existingUser->getUserId());
134            }
135
136            return true;
137        }
138
139        if (!$this->providerConfig->autoProvision) {
140            return false;
141        }
142
143        return $this->create($login, '');
144    }
145
146    public function isValidLogin(string $login, ?array $optionalData = null): int
147    {
148        return $login === $this->resolvedLogin ? 1 : 0;
149    }
150
151    private function getDisplayName(): string
152    {
153        $name = trim((string) ($this->claims['name'] ?? ''));
154        if ($name !== '') {
155            return $name;
156        }
157
158        $preferredUsername = trim((string) ($this->claims['preferred_username'] ?? ''));
159        if ($preferredUsername !== '') {
160            return $preferredUsername;
161        }
162
163        return $this->resolvedLogin;
164    }
165
166    private function getEmail(): string
167    {
168        return trim((string) ($this->claims['email'] ?? ''));
169    }
170
171    private function getSubject(): string
172    {
173        return trim((string) ($this->claims['sub'] ?? ''));
174    }
175
176    private function findUser(string $login): ?User
177    {
178        $user = $this->createUser();
179        return $user->getUserByLogin($login, false) ? $user : null;
180    }
181
182    private function shouldAssignGroups(): bool
183    {
184        return (
185            $this->toBool($this->configuration->get(item: 'keycloak.groupAutoAssign'))
186            && $this->configuration->get(item: 'security.permLevel') === 'medium'
187        );
188    }
189
190    private function assignUserToGroups(int $userId): void
191    {
192        if ($userId <= 0) {
193            return;
194        }
195
196        $mediumPermission = $this->createMediumPermission();
197        $groupMapping = $this->getGroupMapping();
198        if ($groupMapping === []) {
199            return;
200        }
201
202        $currentGroupIds = $mediumPermission->getUserGroups($userId);
203        $desiredGroupIds = [];
204        $roleNames = $this->extractRoleNames();
205
206        foreach ($roleNames as $roleName) {
207            if (!array_key_exists($roleName, $groupMapping)) {
208                continue;
209            }
210            $faqGroupName = $groupMapping[$roleName];
211            $groupId = $mediumPermission->findOrCreateGroupByName($faqGroupName);
212            if ($groupId <= 0) {
213                continue;
214            }
215
216            $desiredGroupIds[] = $groupId;
217            if (in_array($groupId, $currentGroupIds, strict: true)) {
218                continue;
219            }
220
221            $mediumPermission->addToGroup($userId, $groupId);
222            $this->configuration
223                ->getLogger()
224                ->info(sprintf('Added Keycloak user #%d to group %s', $userId, $faqGroupName));
225        }
226
227        if (!$this->shouldSynchronizeGroupsOnLogin()) {
228            return;
229        }
230
231        foreach (array_values(array_unique($groupMapping)) as $groupName) {
232            $groupId = $mediumPermission->getGroupId($groupName);
233            if ($groupId <= 0) {
234                continue;
235            }
236
237            if (
238                !in_array($groupId, $currentGroupIds, strict: true)
239                || in_array($groupId, $desiredGroupIds, strict: true)
240            ) {
241                continue;
242            }
243
244            $mediumPermission->removeFromGroup($userId, $groupId);
245            $this->configuration
246                ->getLogger()
247                ->info(sprintf('Removed Keycloak user #%d from group %s', $userId, $groupName));
248        }
249    }
250
251    /**
252     * @return array<string>
253     */
254    private function extractRoleNames(): array
255    {
256        $roleNames = [];
257
258        $realmRoles = $this->claims['realm_access']['roles'] ?? [];
259        if (is_array($realmRoles)) {
260            foreach ($realmRoles as $realmRole) {
261                if (!is_string($realmRole) || $realmRole === '') {
262                    continue;
263                }
264
265                $roleNames[] = $realmRole;
266            }
267        }
268
269        $clientId = trim((string) $this->configuration->get(item: 'keycloak.clientId'));
270        if ($clientId !== '') {
271            $clientRoles = $this->claims['resource_access'][$clientId]['roles'] ?? [];
272            if (is_array($clientRoles)) {
273                foreach ($clientRoles as $clientRole) {
274                    if (!is_string($clientRole) || $clientRole === '') {
275                        continue;
276                    }
277
278                    $roleNames[] = $clientRole;
279                }
280            }
281        }
282
283        return array_values(array_unique($roleNames));
284    }
285
286    /**
287     * @return array<string, string>
288     */
289    private function getGroupMapping(): array
290    {
291        $groupMapping = $this->configuration->get(item: 'keycloak.groupMapping');
292        if (!is_string($groupMapping) || trim($groupMapping) === '') {
293            return [];
294        }
295
296        $decoded = json_decode($groupMapping, associative: true);
297        if (!is_array($decoded)) {
298            return [];
299        }
300
301        $mapping = [];
302        foreach ($decoded as $keycloakGroup => $faqGroup) {
303            if (!is_string($faqGroup)) {
304                continue;
305            }
306
307            $mapping[(string) $keycloakGroup] = $faqGroup;
308        }
309
310        return $mapping;
311    }
312
313    private function toBool(mixed $value): bool
314    {
315        return filter_var($value, FILTER_VALIDATE_BOOLEAN);
316    }
317
318    private function shouldSynchronizeGroupsOnLogin(): bool
319    {
320        return (
321            $this->toBool($this->configuration->get(item: 'keycloak.groupSyncOnLogin'))
322            && $this->configuration->get(item: 'security.permLevel') === 'medium'
323        );
324    }
325
326    private function redactIdentifier(string $identifier): string
327    {
328        if (str_contains($identifier, '@')) {
329            [$local, $domain] = explode('@', string: $identifier, limit: 2);
330            return $local[0] . '***@' . $domain;
331        }
332
333        if (mb_strlen($identifier) <= 3) {
334            return str_repeat('*', mb_strlen($identifier));
335        }
336
337        return mb_substr($identifier, start: 0, length: 3) . '…';
338    }
339
340    /**
341     * @throws Exception
342     */
343    private function createUser(): User
344    {
345        if ($this->userFactory instanceof Closure) {
346            $user = ($this->userFactory)();
347            if ($user instanceof User) {
348                return $user;
349            }
350        }
351
352        return new User($this->configuration);
353    }
354
355    private function createMediumPermission(): MediumPermission
356    {
357        if ($this->mediumPermissionFactory instanceof Closure) {
358            $mediumPermission = ($this->mediumPermissionFactory)();
359            if ($mediumPermission instanceof MediumPermission) {
360                return $mediumPermission;
361            }
362        }
363
364        return new MediumPermission($this->configuration);
365    }
366}