Skip to content

Commit 21782d2

Browse files
committed
feat(keycloak): added OIDC foundation
1 parent 78970f0 commit 21782d2

12 files changed

Lines changed: 648 additions & 0 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/**
4+
* Keycloak OIDC provider config factory.
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\Keycloak;
21+
22+
use phpMyFAQ\Auth\Oidc\OidcClientConfig;
23+
use phpMyFAQ\Auth\Oidc\OidcProviderConfig;
24+
use phpMyFAQ\Configuration;
25+
26+
final readonly class KeycloakProviderConfigFactory
27+
{
28+
public function __construct(
29+
private Configuration $configuration,
30+
) {
31+
}
32+
33+
public function create(): OidcProviderConfig
34+
{
35+
$baseUrl = rtrim(trim((string) $this->configuration->get('keycloak.baseUrl')), characters: '/');
36+
$realm = trim((string) $this->configuration->get('keycloak.realm'));
37+
$redirectUri = trim((string) $this->configuration->get('keycloak.redirectUri'));
38+
$scopes = preg_split('/\s+/', trim((string) $this->configuration->get('keycloak.scopes')));
39+
if ($scopes === false) {
40+
$scopes = [];
41+
}
42+
43+
if ($redirectUri === '') {
44+
$redirectUri = rtrim($this->configuration->getDefaultUrl(), characters: '/') . '/auth/keycloak/callback';
45+
}
46+
47+
return new OidcProviderConfig(
48+
provider: 'keycloak',
49+
enabled: $this->toBool($this->configuration->get('keycloak.enable')),
50+
discoveryUrl: $this->buildDiscoveryUrl($baseUrl, $realm),
51+
client: new OidcClientConfig(
52+
clientId: trim((string) $this->configuration->get('keycloak.clientId')),
53+
clientSecret: trim((string) $this->configuration->get('keycloak.clientSecret')),
54+
redirectUri: $redirectUri,
55+
scopes: array_values(array_filter($scopes, static fn(string $scope): bool => $scope !== '')),
56+
),
57+
autoProvision: $this->toBool($this->configuration->get('keycloak.autoProvision')),
58+
logoutRedirectUrl: trim((string) $this->configuration->get('keycloak.logoutRedirectUrl')),
59+
);
60+
}
61+
62+
private function buildDiscoveryUrl(string $baseUrl, string $realm): string
63+
{
64+
if ($baseUrl === '' || $realm === '') {
65+
return '';
66+
}
67+
68+
return $baseUrl . '/realms/' . rawurlencode($realm) . '/.well-known/openid-configuration';
69+
}
70+
71+
private function toBool(mixed $value): bool
72+
{
73+
return $value === true || $value === 1 || $value === '1' || $value === 'true';
74+
}
75+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
/**
4+
* OIDC client configuration.
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 SensitiveParameter;
23+
24+
final readonly class OidcClientConfig
25+
{
26+
/**
27+
* @param list<string> $scopes
28+
*/
29+
public function __construct(
30+
public string $clientId,
31+
#[SensitiveParameter]
32+
public string $clientSecret,
33+
public string $redirectUri,
34+
public array $scopes,
35+
) {
36+
}
37+
38+
public function getScopesAsString(): string
39+
{
40+
return implode(' ', $this->scopes);
41+
}
42+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php
2+
3+
/**
4+
* Typed OIDC discovery document.
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 InvalidArgumentException;
23+
24+
final readonly class OidcDiscoveryDocument
25+
{
26+
public function __construct(
27+
public string $issuer,
28+
public string $authorizationEndpoint,
29+
public string $tokenEndpoint,
30+
public string $userInfoEndpoint,
31+
public string $jwksUri,
32+
public ?string $endSessionEndpoint = null,
33+
) {
34+
}
35+
36+
/**
37+
* @param array<string, mixed> $data
38+
*/
39+
public static function fromArray(array $data): self
40+
{
41+
foreach ([
42+
'issuer',
43+
'authorization_endpoint',
44+
'token_endpoint',
45+
'userinfo_endpoint',
46+
'jwks_uri',
47+
] as $requiredKey) {
48+
if (
49+
!array_key_exists($requiredKey, $data)
50+
|| !is_string($data[$requiredKey])
51+
|| trim($data[$requiredKey]) === ''
52+
) {
53+
throw new InvalidArgumentException(sprintf(
54+
'Missing or invalid OIDC discovery field: %s',
55+
$requiredKey,
56+
));
57+
}
58+
}
59+
60+
$endSessionEndpoint = null;
61+
if (array_key_exists('end_session_endpoint', $data) && is_string($data['end_session_endpoint'])) {
62+
$endSessionEndpoint = trim($data['end_session_endpoint']);
63+
}
64+
65+
return new self(
66+
issuer: trim($data['issuer']),
67+
authorizationEndpoint: trim($data['authorization_endpoint']),
68+
tokenEndpoint: trim($data['token_endpoint']),
69+
userInfoEndpoint: trim($data['userinfo_endpoint']),
70+
jwksUri: trim($data['jwks_uri']),
71+
endSessionEndpoint: $endSessionEndpoint !== '' ? $endSessionEndpoint : null,
72+
);
73+
}
74+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
/**
4+
* OIDC discovery document loader.
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 Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
25+
use Symfony\Contracts\HttpClient\HttpClientInterface;
26+
27+
final readonly class OidcDiscoveryService
28+
{
29+
public function __construct(
30+
private HttpClientInterface $httpClient,
31+
) {
32+
}
33+
34+
/**
35+
* @throws ExceptionInterface
36+
*/
37+
public function discover(OidcProviderConfig $config): OidcDiscoveryDocument
38+
{
39+
$response = $this->httpClient->request('GET', $config->discoveryUrl);
40+
$content = $response->getContent(false);
41+
42+
if ($response->getStatusCode() >= 400) {
43+
throw new RuntimeException(sprintf(
44+
'OIDC discovery request failed for %s with status %d',
45+
$config->provider,
46+
$response->getStatusCode(),
47+
));
48+
}
49+
50+
try {
51+
/** @var array<string, mixed> $payload */
52+
$payload = json_decode($content, associative: true, depth: 512, flags: JSON_THROW_ON_ERROR);
53+
} catch (JsonException $exception) {
54+
throw new RuntimeException('OIDC discovery response is not valid JSON', previous: $exception);
55+
}
56+
57+
return OidcDiscoveryDocument::fromArray($payload);
58+
}
59+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
/**
4+
* OIDC PKCE 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+
final class OidcPkceGenerator
23+
{
24+
public function generateVerifier(int $length = 128): string
25+
{
26+
$chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-._~';
27+
$charLength = strlen($chars) - 1;
28+
$verifier = '';
29+
30+
for ($index = 0; $index < $length; ++$index) {
31+
$verifier .= $chars[random_int(0, $charLength)];
32+
}
33+
34+
return $verifier;
35+
}
36+
37+
public function generateChallenge(string $verifier): string
38+
{
39+
return rtrim(
40+
strtr(base64_encode(hash(algo: 'sha256', data: $verifier, binary: true)), from: '+/', to: '-_'),
41+
characters: '=',
42+
);
43+
}
44+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/**
4+
* OIDC provider configuration.
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+
final readonly class OidcProviderConfig
23+
{
24+
public function __construct(
25+
public string $provider,
26+
public bool $enabled,
27+
public string $discoveryUrl,
28+
public OidcClientConfig $client,
29+
public bool $autoProvision,
30+
public string $logoutRedirectUrl,
31+
) {
32+
}
33+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
/**
4+
* OIDC session 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 phpMyFAQ\Session\AbstractSession;
23+
use Symfony\Component\HttpFoundation\Session\Session;
24+
25+
final class OidcSession extends AbstractSession
26+
{
27+
final public const string OIDC_STATE = 'pmf-oidc-state';
28+
final public const string OIDC_NONCE = 'pmf-oidc-nonce';
29+
final public const string OIDC_PKCE_CODE = 'pmf-oidc-pkce-code';
30+
final public const string OIDC_ID_ASSERTION = 'pmf-oidc-id-assertion';
31+
32+
public function __construct(Session $session)
33+
{
34+
parent::__construct($session);
35+
}
36+
37+
public function setAuthorizationState(string $state, string $nonce, string $pkceVerifier): void
38+
{
39+
$this->set(self::OIDC_STATE, $state);
40+
$this->set(self::OIDC_NONCE, $nonce);
41+
$this->set(self::OIDC_PKCE_CODE, $pkceVerifier);
42+
}
43+
44+
/**
45+
* @return array{state: string, nonce: string, verifier: string}
46+
*/
47+
public function getAuthorizationState(): array
48+
{
49+
return [
50+
'state' => (string) $this->get(self::OIDC_STATE),
51+
'nonce' => (string) $this->get(self::OIDC_NONCE),
52+
'verifier' => (string) $this->get(self::OIDC_PKCE_CODE),
53+
];
54+
}
55+
56+
public function clearAuthorizationState(): void
57+
{
58+
$this->set(self::OIDC_STATE, '');
59+
$this->set(self::OIDC_NONCE, '');
60+
$this->set(self::OIDC_PKCE_CODE, '');
61+
}
62+
}

0 commit comments

Comments
 (0)