authentication provider provisioning

This commit is contained in:
root
2025-12-22 17:06:40 -05:00
parent 658a319ded
commit 81822498c8
6 changed files with 268 additions and 831 deletions

View File

@@ -1,137 +0,0 @@
<?php
namespace KTXC\Service;
use KTXC\Identity\Provider\DefaultIdentityProvider;
use KTXC\Models\Identity\User;
use Psr\Container\ContainerInterface;
use KTXC\SessionTenant;
/**
* User Manager Service
* Manages authentication providers and user operations across domains
*/
class UserManagerService
{
private array $availableIdentityProviders = [];
private array $cachedIdentityProviders = [];
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserService $userService,
private readonly ContainerInterface $container
) {
// Register the default identity provider
$this->providerRegister('default', DefaultIdentityProvider::class);
}
/**
* Register an authentication provider
*/
public function providerRegister(string $identifier, string $class): void
{
$this->availableIdentityProviders[$identifier] = $class;
}
public function providerList(?array $filter = null): array
{
$requestedProviders = $filter ? $filter : array_keys($this->availableIdentityProviders);
$result = [];
foreach ($requestedProviders as $identifier) {
// Check if provider is available
if (!isset($this->availableIdentityProviders[$identifier])) {
continue;
}
// Check cache first
if (isset($this->cachedIdentityProviders[$identifier])) {
$result[$identifier] = $this->cachedIdentityProviders[$identifier];
} else {
// Instantiate the provider and cache it
$providerClass = $this->availableIdentityProviders[$identifier];
try {
$providerInstance = $this->container->get($providerClass);
// Cache the instance
$this->cachedIdentityProviders[$identifier] = $providerInstance;
$result[$identifier] = $providerInstance;
} catch (\Exception $e) {
// Skip providers that can't be resolved
error_log("Failed to resolve identity provider {$providerClass}: " . $e->getMessage());
continue;
}
}
}
return $result;
}
/**
* Authenticate user against enabled providers
*/
public function authenticate(string $identity, string $credential): User | null
{
// validate identity and credential
if (empty($identity) || empty($credential)) {
return null;
}
// retrieve user by identity
$user = $this->userService->fetchByIdentity($identity);
// determine if user has logged in before
if (!$user) {
return null;
}
// determine if user has a identity provider assigned
if ($user->getProvider() === null) {
return null;
}
$authenticated = $this->authenticateExtant($user->getProvider(), $identity, $credential);
if ($authenticated) {
return $user;
}
return null;
}
public function authenticateExtant(string $provider, string $identity, string $credential): bool {
// determine if provider is enabled
$providers = $this->providerList([$provider]);
if (empty($providers)) {
return false;
}
// Get the first (and should be only) provider
$provider = reset($providers);
// authenticate user against provider
$user = $provider->authenticate($identity, $credential);
return $user;
}
public function validate(string $identifier): Bool
{
$data = $this->userService->fetchByIdentifier($identifier);
if (!$data) {
return false;
}
if ($data['enabled'] !== true) {
return false;
}
if ($data['tid'] !== $this->tenant->identifier()) {
return false;
}
return true;
}
}

View File

