Skip to content

Commit 63aadc1

Browse files
committed
feat(keycloak): added controller and OIDC helpers
1 parent 21782d2 commit 63aadc1

25 files changed

Lines changed: 1204 additions & 14 deletions

phpmyfaq/assets/templates/admin/login.twig

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,18 @@
7878
<i class="bi bi-windows" aria-hidden="true"></i>
7979
{{ msgSignInWithMicrosoft }}
8080
</a>
81-
{% if isWebAuthnEnabled %}
82-
<a class="w-100 py-2 mb-2 btn btn-outline-primary rounded-3" href="../services/webauthn">
83-
<i class="bi bi-key" aria-hidden="true"></i>
84-
{{ 'msgSignInWithPasskey' | translate }}
85-
</a>
86-
{% endif %}
81+
{% endif %}
82+
{% if hasSignInWithKeycloakActive %}
83+
<a class="w-100 py-2 mb-2 btn btn-outline-dark rounded-3" href="../auth/keycloak/authorize">
84+
<i class="bi bi-shield-lock" aria-hidden="true"></i>
85+
{{ msgSignInWithKeycloak }}
86+
</a>
87+
{% endif %}
88+
{% if isWebAuthnEnabled %}
89+
<a class="w-100 py-2 mb-2 btn btn-outline-primary rounded-3" href="../services/webauthn">
90+
<i class="bi bi-key" aria-hidden="true"></i>
91+
{{ 'msgSignInWithPasskey' | translate }}
92+
</a>
8793
{% endif %}
8894
</div>
8995
</div>

