310 lines
9.3 KiB
PHP
310 lines
9.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace KTXM\AuthenticationProviderOidc;
|
|
|
|
use KTXC\Service\UserService;
|
|
use KTXF\Security\Authentication\AuthenticationProviderAbstract;
|
|
use KTXF\Security\Authentication\ProviderContext;
|
|
use KTXF\Security\Authentication\ProviderResult;
|
|
|
|
/**
|
|
* OpenID Connect Identity Provider
|
|
*
|
|
* Implements OIDC authentication flow for SSO.
|
|
* Can be used as primary (redirect to IdP) or secondary (step-up authentication).
|
|
*/
|
|
class Provider extends AuthenticationProviderAbstract
|
|
{
|
|
public function __construct(
|
|
private readonly OidcClient $client,
|
|
private readonly UserService $userService,
|
|
) { }
|
|
|
|
// =========================================================================
|
|
// Provider Implementation
|
|
// =========================================================================
|
|
|
|
public function type(): string
|
|
{
|
|
return 'authentication';
|
|
}
|
|
|
|
public function identifier(): string
|
|
{
|
|
return 'oidc';
|
|
}
|
|
|
|
public function method(): string
|
|
{
|
|
return self::METHOD_REDIRECT;
|
|
}
|
|
|
|
public function label(): string
|
|
{
|
|
return 'OpenID Connect';
|
|
}
|
|
|
|
public function description(): string
|
|
{
|
|
return 'Authenticate using an OpenID Connect identity provider.';
|
|
}
|
|
|
|
public function icon(): string
|
|
{
|
|
return 'fa-solid fa-circle-o';
|
|
}
|
|
|
|
public function beginRedirect(ProviderContext $context, string $callbackUrl, ?string $returnUrl = null): ProviderResult
|
|
{
|
|
$config = $context->config;
|
|
|
|
if (empty($config)) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INVALID_PROVIDER,
|
|
'OIDC provider configuration is missing'
|
|
);
|
|
}
|
|
|
|
try {
|
|
$authData = $this->client->beginAuth($config, $callbackUrl, $returnUrl);
|
|
|
|
return ProviderResult::redirect(
|
|
$authData['redirect_url'],
|
|
[
|
|
'state' => $authData['state'],
|
|
'nonce' => $authData['nonce'] ?? null,
|
|
'return_url' => $returnUrl,
|
|
]
|
|
);
|
|
} catch (\Throwable $e) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INTERNAL,
|
|
'Failed to initiate OIDC authentication: ' . $e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
|
|
public function completeRedirect(ProviderContext $context, array $params): ProviderResult
|
|
{
|
|
$config = $context->config;
|
|
$expectedState = $context->getMeta('state');
|
|
$expectedNonce = $context->getMeta('nonce');
|
|
|
|
if (empty($config)) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INVALID_PROVIDER,
|
|
'OIDC provider configuration is missing'
|
|
);
|
|
}
|
|
|
|
if (empty($expectedState)) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INVALID_CREDENTIALS,
|
|
'Missing expected state for OIDC verification'
|
|
);
|
|
}
|
|
|
|
try {
|
|
$result = $this->client->completeAuth($params, $config, $expectedState, $expectedNonce);
|
|
|
|
if (!$result || !isset($result['success']) || !$result['success']) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INVALID_CREDENTIALS,
|
|
$result['error'] ?? 'OIDC authentication failed'
|
|
);
|
|
}
|
|
|
|
// Provision/update user directly
|
|
$user = $this->provisionUser($result, $config);
|
|
|
|
if (!$user) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INTERNAL,
|
|
'Failed to provision user from OIDC identity'
|
|
);
|
|
}
|
|
|
|
// Return success with user identifier
|
|
return ProviderResult::success(
|
|
[
|
|
'user_identifier' => $user['uid'],
|
|
'user_identity' => $user['identity'],
|
|
],
|
|
[
|
|
'return_url' => $context->getMeta('return_url'),
|
|
]
|
|
);
|
|
} catch (\Throwable $e) {
|
|
return ProviderResult::failed(
|
|
ProviderResult::ERROR_INTERNAL,
|
|
'Failed to complete OIDC authentication: ' . $e->getMessage()
|
|
);
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Provisioning Helpers
|
|
// =========================================================================
|
|
|
|
/**
|
|
* Provision or update user from OIDC identity
|
|
*/
|
|
private function provisionUser(array $oidcData, array $config): ?array
|
|
{
|
|
$subject = $oidcData['sub'] ?? null;
|
|
$email = $oidcData['email'] ?? null;
|
|
|
|
if (!$subject || !$email) {
|
|
return null;
|
|
}
|
|
|
|
// Check if user exists by provider subject
|
|
$user = $this->userService->fetchByProviderSubject($this->identifier(), $subject);
|
|
|
|
if ($user) {
|
|
// Update existing user
|
|
return $this->updateUser($user, $oidcData, $config);
|
|
}
|
|
|
|
// Check if user exists by email
|
|
$user = $this->userService->fetchByIdentityRaw($email);
|
|
|
|
if ($user) {
|
|
// Link existing user to this OIDC provider
|
|
return $this->linkUser($user, $oidcData, $config);
|
|
}
|
|
|
|
// Create new user
|
|
return $this->createUser($oidcData, $config);
|
|
}
|
|
|
|
/**
|
|
* Create new user from OIDC data
|
|
*/
|
|
private function createUser(array $oidcData, array $config): array
|
|
{
|
|
$managedFields = $this->getManagedFields($config);
|
|
|
|
$userData = [
|
|
'identity' => $oidcData['email'],
|
|
'label' => $oidcData['name'] ?? $oidcData['email'],
|
|
'enabled' => true,
|
|
'provider' => $this->identifier(),
|
|
'provider_subject' => $oidcData['sub'],
|
|
'provider_synced_at' => time(),
|
|
'provider_managed_fields' => $managedFields,
|
|
'profile' => [],
|
|
];
|
|
|
|
// Map OIDC attributes to profile
|
|
$mapping = [
|
|
'given_name' => 'name_given',
|
|
'family_name' => 'name_family',
|
|
'picture' => 'avatar',
|
|
'email' => 'email',
|
|
'phone_number' => 'phone',
|
|
];
|
|
|
|
foreach ($mapping as $oidcField => $profileField) {
|
|
if (isset($oidcData[$oidcField])) {
|
|
$userData['profile'][$profileField] = $oidcData[$oidcField];
|
|
}
|
|
}
|
|
|
|
return $this->userService->createUser($userData);
|
|
}
|
|
|
|
/**
|
|
* Update existing user with OIDC data
|
|
*/
|
|
private function updateUser(array $user, array $oidcData, array $config): array
|
|
{
|
|
$managedFields = $this->getManagedFields($config);
|
|
|
|
// Update provider sync metadata
|
|
$this->userService->updateUser($user['uid'], [
|
|
'provider_synced_at' => time(),
|
|
'provider_managed_fields' => $managedFields,
|
|
]);
|
|
|
|
// Update label if provided
|
|
if (isset($oidcData['name'])) {
|
|
$this->userService->updateUser($user['uid'], [
|
|
'label' => $oidcData['name'],
|
|
]);
|
|
}
|
|
|
|
// Update profile fields (only managed ones will be updated due to storeProfile filtering)
|
|
$profileUpdates = [];
|
|
$mapping = [
|
|
'given_name' => 'name_given',
|
|
'family_name' => 'name_family',
|
|
'picture' => 'avatar',
|
|
];
|
|
|
|
foreach ($mapping as $oidcField => $profileField) {
|
|
if (isset($oidcData[$oidcField]) && in_array($profileField, $managedFields)) {
|
|
$profileUpdates[$profileField] = $oidcData[$oidcField];
|
|
}
|
|
}
|
|
|
|
if (!empty($profileUpdates)) {
|
|
// Use updateUser with profile. notation since these are managed fields
|
|
$updates = [];
|
|
foreach ($profileUpdates as $field => $value) {
|
|
$updates["profile.{$field}"] = $value;
|
|
}
|
|
$this->userService->updateUser($user['uid'], $updates);
|
|
}
|
|
|
|
return $this->userService->fetchByIdentifier($user['uid']);
|
|
}
|
|
|
|
/**
|
|
* Link existing local user to OIDC provider
|
|
*/
|
|
private function linkUser(array $user, array $oidcData, array $config): array
|
|
{
|
|
$managedFields = $this->getManagedFields($config);
|
|
|
|
// Link existing local user to OIDC provider
|
|
$this->userService->updateUser($user['uid'], [
|
|
'provider' => $this->identifier(),
|
|
'provider_subject' => $oidcData['sub'],
|
|
'provider_synced_at' => time(),
|
|
'provider_managed_fields' => $managedFields,
|
|
]);
|
|
|
|
// Then update with OIDC data
|
|
return $this->updateUser(
|
|
$this->userService->fetchByIdentifier($user['uid']),
|
|
$oidcData,
|
|
$config
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get default managed fields for OIDC provider
|
|
*/
|
|
private function managedFields(): array
|
|
{
|
|
return [
|
|
'name_given',
|
|
'name_family',
|
|
'avatar',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get managed fields from config or use defaults
|
|
*/
|
|
private function getManagedFields(array $config): array
|
|
{
|
|
// Allow configuration to override default managed fields
|
|
return $config['managed_fields'] ?? $this->managedFields();
|
|
}
|
|
|
|
}
|