@@ -1,278 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXC\Service;
use KTXC\Models\Identity\User;
use KTXC\SessionTenant;
use KTXC\Stores\UserStore;
use KTXC\Stores\ExternalIdentityStore;
use KTXF\Utile\UUID;
/**
* User Provisioning Service
* Handles JIT (Just-In-Time) user provisioning from external identity providers
* and profile synchronization on login
*/
class UserProvisioningService
{
public function __construct(
private readonly SessionTenant $tenant,
private readonly UserStore $userStore,
private readonly ExternalIdentityStore $externalIdentityStore
) { }
/**
* Provision a new user from external provider attributes
*
* @param string $providerId Provider identifier
* @param array $attributes User attributes from provider
* @param array $providerConfig Provider configuration including attribute_map and default_roles
* @return User|null The provisioned user or null on failure
*/
public function provisionUser(string $providerId, array $attributes, array $providerConfig): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Map attributes to user fields
$mappedData = $this->mapAttributes($attributes, $providerConfig['attribute_map'] ?? []);
// Validate required fields
$identity = $mappedData['identity'] ?? $attributes['identity'] ?? $attributes['email'] ?? null;
if (!$identity) {
return null;
}
// Generate user ID
$userId = UUID::v4();
// Build user data
$userData = [
'tid' => $tenantId,
'uid' => $userId,
'identity' => $identity,
'label' => $mappedData['label'] ?? $attributes['label'] ?? $attributes['name'] ?? $identity,
'enabled' => true,
'provider' => $providerId,
'external_subject' => $attributes['external_subject'] ?? null,
'roles' => $providerConfig['default_roles'] ?? [],
'profile' => $mappedData['profile'] ?? [],
'settings' => [],
'initial_login' => time(),
'recent_login' => time(),
];
// Create the user
$createdUserId = $this->userStore->create($userData);
if (!$createdUserId) {
return null;
}
// Link external identity if we have an external subject
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
// Build and return User object
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Synchronize user profile with attributes from provider
* Called on each login to keep profile data up to date
*
* @param User $user The existing user
* @param array $attributes Attributes from provider
* @param array $attributeMap Attribute mapping configuration
* @return bool Whether sync was successful
*/
public function syncProfile(User $user, array $attributes, array $attributeMap = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
// Map attributes
$mappedData = $this->mapAttributes($attributes, $attributeMap);
// Update profile fields if we have mapped profile data
if (!empty($mappedData['profile'])) {
$this->userStore->updateProfile($tenantId, $userId, $mappedData['profile']);
}
// Update label if provided and different
if (!empty($mappedData['label']) && $mappedData['label'] !== $user->getLabel()) {
$this->userStore->updateLabel($tenantId, $userId, $mappedData['label']);
}
// Always update last login
$this->userStore->updateLastLogin($tenantId, $userId);
// Update external identity attributes if applicable
if (!empty($attributes['external_subject'])) {
$this->externalIdentityStore->updateLastLogin(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject']
);
$this->externalIdentityStore->updateAttributes(
$tenantId,
$user->getProvider() ?? '',
$attributes['external_subject'],
$attributes['raw'] ?? $attributes
);
}
return true;
}
/**
* Link an external identity to an existing user
*
* @param User $user The user to link
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes Optional attributes from provider
* @return bool Whether linking was successful
*/
public function linkExternalIdentity(User $user, string $providerId, string $externalSubject, array $attributes = []): bool
{
$tenantId = $this->tenant->identifier();
$userId = $user->getId();
if (!$tenantId || !$userId) {
return false;
}
return $this->externalIdentityStore->linkIdentity(
$tenantId,
$userId,
$providerId,
$externalSubject,
$attributes
);
}
/**
* Find user by external identity
*
* @param string $providerId Provider identifier
* @param string $externalSubject External subject identifier
* @return User|null The user or null if not found
*/
public function findByExternalIdentity(string $providerId, string $externalSubject): ?User
{
$tenantId = $this->tenant->identifier();
if (!$tenantId) {
return null;
}
// Look up in external identities table
$externalIdentity = $this->externalIdentityStore->findByExternalSubject(
$tenantId,
$providerId,
$externalSubject
);
if (!$externalIdentity) {
return null;
}
// Fetch the linked user
$userData = $this->userStore->fetchByIdentifier($tenantId, $externalIdentity['uid']);
if (!$userData) {
return null;
}
$user = new User();
$user->populate($userData, 'users');
return $user;
}
/**
* Map provider attributes to user fields using attribute map
*
* @param array $attributes Raw attributes from provider
* @param array $attributeMap Mapping configuration {source_attr: target_field}
* @return array Mapped data with 'identity', 'label', 'profile' keys
*/
protected function mapAttributes(array $attributes, array $attributeMap): array
{
$result = [
'identity' => null,
'label' => null,
'profile' => [],
];
foreach ($attributeMap as $sourceAttr => $targetField) {
// Get source value (supports nested attributes with dot notation)
$value = $this->getNestedValue($attributes, $sourceAttr);
if ($value === null) {
continue;
}
// Set target value (supports nested targets with dot notation)
if ($targetField === 'identity') {
$result['identity'] = $value;
} elseif ($targetField === 'label') {
$result['label'] = $value;
} elseif (str_starts_with($targetField, 'profile.')) {
$profileField = substr($targetField, 8);
$result['profile'][$profileField] = $value;
}
}
return $result;
}
/**
* Get nested value from array using dot notation
*
* @param array $array Source array
* @param string $key Key with optional dot notation (e.g., 'user.email')
* @return mixed|null Value or null if not found
*/
protected function getNestedValue(array $array, string $key): mixed
{
$keys = explode('.', $key);
$value = $array;
foreach ($keys as $k) {
if (!is_array($value) || !array_key_exists($k, $value)) {
return null;
}
$value = $value[$k];
}
return $value;
}
/**
* Check if auto-provisioning is enabled for a provider
*
* @param string $providerId Provider identifier
* @return bool
*/
public function isAutoProvisioningEnabled(string $providerId): bool
{
$config = $this->tenant->identityProviderConfig($providerId);
return ($config['provisioning'] ?? 'manual') === 'auto';
}
}

