Skip to content

Commit b452fae

Browse files
committed
feat: add keycloak oidc support and docs verification
1 parent 99c235c commit b452fae

28 files changed

Lines changed: 725 additions & 37 deletions

.github/workflows/build.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ jobs:
6262
package-manager-cache: false
6363
node-version: 24
6464

65+
- name: Setup Python for docs build
66+
uses: actions/setup-python@v6
67+
with:
68+
python-version: '3.x'
69+
70+
- name: Install MkDocs
71+
run: python -m pip install --upgrade pip mkdocs
72+
6573
- name: Setup pnpm
6674
uses: pnpm/action-setup@v6
6775
with:
@@ -90,3 +98,6 @@ jobs:
9098

9199
- name: Run Vitest tests
92100
run: pnpm test
101+
102+
- name: Build documentation
103+
run: mkdocs build --strict

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@
135135
"test:coverage": "./phpmyfaq/src/libs/bin/phpunit --coverage-text",
136136
"test:coverage-html": "./phpmyfaq/src/libs/bin/phpunit --coverage-html coverage",
137137
"bench": "./phpmyfaq/src/libs/bin/phpbench run tests/Benchmarks --report=aggregate",
138+
"docs:build": "mkdocs build --strict",
138139
"version:get": "php scripts/get-version.php",
139140
"version:sync": "pnpm version $(php scripts/get-version.php) --no-git-tag-version --allow-same-version",
140141
"version:check": "php scripts/check-version.php"

docs/administration.md

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -499,7 +499,7 @@ Navigate to the **Configuration → Translation** tab to configure your translat
499499
- **Amazon**: AWS Access Key ID, Secret Access Key, and region
500500
- **LibreTranslate**: Server URL and optional API key
501501

502-
For detailed setup instructions for each provider, see the [AI Translation Guide](9-ai-translation.md).
502+
For detailed setup instructions for each provider, see the [AI Translation Guide](ai-translation.md).
503503

504504
#### 5.2.13.3 Translating Content
505505

@@ -596,7 +596,7 @@ The AI will translate:
596596
- Re-translate if formatting is broken
597597

598598
For comprehensive documentation, see:
599-
- [Complete AI Translation Guide](9-ai-translation.md) - Full documentation
599+
- [Complete AI Translation Guide](ai-translation.md) - Full documentation
600600

601601
## 5.3 Statistics
602602

@@ -791,7 +791,7 @@ To back up the whole data located on your web server, you can run our simple bac
791791
Here you can edit the general, FAQ specific, search, spam protection, spam control center, SEO related, layout
792792
settings, Mail setup for SMTP, API settings, online update settings, and if enabled, LDAP configuration of phpMyFAQ.
793793

794-
You can find a detailed description of all settings in the [Configuration key reference](8-configuration.md).
794+
You can find a detailed description of all settings in the [Configuration key reference](configuration.md).
795795

796796
### 5.6.2 FAQ Multi-sites
797797

@@ -879,14 +879,19 @@ phpMyFAQ can integrate with external identity providers for administrator and fr
879879

880880
Keycloak support uses OpenID Connect Authorization Code flow with PKCE.
881881
You can enable it in the administration under `Configuration` -> `Security` -> `Keycloak`.
882+
For a worked configuration example, see the dedicated [Keycloak Integration guide](keycloak.md).
882883

883884
Recommended Keycloak client settings:
884885

885886
- Client type: confidential
886887
- Standard flow enabled
887888
- Direct access grants disabled unless you need them for other tools
889+
- PKCE code challenge method: `S256`
890+
- Root URL: `https://faq.example.com/`
891+
- Home URL: `https://faq.example.com/`
888892
- Valid redirect URI: `https://faq.example.com/auth/keycloak/callback`
889893
- Valid post logout redirect URI: `https://faq.example.com/`
894+
- Web origin: `https://faq.example.com`
890895

891896
Minimum phpMyFAQ configuration:
892897

