Files
authentication_provider_oidc/lib/Provider.php
2025-12-22 17:07:07 -05:00

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();
}
}