View File

@@ -17,6 +17,10 @@ class UserService
) {
}
// =========================================================================
// User Operations
// =========================================================================
public function fetchByIdentity(string $identifier): User | null
{
$data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
@@ -34,6 +38,69 @@ class UserService
return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier);
}
public function fetchByIdentityRaw(string $identifier): array | null
{
return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier);
}
public function fetchByProviderSubject(string $provider, string $subject): ?array
{
return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject);
}
public function createUser(array $userData): array
{
return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData);
}
public function updateUser(string $uid, array $updates): bool
{
return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates);
}
public function deleteUser(string $uid): bool
{
return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid);
}
// =========================================================================
// Profile Operations
// =========================================================================
public function fetchProfile(string $uid): ?array
{
return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid);
}
public function storeProfile(string $uid, array $profileFields): bool
{
// Get managed fields to filter out read-only fields
$user = $this->fetchByIdentifier($uid);
if (!$user) {
return false;
}
$managedFields = $user['provider_managed_fields'] ?? [];
$editableFields = [];
// Only include fields that are not managed by provider
foreach ($profileFields as $field => $value) {
if (!in_array($field, $managedFields)) {
$editableFields[$field] = $value;
}
}
if (empty($editableFields)) {
return false;
}
return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields);
}
// =========================================================================
// Settings Operations
// =========================================================================
public function fetchSettings(array $settings = []): array | null
{
return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
@@ -42,6 +109,56 @@ class UserService
public function storeSettings(array $settings): bool
{
return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings);
}
}
// =========================================================================
// Helper Methods
// =========================================================================
/**
* Check if a profile field is editable by the user
*
* @param string $uid User identifier
* @param string $field Profile field name
* @return bool True if field is editable, false if managed by provider
*/
public function isFieldEditable(string $uid, string $field): bool
{
$user = $this->fetchByIdentifier($uid);
if (!$user) {
return false;
}
$managedFields = $user['provider_managed_fields'] ?? [];
return !in_array($field, $managedFields);
}
/**
* Get editable fields for a user
*
* @param string $uid User identifier
* @return array Array with field => ['value' => ..., 'editable' => bool, 'provider' => ...]
*/
public function getEditableFields(string $uid): array
{
$user = $this->fetchByIdentifier($uid);
if (!$user || !isset($user['profile'])) {
return [];
}
$managedFields = $user['provider_managed_fields'] ?? [];
$provider = $user['provider'] ?? null;
$editable = [];
foreach ($user['profile'] as $field => $value) {
$editable[$field] = [
'value' => $value,
'editable' => !in_array($field, $managedFields),
'provider' => in_array($field, $managedFields) ? $provider : null,
];
}
return $editable;
}
}