@@ -897,27 +902,47 @@ Minimum phpMyFAQ configuration:
897902
5. Set the `Client secret`
898903
6. Set the `Redirect URI` to your phpMyFAQ callback URL
899904
7. Keep `Scopes` at least on `openid profile email`
905+
8. Optionally set `Logout redirect URL` to the page users should see after provider logout
906+
907+
Example phpMyFAQ values for a production setup:
908+
909+
- `keycloak.baseUrl`: `https://sso.example.com`
910+
- `keycloak.realm`: `faq`
911+
- `keycloak.clientId`: `phpmyfaq`
912+
- `keycloak.redirectUri`: `https://faq.example.com/auth/keycloak/callback`
913+
- `keycloak.scopes`: `openid profile email`
914+
- `keycloak.logoutRedirectUrl`: `https://faq.example.com/`
900915

901916
Optional settings:
902917

903918
- Enable automatic provisioning if phpMyFAQ should create local users on first successful Keycloak login
904919
- Enable automatic group assignment if phpMyFAQ should assign local groups from Keycloak roles
920+
- Enable group synchronization on login if phpMyFAQ should remove stale memberships for mapped Keycloak groups
905921
- Add a JSON role-to-group mapping if Keycloak role names should map to different phpMyFAQ group names
906922
- Set a logout redirect URL if users should return to a specific page after provider logout
923+
- Use a JSON mapping such as `{"admin":"Administrators","faq-editors":"Editors"}` if Keycloak role names and phpMyFAQ group names differ
907924

908925
phpMyFAQ resolves users in this order:
909926

910-
1. preferred username from Keycloak
911-
2. existing user by email address
912-
3. automatic provisioning if enabled
927+
1. existing user linked by stored Keycloak subject (`sub`)
928+
2. preferred username from Keycloak
929+
3. existing user by email address
930+
4. automatic provisioning if enabled
913931

914932
If automatic provisioning is disabled, users must already exist in phpMyFAQ before they can sign in with Keycloak.
915933

916-
Group assignment is additive in the current implementation:
934+
Group assignment behavior:
917935

918-
- mapped or unmapped Keycloak roles can create phpMyFAQ groups automatically
936+
- only roles listed in the JSON mapping are managed by phpMyFAQ
919937
- matched groups are added to the user on login
920-
- existing phpMyFAQ group memberships are not removed automatically
938+
- if group synchronization on login is enabled, stale memberships for mapped groups are removed during login
939+
- phpMyFAQ groups outside the configured Keycloak mapping are left untouched
940+
941+
Troubleshooting:
942+
943+
- If login works but logout does not return to phpMyFAQ, verify `Valid post logout redirect URI` in Keycloak and `keycloak.logoutRedirectUrl` in phpMyFAQ
944+
- If users are created but not added to groups, make sure permission level `medium` is enabled and the Keycloak roles actually match your JSON mapping keys
945+
- If an existing user cannot log in, check whether the stored Keycloak subject (`sub`) is already linked to a different account
921946

922947
### 5.7.2 Using Microsoft Entra ID
923948

