'Sync first name', 'last_name' => 'Sync last name', 'phone' => 'Sync phone', 'mobile' => 'Sync mobile', 'avatar' => 'Sync avatar image', ]; private const DEFAULT_PROFILE_SYNC_FIELDS = ['first_name', 'last_name']; public function __construct( private readonly AuthTenantGateway $tenantGateway, private readonly TenantMicrosoftAuthRepositoryInterface $tenantMicrosoftAuthRepository, private readonly AuthSettingsGateway $settingsGateway, private readonly AuthCryptoGateway $cryptoGateway ) { } public function microsoftProviderKey(): string { return self::PROVIDER_MICROSOFT; } public function findTenantByLoginSlug(string $slug): ?array { $slug = trim(strtolower($slug)); if ($slug === '') { return null; } if (preg_match('/^[a-f0-9-]{36}$/', $slug)) { $tenant = $this->tenantGateway->findByUuid($slug); if ($tenant && (string) ($tenant['status'] ?? 'active') === 'active') { return $tenant; } } $matches = []; foreach ($this->tenantGateway->list() as $tenant) { if ((string) ($tenant['status'] ?? 'active') !== 'active') { continue; } if ($this->slugify((string) ($tenant['description'] ?? '')) === $slug) { $matches[] = $tenant; } } // Reject ambiguous slugs — if two tenants have the same name-slug, neither matches. if (count($matches) !== 1) { return null; } return $matches[0]; } public function tenantLoginSlug(array $tenant): string { $slug = $this->slugify((string) ($tenant['description'] ?? '')); if ($slug !== '') { return $slug; } return strtolower((string) ($tenant['uuid'] ?? '')); } public function getTenantMicrosoftAuth(int $tenantId): array { $row = $this->tenantMicrosoftAuthRepository->findByTenantId($tenantId) ?? []; $syncProfileOnLogin = !empty($row['sync_profile_on_login']); $syncFields = $this->normalizeProfileSyncFields($row['sync_profile_fields'] ?? []); if ($syncProfileOnLogin && !$syncFields) { $syncFields = $this->defaultProfileSyncFields(); } return [ 'enabled' => !empty($row['enabled']), 'enforce_microsoft_login' => !empty($row['enforce_microsoft_login']), 'sync_profile_on_login' => $syncProfileOnLogin, 'sync_profile_fields' => $this->profileSyncFieldsCsv($syncFields), 'sync_profile_fields_list' => $syncFields, 'entra_tenant_id' => strtolower(trim((string) ($row['entra_tenant_id'] ?? ''))), 'allowed_domains' => $this->normalizeDomains((string) ($row['allowed_domains'] ?? '')), 'use_shared_app' => !array_key_exists('use_shared_app', $row) || !empty($row['use_shared_app']), 'client_id_override' => trim((string) ($row['client_id_override'] ?? '')), 'client_secret_override_enc' => trim((string) ($row['client_secret_override_enc'] ?? '')), 'auto_remember_mode' => array_key_exists('auto_remember_mode', $row) && $row['auto_remember_mode'] !== null ? (int) $row['auto_remember_mode'] : null, ]; } public function buildMicrosoftUiState(int $tenantId, array $input = []): array { $existing = $this->getTenantMicrosoftAuth($tenantId); $state = $this->normalizeMicrosoftInputState($input, $existing); $configState = $this->evaluateMicrosoftConfigState($state); return [ 'enabled' => $state['enabled'], 'config_complete' => !$state['enabled'] || $configState['complete'], 'config_error_code' => (string) ($configState['error_code'] ?? ''), 'config_error_label' => $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')), 'password_mode' => !$state['enabled'] ? 'local_only' : ($state['enforce_microsoft_login'] ? 'microsoft_only' : 'local_and_microsoft'), 'credential_source' => $state['use_shared_app'] ? 'shared' : 'override', 'sync_needs_graph' => $state['sync_profile_on_login'] && $this->profileSyncFieldsRequireGraph($state['sync_profile_fields']), ]; } public function isLocalPasswordLoginAllowed(int $tenantId): bool { $auth = $this->getTenantMicrosoftAuth($tenantId); if (!$auth['enabled']) { return true; } return !$auth['enforce_microsoft_login']; } public function resolveTenantLoginMethods(int $tenantId): array { $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { return [ 'local' => false, 'microsoft' => false, 'microsoft_reason' => 'tenant_not_found', ]; } $configResult = $this->getEffectiveMicrosoftProviderConfig($tenant); return [ 'local' => $this->isLocalPasswordLoginAllowed($tenantId), 'microsoft' => !empty($configResult['ok']), 'microsoft_reason' => $configResult['ok'] ?? false ? '' : (string) ($configResult['error'] ?? 'sso_disabled'), ]; } public function getEffectiveMicrosoftProviderConfig(array $tenant): array { $tenantId = (int) ($tenant['id'] ?? 0); if ($tenantId <= 0) { return ['ok' => false, 'error' => 'tenant_not_found']; } $auth = $this->getTenantMicrosoftAuth($tenantId); if (!$auth['enabled']) { return ['ok' => false, 'error' => 'sso_disabled']; } $entraTenantId = $auth['entra_tenant_id']; if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) { return ['ok' => false, 'error' => 'tenant_id_invalid']; } $useSharedApp = $auth['use_shared_app']; $clientId = ''; $clientSecret = ''; if ($useSharedApp) { $clientId = $this->settingsGateway->getMicrosoftSharedClientId(); $clientSecret = $this->settingsGateway->getMicrosoftSharedClientSecret(); if (trim($clientId) === '' || trim($clientSecret) === '') { return ['ok' => false, 'error' => 'shared_credentials_missing']; } } else { $clientId = (string) $auth['client_id_override']; if (trim($clientId) === '') { return ['ok' => false, 'error' => 'client_id_override_required']; } if ($auth['client_secret_override_enc'] === '') { return ['ok' => false, 'error' => 'client_secret_override_required']; } if ($auth['client_secret_override_enc'] !== '') { try { $clientSecret = $this->cryptoGateway->decryptString($auth['client_secret_override_enc']); } catch (\Throwable $exception) { return ['ok' => false, 'error' => 'secret_invalid']; } } if (trim($clientSecret) === '') { return ['ok' => false, 'error' => 'client_secret_override_required']; } } $clientId = trim($clientId); $clientSecret = trim($clientSecret); return [ 'ok' => true, 'config' => [ 'provider' => self::PROVIDER_MICROSOFT, 'tenant_id' => $entraTenantId, 'client_id' => $clientId, 'client_secret' => $clientSecret, 'allowed_domains' => $auth['allowed_domains'], 'enforce_microsoft_login' => $auth['enforce_microsoft_login'], 'sync_profile_on_login' => $auth['sync_profile_on_login'], 'sync_profile_fields' => $auth['sync_profile_fields_list'], 'sync_profile_needs_graph' => $auth['sync_profile_on_login'] && $this->profileSyncFieldsRequireGraph($auth['sync_profile_fields_list']), 'authority' => $this->settingsGateway->getMicrosoftAuthority(), 'use_shared_app' => $useSharedApp, ], ]; } public function saveTenantMicrosoftAuth(int $tenantId, array $input): array { $tenant = $this->tenantGateway->findById($tenantId); if (!$tenant) { return ['ok' => false, 'errors' => [t('Tenant not found')]]; } $enabled = !empty($input['microsoft_enabled']); $enforce = !empty($input['enforce_microsoft_login']); $entraTenantId = strtolower(trim((string) ($input['entra_tenant_id'] ?? ''))); $allowedDomains = $this->normalizeDomains((string) ($input['allowed_domains'] ?? '')); $syncProfileOnLogin = !empty($input['sync_profile_on_login']); $syncProfileFields = $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? []); if ($syncProfileOnLogin && !$syncProfileFields) { $syncProfileFields = $this->defaultProfileSyncFields(); } $syncProfileFieldsCsv = $this->profileSyncFieldsCsv($syncProfileFields); $useSharedApp = !empty($input['use_shared_app']); $clientIdOverride = trim((string) ($input['client_id_override'] ?? '')); $clientSecretOverride = trim((string) ($input['client_secret_override'] ?? '')); $clearSecret = !empty($input['clear_client_secret_override']); $errors = []; $existing = $this->getTenantMicrosoftAuth($tenantId); $clientSecretOverrideEnc = (string) ($existing['client_secret_override_enc'] ?? ''); if ($clearSecret) { $clientSecretOverrideEnc = ''; } elseif ($clientSecretOverride !== '') { if (!$this->cryptoGateway->isConfigured()) { $errors[] = t('APP_CRYPTO_KEY is missing or invalid'); } else { try { $clientSecretOverrideEnc = $this->cryptoGateway->encryptString($clientSecretOverride); } catch (\Throwable $exception) { $errors[] = t('Client secret could not be encrypted'); } } } $stateForValidation = [ 'enabled' => $enabled, 'enforce_microsoft_login' => $enforce, 'sync_profile_on_login' => $syncProfileOnLogin, 'sync_profile_fields' => $syncProfileFields, 'entra_tenant_id' => $entraTenantId, 'use_shared_app' => $useSharedApp, 'client_id_override' => $clientIdOverride, 'client_secret_override_enc' => $clientSecretOverrideEnc, ]; $configState = $this->evaluateMicrosoftConfigState($stateForValidation); if ($enabled && !($configState['complete'] ?? false)) { $label = $this->microsoftConfigErrorLabel((string) ($configState['error_code'] ?? '')); if ($label !== '') { $errors[] = $label; } } if ($enabled && $enforce && !($configState['complete'] ?? false)) { $errors[] = t('Microsoft-only login requires a complete configuration'); } if ($errors) { return ['ok' => false, 'errors' => $errors]; } $autoRememberMode = $this->normalizeAutoRememberMode($input['microsoft_auto_remember_mode'] ?? null); $saved = $this->tenantMicrosoftAuthRepository->upsertByTenantId($tenantId, [ 'enabled' => $enabled, 'enforce_microsoft_login' => $enabled ? $enforce : false, 'sync_profile_on_login' => $enabled ? $syncProfileOnLogin : false, 'sync_profile_fields' => $syncProfileFieldsCsv !== '' ? $syncProfileFieldsCsv : null, 'entra_tenant_id' => $enabled ? $entraTenantId : null, 'allowed_domains' => $allowedDomains === '' ? null : $allowedDomains, 'use_shared_app' => $useSharedApp, 'client_id_override' => $useSharedApp ? null : ($clientIdOverride !== '' ? $clientIdOverride : null), 'client_secret_override_enc' => $useSharedApp ? null : ($clientSecretOverrideEnc !== '' ? $clientSecretOverrideEnc : null), 'auto_remember_mode' => $autoRememberMode, ]); if (!$saved) { return ['ok' => false, 'errors' => [t('Tenant SSO settings could not be saved')]]; } return ['ok' => true]; } // Empty allowedDomains list means all email domains are permitted. public function isEmailDomainAllowed(string $email, array $allowedDomains): bool { $allowedDomains = array_values(array_filter(array_map( static fn ($domain): string => strtolower(trim((string) $domain)), $allowedDomains ), static fn (string $domain): bool => $domain !== '')); if (!$allowedDomains) { return true; } $parts = explode('@', strtolower(trim($email))); if (count($parts) !== 2) { return false; } return in_array($parts[1], $allowedDomains, true); } public function profileSyncFieldOptions(): array { return self::PROFILE_SYNC_FIELD_OPTIONS; } public function defaultProfileSyncFields(): array { return self::DEFAULT_PROFILE_SYNC_FIELDS; } public function normalizeProfileSyncFields($raw): array { if (is_string($raw)) { $raw = preg_split('/[\s,;]+/', $raw) ?: []; } elseif (!is_array($raw)) { return []; } $allowed = array_fill_keys(array_keys(self::PROFILE_SYNC_FIELD_OPTIONS), true); $normalized = []; foreach ($raw as $item) { $key = strtolower(trim((string) $item)); if ($key === '' || !isset($allowed[$key])) { continue; } $normalized[$key] = true; } return array_keys($normalized); } public function profileSyncFieldsCsv(array $fields): string { $fields = $this->normalizeProfileSyncFields($fields); return implode(',', $fields); } public function profileSyncFieldsRequireGraph(array $fields): bool { $fields = $this->normalizeProfileSyncFields($fields); return in_array('phone', $fields, true) || in_array('mobile', $fields, true) || in_array('avatar', $fields, true); } public function shouldAutoRememberOnMicrosoftLogin(int $tenantId): bool { $auth = $this->getTenantMicrosoftAuth($tenantId); $mode = $auth['auto_remember_mode'] ?? null; if ($mode === 1) { return true; } if ($mode === 0) { return false; } return $this->settingsGateway->isMicrosoftAutoRememberDefault(); } private function normalizeAutoRememberMode(mixed $value): ?int { if ($value === null || $value === '' || $value === 'inherit') { return null; } $intVal = (int) $value; return in_array($intVal, [0, 1], true) ? $intVal : null; } private function normalizeDomains(string $raw): string { $parts = preg_split('/[,\n;\r]+/', $raw) ?: []; $domains = []; foreach ($parts as $part) { $domain = strtolower(trim($part)); $domain = preg_replace('/^@+/', '', $domain); if ($domain === '') { continue; } if (!preg_match('/^[a-z0-9.-]+\.[a-z]{2,}$/i', $domain)) { continue; } $domains[] = $domain; } $domains = array_values(array_unique($domains)); return implode(',', $domains); } private function normalizeMicrosoftInputState(array $input, array $existing): array { $enabled = array_key_exists('microsoft_enabled', $input) ? !empty($input['microsoft_enabled']) : !empty($existing['enabled']); $enforce = array_key_exists('enforce_microsoft_login', $input) ? !empty($input['enforce_microsoft_login']) : !empty($existing['enforce_microsoft_login']); $syncProfileOnLogin = array_key_exists('sync_profile_on_login', $input) ? !empty($input['sync_profile_on_login']) : !empty($existing['sync_profile_on_login']); $syncProfileFields = array_key_exists('sync_profile_fields', $input) ? $this->normalizeProfileSyncFields($input['sync_profile_fields'] ?? []) : $this->normalizeProfileSyncFields($existing['sync_profile_fields_list'] ?? []); if ($syncProfileOnLogin && !$syncProfileFields) { $syncProfileFields = $this->defaultProfileSyncFields(); } $entraTenantId = array_key_exists('entra_tenant_id', $input) ? strtolower(trim((string) ($input['entra_tenant_id'] ?? ''))) : strtolower(trim((string) ($existing['entra_tenant_id'] ?? ''))); $useSharedApp = array_key_exists('use_shared_app', $input) ? !empty($input['use_shared_app']) : !empty($existing['use_shared_app']); $clientIdOverride = array_key_exists('client_id_override', $input) ? trim((string) ($input['client_id_override'] ?? '')) : trim((string) ($existing['client_id_override'] ?? '')); $clientSecretOverrideEnc = trim((string) ($existing['client_secret_override_enc'] ?? '')); if (array_key_exists('clear_client_secret_override', $input) && !empty($input['clear_client_secret_override'])) { $clientSecretOverrideEnc = ''; } elseif (trim((string) ($input['client_secret_override'] ?? '')) !== '') { // For UI status, a newly typed secret is considered present before persisting. $clientSecretOverrideEnc = '__pending_secret__'; } return [ 'enabled' => $enabled, 'enforce_microsoft_login' => $enforce, 'sync_profile_on_login' => $syncProfileOnLogin, 'sync_profile_fields' => $syncProfileFields, 'entra_tenant_id' => $entraTenantId, 'use_shared_app' => $useSharedApp, 'client_id_override' => $clientIdOverride, 'client_secret_override_enc' => $clientSecretOverrideEnc, ]; } private function evaluateMicrosoftConfigState(array $state): array { if (empty($state['enabled'])) { return ['complete' => true, 'error_code' => '']; } $entraTenantId = strtolower(trim((string) ($state['entra_tenant_id'] ?? ''))); if (!preg_match('/^[a-f0-9-]{36}$/', $entraTenantId)) { return ['complete' => false, 'error_code' => 'tenant_id_invalid']; } if (!empty($state['use_shared_app'])) { $sharedClientId = trim($this->settingsGateway->getMicrosoftSharedClientId()); $sharedClientSecret = trim($this->settingsGateway->getMicrosoftSharedClientSecret()); if ($sharedClientId === '' || $sharedClientSecret === '') { return ['complete' => false, 'error_code' => 'shared_credentials_missing']; } return ['complete' => true, 'error_code' => '']; } $clientIdOverride = trim((string) ($state['client_id_override'] ?? '')); if ($clientIdOverride === '') { return ['complete' => false, 'error_code' => 'client_id_override_required']; } $secretEnc = trim((string) ($state['client_secret_override_enc'] ?? '')); if ($secretEnc === '') { return ['complete' => false, 'error_code' => 'client_secret_override_required']; } if ($secretEnc !== '__pending_secret__') { try { $secret = trim($this->cryptoGateway->decryptString($secretEnc)); } catch (\Throwable $exception) { return ['complete' => false, 'error_code' => 'secret_invalid']; } if ($secret === '') { return ['complete' => false, 'error_code' => 'client_secret_override_required']; } } return ['complete' => true, 'error_code' => '']; } private function microsoftConfigErrorLabel(string $errorCode): string { return match ($errorCode) { 'tenant_id_invalid' => t('Entra tenant ID is invalid'), 'shared_credentials_missing' => t('Shared credentials missing'), 'client_id_override_required' => t('Client ID override is required'), 'client_secret_override_required' => t('Client secret override is required'), 'secret_invalid' => t('Client secret override is invalid'), 'client_credentials_missing' => t('Client credentials are missing'), default => '', }; } private function slugify(string $value): string { $value = strtolower(trim($value)); if ($value === '') { return ''; } $value = preg_replace('/[^a-z0-9]+/', '-', $value) ?? ''; return trim($value, '-'); } }