phpmyfaq/assets/templates/default/login.twig

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,12 @@
7474
{{ 'msgSignInWithMicrosoft' | translate }}
7575
</a>
7676
{% endif %}
77+
{% if useSignInWithKeycloak %}
78+
<a class="w-100 py-2 mb-2 btn btn-outline-warning rounded-3" href="./auth/keycloak/authorize">
79+
<i class="bi bi-shield-lock" aria-hidden="true"></i>
80+
{{ 'msgSignInWithKeycloak' | translate }}
81+
</a>
82+
{% endif %}
7783
{% if isWebAuthnEnabled %}
7884
<a class="w-100 py-2 mb-2 btn btn-outline-primary rounded-3" href="./services/webauthn">
7985
<i class="bi bi-key" aria-hidden="true"></i>
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
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+
18+
declare(strict_types=1);
19+
20+
namespace phpMyFAQ\Auth;
21+
22+
use Closure;
23+
use phpMyFAQ\Auth;
24+
use phpMyFAQ\Auth\Oidc\OidcProviderConfig;
25+
use phpMyFAQ\Configuration;
26+
use phpMyFAQ\Core\Exception;
27+
use phpMyFAQ\Enums\AuthenticationSourceType;
28+
use phpMyFAQ\User;
29+
use SensitiveParameter;
30+
31+
class AuthKeycloak extends Auth implements AuthDriverInterface
32+
{
33+
/** @param array<string, mixed> $claims */
34+
public function __construct(
35+
Configuration $configuration,
36+
private readonly OidcProviderConfig $providerConfig,
37+
private readonly array $claims,
38+
private readonly string $resolvedLogin,
39+
private readonly ?Closure $userFactory = null,
40+
) {
41+
parent::__construct($configuration);
42+
}
43+
44+
/**
45+
* @throws Exception
46+
*/
47+
public function create(string $login, #[SensitiveParameter] string $password, string $domain = ''): bool
48+
{
49+
$result = false;
50+
$user = $this->createUser();
51+
52+
try {
53+
$result = $user->createUser($login, '', $domain);
54+
} catch (\Exception $exception) {
55+
$this->configuration->getLogger()->info($exception->getMessage());
56+
}
57+
58+
$user->setStatus('active');
59+
$user->setAuthSource(AuthenticationSourceType::AUTH_KEYCLOAK->value);
60+
$user->setUserData([
61+
'display_name' => $this->getDisplayName(),
62+
'email' => $this->getEmail(),
63+
]);
64+
65+
return $result;
66+
}
67+
68+
public function update(string $login, #[SensitiveParameter] string $password): bool
69+
{
70+
return true;
71+
}
72+
73+
public function delete(string $login): bool
74+
{
75+
return true;
76+
}
77+
78+
public function checkCredentials(
79+
string $login,
80+
#[SensitiveParameter]
81+
string $password,
82+
?array $optionalData = null,
83+
): bool {
84+
if ($login !== $this->resolvedLogin) {
85+
return false;
86+
}
87+
88+
if ($this->userExists($login)) {
89+
return true;
90+
}
91+
92+
if (!$this->providerConfig->autoProvision) {
93+
return false;
94+
}
95+
96+
return $this->create($login, '');
97+
}
98+
99+
public function isValidLogin(string $login, ?array $optionalData = null): int
100+
{
101+
return $login === $this->resolvedLogin ? 1 : 0;
102+
}
103+
104+
private function getDisplayName(): string
105+
{
106+
$name = trim((string) ($this->claims['name'] ?? ''));
107+
if ($name !== '') {
108+
return $name;
109+
}
110+
111+
$preferredUsername = trim((string) ($this->claims['preferred_username'] ?? ''));
112+
if ($preferredUsername !== '') {
113+
return $preferredUsername;
114+
}
115+
116+
return $this->resolvedLogin;
117+
}
118+
119+
private function getEmail(): string
120+
{
121+
return trim((string) ($this->claims['email'] ?? ''));
122+
}
123+
124+
private function userExists(string $login): bool
125+
{
126+
$user = $this->createUser();
127+
return $user->getUserByLogin($login, false);
128+
}
129+
130+
private function createUser(): User
131+
{
132+
if ($this->userFactory instanceof Closure) {
133+
return ($this->userFactory)();
134+
}
135+
136+
return new User($this->configuration);
137+
}
138+
}

phpmyfaq/src/phpMyFAQ/Auth/Keycloak/KeycloakProviderConfigFactory.php

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
namespace phpMyFAQ\Auth\Keycloak;
2121

22+
use InvalidArgumentException;
2223
use phpMyFAQ\Auth\Oidc\OidcClientConfig;
2324
use phpMyFAQ\Auth\Oidc\OidcProviderConfig;
2425
use phpMyFAQ\Configuration;
@@ -40,17 +41,27 @@ public function create(): OidcProviderConfig
4041
$scopes = [];
4142
}
4243

44+
$enabled = $this->toBool($this->configuration->get('keycloak.enable'));
45+
46+
if ($enabled && ($baseUrl === '' || $realm === '')) {
47+
$missing = array_filter([
48+
$baseUrl === '' ? 'baseUrl' : null,
49+
$realm === '' ? 'realm' : null,
50+
]);
51+
throw new InvalidArgumentException(sprintf('Keycloak enabled but missing: %s', implode(' and ', $missing)));
52+
}
53+
4354
if ($redirectUri === '') {
4455
$redirectUri = rtrim($this->configuration->getDefaultUrl(), characters: '/') . '/auth/keycloak/callback';
4556
}
4657

4758
return new OidcProviderConfig(
4859
provider: 'keycloak',
49-
enabled: $this->toBool($this->configuration->get('keycloak.enable')),
60+
enabled: $enabled,
5061
discoveryUrl: $this->buildDiscoveryUrl($baseUrl, $realm),
5162
client: new OidcClientConfig(
5263
clientId: trim((string) $this->configuration->get('keycloak.clientId')),
53-
clientSecret: trim((string) $this->configuration->get('keycloak.clientSecret')),
64+
clientSecret: (string) $this->configuration->get('keycloak.clientSecret'),
5465
redirectUri: $redirectUri,
5566
scopes: array_values(array_filter($scopes, static fn(string $scope): bool => $scope !== '')),
5667
),
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
/**
4+
* OIDC HTTP client helper.
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+
18+
declare(strict_types=1);
19+
20+
namespace phpMyFAQ\Auth\Oidc;
21+
22+
use JsonException;
23+
use RuntimeException;
24+
use SensitiveParameter;
25+
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
26+
use Symfony\Contracts\HttpClient\HttpClientInterface;
27+
28+
final readonly class OidcClient
29+
{
30+
public function __construct(
31+
private HttpClientInterface $httpClient,
32+
) {
33+
}
34+
35+
public function buildAuthorizationUrl(
36+
OidcProviderConfig $config,
37+
OidcDiscoveryDocument $discoveryDocument,
38+
string $state,
39+
string $nonce,
40+
string $codeChallenge,
41+
): string {
42+
$query = http_build_query([
43+
'response_type' => 'code',
44+
'client_id' => $config->client->clientId,
45+
'redirect_uri' => $config->client->redirectUri,
46+
'scope' => $config->client->getScopesAsString(),
47+
'state' => $state,
48+
'nonce' => $nonce,
49+
'code_challenge' => $codeChallenge,
50+
'code_challenge_method' => 'S256',
51+
]);
52+
53+
return $discoveryDocument->authorizationEndpoint . '?' . $query;
54+
}
55+
56+
/**
57+
* @return array<string, mixed>
58+
* @throws ExceptionInterface
59+
*/
60+
public function exchangeAuthorizationCode(
61+
OidcProviderConfig $config,
62+
OidcDiscoveryDocument $discoveryDocument,
63+
string $code,
64+
#[SensitiveParameter]
65+
string $codeVerifier,
66+
): array {
67+
$response = $this->httpClient->request('POST', $discoveryDocument->tokenEndpoint, [
68+
'body' => [
69+
'grant_type' => 'authorization_code',
70+
'code' => $code,
71+
'client_id' => $config->client->clientId,
72+
'client_secret' => $config->client->clientSecret,
73+
'redirect_uri' => $config->client->redirectUri,
74+
'code_verifier' => $codeVerifier,
75+
],
76+
]);
77+
78+
$payload = $this->decodeJsonResponse($response->getContent(false), $response->getStatusCode(), 'token');
79+
if (
80+
!array_key_exists('access_token', $payload)
81+
|| !is_string($payload['access_token'])
82+
|| $payload['access_token'] === ''
83+
) {
84+
throw new RuntimeException('OIDC token response did not contain a valid access_token');
85+
}
86+
87+
return $payload;
88+
}
89+
90+
/**
91+
* @return array<string, mixed>
92+
* @throws ExceptionInterface
93+
*/
94+
public function fetchUserInfo(
95+
OidcDiscoveryDocument $discoveryDocument,
96+
#[SensitiveParameter]
97+
string $accessToken,
98+
): array {
99+
$response = $this->httpClient->request('GET', $discoveryDocument->userInfoEndpoint, [
100+
'headers' => [
101+
'Authorization' => 'Bearer ' . $accessToken,
102+
],
103+
]);
104+
105+
return $this->decodeJsonResponse($response->getContent(false), $response->getStatusCode(), 'userinfo');
106+
}
107+
108+
public function buildLogoutUrl(
109+
OidcProviderConfig $config,
110+
OidcDiscoveryDocument $discoveryDocument,
111+
#[SensitiveParameter]
112+
string $idTokenHint = '',
113+
): ?string {
114+
if ($discoveryDocument->endSessionEndpoint === null || $discoveryDocument->endSessionEndpoint === '') {
115+
return null;
116+
}
117+
118+
$query = [
119+
'client_id' => $config->client->clientId,
120+
];
121+
122+
if ($config->logoutRedirectUrl !== '') {
123+
$query['post_logout_redirect_uri'] = $config->logoutRedirectUrl;
124+
}
125+
126+
if ($idTokenHint !== '') {
127+
$query['id_token_hint'] = $idTokenHint;
128+
}
129+
130+
return $discoveryDocument->endSessionEndpoint . '?' . http_build_query($query);
131+
}
132+
133+
/**
134+
* @return array<string, mixed>
135+
*/
136+
private function decodeJsonResponse(string $content, int $statusCode, string $context): array
137+
{
138+
if ($statusCode >= 400) {
139+
throw new RuntimeException(sprintf('OIDC %s request failed with status %d', $context, $statusCode));
140+
}
141+
142+
try {
143+
/** @var array<string, mixed> $payload */
144+
$payload = json_decode($content, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
145+
} catch (JsonException $exception) {
146+
throw new RuntimeException(sprintf('OIDC %s response is not valid JSON', $context), previous: $exception);
147+
}
148+
149+
return $payload;
150+
}
151+
}

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,18 @@ public function discover(OidcProviderConfig $config): OidcDiscoveryDocument
4848
}
4949

5050
try {
51-
/** @var array<string, mixed> $payload */
5251
$payload = json_decode($content, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
5352
} catch (JsonException $exception) {
5453
throw new RuntimeException('OIDC discovery response is not valid JSON', previous: $exception);
5554
}
5655

56+
if (!is_array($payload)) {
57+
throw new RuntimeException(sprintf(
58+
'OIDC discovery response is not a JSON object/array, got %s',
59+
gettype($payload),
60+
));
61+
}
62+
5763
return OidcDiscoveryDocument::fromArray($payload);
5864
}
5965
}

0 commit comments

Comments
 (0)