docs/configuration.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,9 @@ each setting controls. These values are set during installation and can be chang
133133
| `keycloak.scopes` | Scopes | `openid profile email` | Space-separated scopes requested during login. |
134134
| `keycloak.autoProvision` | Automatically create phpMyFAQ users on first Keycloak login | `false` | When enabled, phpMyFAQ creates a local user automatically if no matching account exists yet. |
135135
| `keycloak.groupAutoAssign` | Automatically assign phpMyFAQ groups from Keycloak roles | `false` | When enabled and permission level `medium` is active, phpMyFAQ assigns users to groups derived from Keycloak roles on login. |
136-
| `keycloak.groupMapping` | Role to group mapping | *(empty)* | JSON object mapping Keycloak role names to phpMyFAQ group names, for example `{"admin":"Administrators"}`. Unmapped roles keep their original name. |
137-
| `keycloak.logoutRedirectUrl` | Logout redirect URL | *(empty)* | URL users should be redirected to after logging out from Keycloak. |
136+
| `keycloak.groupSyncOnLogin` | Synchronize mapped phpMyFAQ groups on login | `false` | When enabled, phpMyFAQ also removes stale memberships for groups managed by the Keycloak role mapping during login. |
137+
| `keycloak.groupMapping` | Role to group mapping | *(empty)* | JSON object mapping Keycloak role names to phpMyFAQ group names, for example `{"admin":"Administrators","faq-editors":"Editors"}`. Only mapped roles are managed for assignment and synchronization. |
138+
| `keycloak.logoutRedirectUrl` | Logout redirect URL | *(empty)* | URL users should be redirected to after logging out from Keycloak, for example `https://faq.example.com/`. |
138139
| `security.enableGoogleReCaptchaV2` | Enable Invisible Google ReCAPTCHA v2 | `false` | Enables Google reCAPTCHA v2 to protect forms from spam and abuse. |
139140
| `security.googleReCaptchaV2SiteKey` | Google ReCAPTCHA v2 site key | *(empty)* | The site key from your Google reCAPTCHA v2 registration. |
140141
| `security.googleReCaptchaV2SecretKey` | Google ReCAPTCHA v2 secret key | *(empty)* | The secret key from your Google reCAPTCHA v2 registration. |

docs/keycloak.md

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
# Keycloak Integration
2+
3+
phpMyFAQ supports Keycloak as an OpenID Connect provider for frontend and administration logins.
4+
The integration uses Authorization Code flow with PKCE.
5+
6+
## 1. Prerequisites
7+
8+
Before you configure phpMyFAQ, make sure you have:
9+
10+
- a reachable Keycloak server, for example `https://sso.example.com`
11+
- a realm for phpMyFAQ users, for example `faq`
12+
- a confidential client for phpMyFAQ
13+
- the public URL of your phpMyFAQ installation, for example `https://faq.example.com/`
14+
15+
## 2. Recommended Keycloak client settings
16+
17+
Recommended client settings for a phpMyFAQ installation at `https://faq.example.com/`:
18+
19+
- Client type: confidential
20+
- Standard flow enabled
21+
- Direct access grants are disabled unless required for another integration
22+
- Service accounts disabled
23+
- PKCE code challenge method: `S256`
24+
- Root URL: `https://faq.example.com/`
25+
- Home URL: `https://faq.example.com/`
26+
- Valid redirect URIs:
27+
- `https://faq.example.com/auth/keycloak/callback`
28+
- Valid post logout redirect URIs:
29+
- `https://faq.example.com/`
30+
- Web origins:
31+
- `https://faq.example.com`
32+
33+
## 3. phpMyFAQ configuration
34+
35+
In phpMyFAQ, open:
36+
37+
- `Configuration`
38+
- `Security`
39+
- `Keycloak`
40+
41+
Typical values:
42+
43+
- `keycloak.enable`: `true`
44+
- `keycloak.baseUrl`: `https://sso.example.com`
45+
- `keycloak.realm`: `faq`
46+
- `keycloak.clientId`: `phpmyfaq`
47+
- `keycloak.clientSecret`: `<client secret from Keycloak>`
48+
- `keycloak.redirectUri`: `https://faq.example.com/auth/keycloak/callback`
49+
- `keycloak.scopes`: `openid profile email`
50+
- `keycloak.logoutRedirectUrl`: `https://faq.example.com/`
51+
52+
Optional user and group settings:
53+
54+
- `keycloak.autoProvision`: `true`
55+
- `keycloak.groupAutoAssign`: `true`
56+
- `keycloak.groupSyncOnLogin`: `true`
57+
- `keycloak.groupMapping`: `{"admin":"Administrators","faq-editors":"Editors"}`
58+
59+
## 4. User resolution and account linking
60+
61+
phpMyFAQ resolves Keycloak users in this order:
62+
63+
1. existing user linked by stored Keycloak subject (`sub`)
64+
2. preferred username from Keycloak
65+
3. existing user by email address
66+
4. automatic provisioning, if enabled
67+
68+
The stored Keycloak subject is the durable link between a local phpMyFAQ account and the external identity.
69+
70+
If automatic provisioning is disabled, users must already exist in phpMyFAQ before they can sign in.
71+
72+
## 5. Group mapping behavior
73+
74+
Group handling is intentionally conservative:
75+
76+
- only roles listed in `keycloak.groupMapping` are managed by phpMyFAQ
77+
- mapped groups are added on login when `keycloak.groupAutoAssign` is enabled
78+
- stale memberships are removed only for mapped groups when `keycloak.groupSyncOnLogin` is enabled
79+
- phpMyFAQ groups outside the configured mapping are left untouched
80+
81+
Example mapping:
82+
83+
```json
84+
{
85+
"admin": "Administrators",
86+
"faq-editors": "Editors"
87+
}
88+
```
89+
90+
This means:
91+
92+
- Keycloak role `admin` maps to phpMyFAQ group `Administrators`
93+
- Keycloak role `faq-editors` maps to phpMyFAQ group `Editors`
94+
95+
## 6. Logout behavior
96+
97+
phpMyFAQ logs the user out locally and then redirects to Keycloak logout when:
98+
99+
- Keycloak sign-in is enabled
100+
- the current user is authenticated through Keycloak
101+
102+
For a reliable provider logout:
103+
104+
- set `keycloak.logoutRedirectUrl` in phpMyFAQ
105+
- make sure the same URL is listed as a valid post-logout redirect URI in Keycloak
106+
107+
## 7. Troubleshooting
108+
109+
If login works but logout does not return to phpMyFAQ:
110+
111+
- verify `keycloak.logoutRedirectUrl`
112+
- verify the matching valid post-logout redirect URI in Keycloak
113+
114+
If users are created but not assigned to groups:
115+
116+
- verify permission level `medium`
117+
- verify `keycloak.groupAutoAssign` is enabled
118+
- verify the Keycloak role names exactly match the JSON mapping keys
119+
120+
If group synchronization removes the wrong memberships:
121+
122+
- check `keycloak.groupMapping`
123+
- remember that only mapped groups are managed
124+
125+
If an existing user cannot log in:
126+
127+
- check whether the stored Keycloak subject (`sub`) is already linked to another local account

