Skip to content

Commit b74f01b

Browse files
committed
feat(keycloak): added persistent keycloak_sub support to the user schema
1 parent 2ded219 commit b74f01b

27 files changed

Lines changed: 1330 additions & 59 deletions

phpmyfaq/admin/assets/src/configuration/configuration.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,13 @@ describe('Configuration Functions', () => {
327327
<li class="nav-item" data-config-group="core" data-config-label="Main">
328328
<a class="nav-link" href="#main"></a>
329329
</li>
330+
<li class="pmf-configuration-group" data-config-group="integrations"><span>Integrations</span></li>
331+
<li class="nav-item" data-config-group="integrations" data-config-label="OAuth 2.0">
332+
<a class="nav-link" href="#oauth2"></a>
333+
</li>
334+
<li class="nav-item" data-config-group="integrations" data-config-label="Keycloak">
335+
<a class="nav-link" href="#keycloak"></a>
336+
</li>
330337
<li class="pmf-configuration-group" data-config-group="maintenance"><span>Maintenance</span></li>
331338
<li class="nav-item" data-config-group="maintenance" data-config-label="Upgrade">
332339
<a class="nav-link active" href="#upgrade"></a>
@@ -436,6 +443,59 @@ describe('Configuration Functions', () => {
436443
vi.useRealTimers();
437444
});
438445

446+
it('should show Keycloak tab and items when filtering by "keycloak"', async () => {
447+
vi.useFakeTimers();
448+
buildFilterDOM();
449+
450+
(fetchConfiguration as Mock).mockResolvedValue('');
451+
452+
handleConfigurationTabFiltering();
453+
454+
await triggerFilterAndFlush('keycloak');
455+
456+
const keycloakTab = document.querySelector('li.nav-item[data-config-label="Keycloak"]');
457+
const oauth2Tab = document.querySelector('li.nav-item[data-config-label="OAuth 2.0"]');
458+
const mainTab = document.querySelector('li.nav-item[data-config-label="Main"]');
459+
const upgradeTab = document.querySelector('li.nav-item[data-config-label="Upgrade"]');
460+
461+
expect(keycloakTab?.classList.contains('d-none')).toBe(false);
462+
expect(oauth2Tab?.classList.contains('d-none')).toBe(true);
463+
expect(mainTab?.classList.contains('d-none')).toBe(true);
464+
expect(upgradeTab?.classList.contains('d-none')).toBe(true);
465+
466+
const keycloakEnable = document.querySelector('.pmf-config-item[data-config-key="keycloak.enable"]');
467+
const keycloakClientId = document.querySelector('.pmf-config-item[data-config-key="keycloak.clientId"]');
468+
const languageItem = document.querySelector('.pmf-config-item[data-config-key="main.language"]');
469+
470+
expect(keycloakEnable?.classList.contains('d-none')).toBe(false);
471+
expect(keycloakClientId?.classList.contains('d-none')).toBe(false);
472+
expect(languageItem?.classList.contains('d-none')).toBe(true);
473+
474+
vi.useRealTimers();
475+
});
476+
477+
it('should show Keycloak tab when filtering by "client id"', async () => {
478+
vi.useFakeTimers();
479+
buildFilterDOM();
480+
481+
(fetchConfiguration as Mock).mockResolvedValue('');
482+
483+
handleConfigurationTabFiltering();
484+
485+
await triggerFilterAndFlush('client id');
486+
487+
const keycloakTab = document.querySelector('li.nav-item[data-config-label="Keycloak"]');
488+
const mainTab = document.querySelector('li.nav-item[data-config-label="Main"]');
489+
490+
expect(keycloakTab?.classList.contains('d-none')).toBe(false);
491+
expect(mainTab?.classList.contains('d-none')).toBe(true);
492+
493+
const clientIdItem = document.querySelector('.pmf-config-item[data-config-key="keycloak.clientId"]');
494+
expect(clientIdItem?.classList.contains('d-none')).toBe(false);
495+
496+
vi.useRealTimers();
497+
});
498+
439499
it('should restore all items and tabs when filter is cleared', async () => {
440500
vi.useFakeTimers();
441501
buildFilterDOM();

phpmyfaq/assets/templates/default/login.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@
7575
</a>
7676
{% endif %}
7777
{% if useSignInWithKeycloak %}
78-
<a class="w-100 py-2 mb-2 btn btn-outline-warning rounded-3" href="./auth/keycloak/authorize">
78+
<a class="w-100 py-2 mb-2 btn btn-warning rounded-3" href="./auth/keycloak/authorize">
7979
<i class="bi bi-shield-lock" aria-hidden="true"></i>
8080
{{ 'msgSignInWithKeycloak' | translate }}
8181
</a>

phpmyfaq/src/phpMyFAQ/Auth/AuthKeycloak.php

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,14 +54,22 @@ public function create(string $login, #[SensitiveParameter] string $password, st
5454
try {
5555
$result = $user->createUser($login, '', $domain);
5656
} catch (\Exception $exception) {
57-
$this->configuration->getLogger()->info($exception->getMessage());
57+
$this->configuration
58+
->getLogger()
59+
->error(sprintf('Keycloak user creation failed for "%s": %s', $login, $exception->getMessage()));
60+
return false;
61+
}
62+
63+
if (!$result) {
64+
return false;
5865
}
5966

6067
$user->setStatus('active');
6168
$user->setAuthSource(AuthenticationSourceType::AUTH_KEYCLOAK->value);
6269
$user->setUserData([
6370
'display_name' => $this->getDisplayName(),
6471
'email' => $this->getEmail(),
72+
'keycloak_sub' => $this->getSubject(),
6573
]);
6674

6775
if ($this->shouldAssignGroups()) {
@@ -127,6 +135,11 @@ private function getEmail(): string
127135
return trim((string) ($this->claims['email'] ?? ''));
128136
}
129137

138+
private function getSubject(): string
139+
{
140+
return trim((string) ($this->claims['sub'] ?? ''));
141+
}
142+
130143
private function userExists(string $login): bool
131144
{
132145
$user = $this->createUser();
@@ -156,7 +169,11 @@ private function assignUserToGroups(int $userId): void
156169
$groupMapping = $this->getGroupMapping();
157170

158171
foreach ($roleNames as $roleName) {
159-
$faqGroupName = $groupMapping[$roleName] ?? $roleName;
172+
if (!isset($groupMapping[$roleName])) {
173+
continue;
174+
}
175+
176+
$faqGroupName = $groupMapping[$roleName];
160177
$groupId = $mediumPermission->findOrCreateGroupByName($faqGroupName);
161178

162179
if ($groupId <= 0) {
@@ -166,7 +183,7 @@ private function assignUserToGroups(int $userId): void
166183
$mediumPermission->addToGroup($userId, $groupId);
167184
$this->configuration
168185
->getLogger()
169-
->info(sprintf('Added Keycloak user %s to group %s', $this->resolvedLogin, $faqGroupName));
186+
->info(sprintf('Added Keycloak user #%d to group %s', $userId, $faqGroupName));
170187
}
171188
}
172189

@@ -188,20 +205,16 @@ private function extractRoleNames(): array
188205
}
189206
}
190207

191-
$resourceAccess = $this->claims['resource_access'] ?? [];
192-
if (is_array($resourceAccess)) {
193-
foreach ($resourceAccess as $resource) {
194-
$resourceRoles = is_array($resource) ? $resource['roles'] ?? null : null;
195-
if (!is_array($resourceRoles)) {
196-
continue;
197-
}
198-
199-
foreach ($resourceRoles as $resourceRole) {
200-
if (!is_string($resourceRole) || $resourceRole === '') {
208+
$clientId = trim((string) $this->configuration->get(item: 'keycloak.clientId'));
209+
if ($clientId !== '') {
210+
$clientRoles = $this->claims['resource_access'][$clientId]['roles'] ?? [];
211+
if (is_array($clientRoles)) {
212+
foreach ($clientRoles as $clientRole) {
213+
if (!is_string($clientRole) || $clientRole === '') {
201214
continue;
202215
}
203216

204-
$roleNames[] = $resourceRole;
217+
$roleNames[] = $clientRole;
205218
}
206219
}
207220
}

phpmyfaq/src/phpMyFAQ/Auth/Oidc/OidcClient.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,12 +140,19 @@ private function decodeJsonResponse(string $content, int $statusCode, string $co
140140
}
141141

142142
try {
143-
/** @var array<string, mixed> $payload */
144143
$payload = json_decode($content, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
145144
} catch (JsonException $exception) {
146145
throw new RuntimeException(sprintf('OIDC %s response is not valid JSON', $context), previous: $exception);
147146
}
148147

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+
149156
return $payload;
150157
}
151158
}

0 commit comments

Comments
 (0)