mkdocs.yml

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ site_name: phpMyFAQ v4.2
22
theme:
33
name: readthedocs
44
highlightjs: true
5+
not_in_nav: |
6+
/s3-storage-checklist.md
7+
/keys/**
58
extra_css:
69
- css/custom.css
710
plugins:
@@ -15,9 +18,10 @@ nav:
1518
- 6. Use phpMyFAQ: 'usage.md'
1619
- 7. Manage phpMyFAQ: 'administration.md'
1720
- 8. Configuration reference: 'configuration.md'
18-
- 9. AI-Assisted Translation feature: 'ai-translation.md'
19-
- 10. Developer documentation: 'development.md'
20-
- 11. Plugins: 'plugins.md'
21-
- 12. MCP Server: 'mcp-server.md'
22-
- 13. Release: 'release.md'
23-
- 14. Thank you!: 'thank-you.md'
21+
- 9. Keycloak Integration: 'keycloak.md'
22+
- 10. AI-Assisted Translation feature: 'ai-translation.md'
23+
- 11. Developer documentation: 'development.md'
24+
- 12. Plugins: 'plugins.md'
25+
- 13. MCP Server: 'mcp-server.md'
26+
- 14. Release: 'release.md'
27+
- 15. Thank you!: 'thank-you.md'

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
"release:artifacts": "./scripts/prepare-release-artifacts.sh",
3939
"release:sign": "./scripts/sign-release-artifacts.sh",
4040
"sbom": "./scripts/generate-sbom.sh",
41+
"docs:build": "mkdocs build --strict",
4142
"eslint": "eslint .",
4243
"lint": "prettier --check .",
4344
"lint:fix": "prettier --write .",

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,7 @@ describe('Configuration Functions', () => {
361361
<div id="keycloak">
362362
<div class="pmf-config-item" data-config-key="keycloak.enable">Enable Keycloak sign-in</div>
363363
<div class="pmf-config-item" data-config-key="keycloak.clientId">Client ID</div>
364+
<div class="pmf-config-item" data-config-key="keycloak.groupSyncOnLogin">Synchronize groups on login</div>
364365
<div class="pmf-config-item" data-config-key="keycloak.groupMapping">Role to group mapping</div>
365366
</div>
366367
<div id="upgrade">

phpmyfaq/src/phpMyFAQ/Auth/AuthKeycloak.php

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,31 +160,56 @@ private function assignUserToGroups(int $userId): void
160160
return;
161161
}
162162

163-
$roleNames = $this->extractRoleNames();
164-
if ($roleNames === []) {
163+
$mediumPermission = $this->createMediumPermission();
164+
$groupMapping = $this->getGroupMapping();
165+
if ($groupMapping === []) {
165166
return;
166167
}
167168

168-
$mediumPermission = $this->createMediumPermission();
169-
$groupMapping = $this->getGroupMapping();
169+
$currentGroupIds = $mediumPermission->getUserGroups($userId);
170+
$desiredGroupIds = [];
171+
$roleNames = $this->extractRoleNames();
170172

171173
foreach ($roleNames as $roleName) {
172-
if (!isset($groupMapping[$roleName])) {
174+
if (!array_key_exists($roleName, $groupMapping)) {
173175
continue;
174176
}
175-
176177
$faqGroupName = $groupMapping[$roleName];
177178
$groupId = $mediumPermission->findOrCreateGroupByName($faqGroupName);
178-
179179
if ($groupId <= 0) {
180180
continue;
181181
}
182182

183+
$desiredGroupIds[] = $groupId;
184+
if (in_array($groupId, $currentGroupIds, true)) {
185+
continue;
186+
}
187+
183188
$mediumPermission->addToGroup($userId, $groupId);
184189
$this->configuration
185190
->getLogger()
186191
->info(sprintf('Added Keycloak user #%d to group %s', $userId, $faqGroupName));
187192
}
193+
194+
if (!$this->shouldSynchronizeGroupsOnLogin()) {
195+
return;
196+
}
197+
198+
foreach (array_values(array_unique($groupMapping)) as $groupName) {
199+
$groupId = $mediumPermission->getGroupId($groupName);
200+
if ($groupId <= 0) {
201+
continue;
202+
}
203+
204+
if (!in_array($groupId, $currentGroupIds, true) || in_array($groupId, $desiredGroupIds, true)) {
205+
continue;
206+
}
207+
208+
$mediumPermission->removeFromGroup($userId, $groupId);
209+
$this->configuration
210+
->getLogger()
211+
->info(sprintf('Removed Keycloak user #%d from group %s', $userId, $groupName));
212+
}
188213
}
189214

190215
/**
@@ -242,6 +267,11 @@ private function toBool(mixed $value): bool
242267
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
243268
}
244269

270+
private function shouldSynchronizeGroupsOnLogin(): bool
271+
{
272+
return $this->toBool($this->configuration->get(item: 'keycloak.groupSyncOnLogin'));
273+
}
274+
245275
private function createUser(): User
246276
{
247277
if ($this->userFactory instanceof Closure) {

phpmyfaq/src/phpMyFAQ/Controller/Administration/Api/ConfigurationTabController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,7 @@ private function logSecurityConfigChanges(array $changedKeys, array $oldConfig,
331331
'keycloak.scopes',
332332
'keycloak.autoProvision',
333333
'keycloak.groupAutoAssign',
334+
'keycloak.groupSyncOnLogin',
334335
'keycloak.groupMapping',
335336
'keycloak.logoutRedirectUrl',
336337
];

0 commit comments

Comments
 (0)