From 1c784efd5c6f70b20a54ca4787d2ba92fcbd2c53 Mon Sep 17 00:00:00 2001 From: fs Date: Tue, 7 Apr 2026 14:35:57 +0200 Subject: [PATCH] feat(auth): add domain-based SSO discovery for unprovisioned users Users who exist in Microsoft Entra ID or LDAP but have no local account were blocked at the email-first login step because the system required a local user record before showing SSO options. This adds a domain-based fallback: when the email is unknown locally, the system checks tenant SSO configurations for matching allowed_domains and presents SSO-only login options, enabling the existing JIT provisioning to trigger on callback. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Tenant/TenantLdapAuthRepository.php | 32 ++++ .../TenantLdapAuthRepositoryInterface.php | 3 + .../Tenant/TenantMicrosoftAuthRepository.php | 32 ++++ ...TenantMicrosoftAuthRepositoryInterface.php | 3 + lib/Service/Auth/TenantSsoService.php | 58 ++++++ pages/auth/login().php | 35 ++-- tests/Service/Auth/TenantSsoServiceTest.php | 173 ++++++++++++++++++ 7 files changed, 325 insertions(+), 11 deletions(-) diff --git a/lib/Repository/Tenant/TenantLdapAuthRepository.php b/lib/Repository/Tenant/TenantLdapAuthRepository.php index d777d5f..a359e07 100644 --- a/lib/Repository/Tenant/TenantLdapAuthRepository.php +++ b/lib/Repository/Tenant/TenantLdapAuthRepository.php @@ -102,4 +102,36 @@ class TenantLdapAuthRepository implements TenantLdapAuthRepositoryInterface return $result !== false; } + + public function findEnabledWithExplicitDomain(string $domain): array + { + $domain = strtolower(trim($domain)); + if ($domain === '') { + return []; + } + + $rows = DB::select( + "select tenant_id, allowed_domains from tenant_auth_ldap where enabled = 1 and allowed_domains is not null and allowed_domains != '' and FIND_IN_SET(?, allowed_domains) > 0", + $domain + ); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + $item = $this->unwrap($row); + if (!is_array($item)) { + continue; + } + $tenantId = (int) ($item['tenant_id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $result[] = ['tenant_id' => $tenantId, 'allowed_domains' => (string) ($item['allowed_domains'] ?? '')]; + } + + return $result; + } } diff --git a/lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php b/lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php index 611ef2d..77d1391 100644 --- a/lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php +++ b/lib/Repository/Tenant/TenantLdapAuthRepositoryInterface.php @@ -10,4 +10,7 @@ interface TenantLdapAuthRepositoryInterface public function listByTenantIds(array $tenantIds): array; public function upsertByTenantId(int $tenantId, array $data): bool; + + /** @return list Enabled configs with explicit domain match */ + public function findEnabledWithExplicitDomain(string $domain): array; } diff --git a/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php b/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php index 40b24fd..022f468 100644 --- a/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php +++ b/lib/Repository/Tenant/TenantMicrosoftAuthRepository.php @@ -86,4 +86,36 @@ class TenantMicrosoftAuthRepository implements TenantMicrosoftAuthRepositoryInte return $result !== false; } + + public function findEnabledWithExplicitDomain(string $domain): array + { + $domain = strtolower(trim($domain)); + if ($domain === '') { + return []; + } + + $rows = DB::select( + "select tenant_id, allowed_domains from tenant_auth_microsoft where enabled = 1 and allowed_domains is not null and allowed_domains != '' and FIND_IN_SET(?, allowed_domains) > 0", + $domain + ); + + if (!is_array($rows)) { + return []; + } + + $result = []; + foreach ($rows as $row) { + $item = $this->unwrap($row); + if (!is_array($item)) { + continue; + } + $tenantId = (int) ($item['tenant_id'] ?? 0); + if ($tenantId <= 0) { + continue; + } + $result[] = ['tenant_id' => $tenantId, 'allowed_domains' => (string) ($item['allowed_domains'] ?? '')]; + } + + return $result; + } } diff --git a/lib/Repository/Tenant/TenantMicrosoftAuthRepositoryInterface.php b/lib/Repository/Tenant/TenantMicrosoftAuthRepositoryInterface.php index eab3a38..184b3ab 100644 --- a/lib/Repository/Tenant/TenantMicrosoftAuthRepositoryInterface.php +++ b/lib/Repository/Tenant/TenantMicrosoftAuthRepositoryInterface.php @@ -10,4 +10,7 @@ interface TenantMicrosoftAuthRepositoryInterface public function listByTenantIds(array $tenantIds): array; public function upsertByTenantId(int $tenantId, array $data): bool; + + /** @return list Enabled configs with explicit domain match */ + public function findEnabledWithExplicitDomain(string $domain): array; } diff --git a/lib/Service/Auth/TenantSsoService.php b/lib/Service/Auth/TenantSsoService.php index 589c3a9..70f9341 100644 --- a/lib/Service/Auth/TenantSsoService.php +++ b/lib/Service/Auth/TenantSsoService.php @@ -154,6 +154,64 @@ class TenantSsoService return true; } + /** + * Discover tenants that accept SSO login for the given email domain. + * Only tenants with explicitly configured (non-empty) allowed_domains are returned. + * Returns SSO-only methods (local is always false since the user has no local account). + * + * @return list Tenant candidates with SSO methods + */ + public function discoverTenantsByEmailDomain(string $email): array + { + $email = strtolower(trim($email)); + $parts = explode('@', $email); + if (count($parts) !== 2 || $parts[1] === '') { + return []; + } + $domain = $parts[1]; + + $microsoftMatches = $this->tenantMicrosoftAuthRepository->findEnabledWithExplicitDomain($domain); + $ldapMatches = $this->tenantLdapAuthRepository->findEnabledWithExplicitDomain($domain); + + $tenantIds = []; + foreach ($microsoftMatches as $match) { + $tenantIds[(int) $match['tenant_id']] = true; + } + foreach ($ldapMatches as $match) { + $tenantIds[(int) $match['tenant_id']] = true; + } + + if (!$tenantIds) { + return []; + } + + $candidates = []; + foreach (array_keys($tenantIds) as $tenantId) { + $tenant = $this->tenantGateway->findById($tenantId); + if (!$tenant || (string) ($tenant['status'] ?? 'active') !== 'active') { + continue; + } + + $methods = $this->resolveTenantLoginMethods($tenantId); + + // Discovery candidates never offer local password (user has no local account) + $methods['local'] = false; + + // Skip tenants where no SSO method is actually functional + if (empty($methods['microsoft']) && empty($methods['ldap'])) { + continue; + } + + $candidates[] = [ + 'id' => $tenantId, + 'tenant' => $tenant, + 'methods' => $methods, + ]; + } + + return $candidates; + } + public function resolveTenantLoginMethods(int $tenantId): array { $tenant = $this->tenantGateway->findById($tenantId); diff --git a/pages/auth/login().php b/pages/auth/login().php index dd242ea..f89e2e8 100644 --- a/pages/auth/login().php +++ b/pages/auth/login().php @@ -85,18 +85,28 @@ $resolveLoginCandidates = static function (string $inputEmail) use ($userReadRep } $user = $userReadRepository->findByEmail($emailValue); - if (!$user || (int) ($user['active'] ?? 0) !== 1) { - return ['ok' => false]; + $isKnownActiveUser = $user && (int) ($user['active'] ?? 0) === 1 && (int) ($user['id'] ?? 0) > 0; + + $tenants = []; + $discovery = false; + + if ($isKnownActiveUser) { + $userId = (int) $user['id']; + $tenants = $userTenantContextService->getAssignedActiveTenants($userId); } - $userId = (int) ($user['id'] ?? 0); - if ($userId <= 0) { - return ['ok' => false]; - } - - $tenants = $userTenantContextService->getAssignedActiveTenants($userId); + // Fallback: domain-based SSO discovery for users not yet provisioned locally if (!$tenants) { - return ['ok' => false]; + $discoveryResults = $tenantSsoService->discoverTenantsByEmailDomain($emailValue); + if (!$discoveryResults) { + return ['ok' => false]; + } + $discovery = true; + $tenants = array_map(static fn (array $r): array => $r['tenant'], $discoveryResults); + $discoveryMethodsByTenantId = []; + foreach ($discoveryResults as $r) { + $discoveryMethodsByTenantId[(int) $r['id']] = $r['methods']; + } } $candidates = []; @@ -110,7 +120,9 @@ $resolveLoginCandidates = static function (string $inputEmail) use ($userReadRep if ($tenantSlug === '') { continue; } - $methods = $tenantSsoService->resolveTenantLoginMethods($tenantId); + $methods = $discovery + ? ($discoveryMethodsByTenantId[$tenantId] ?? ['local' => false, 'microsoft' => false, 'microsoft_reason' => '', 'ldap' => false, 'ldap_reason' => '']) + : $tenantSsoService->resolveTenantLoginMethods($tenantId); $tenantUuid = (string) ($tenant['uuid'] ?? ''); $hasAvatar = $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid); $candidate = [ @@ -135,9 +147,10 @@ $resolveLoginCandidates = static function (string $inputEmail) use ($userReadRep return [ 'ok' => true, 'email' => $emailValue, - 'user' => $user, + 'user' => $isKnownActiveUser ? $user : null, 'candidates' => $candidates, 'candidate_by_id' => $candidateById, + 'discovery' => $discovery, ]; }; diff --git a/tests/Service/Auth/TenantSsoServiceTest.php b/tests/Service/Auth/TenantSsoServiceTest.php index f9eb6f8..37eb1a1 100644 --- a/tests/Service/Auth/TenantSsoServiceTest.php +++ b/tests/Service/Auth/TenantSsoServiceTest.php @@ -459,6 +459,179 @@ class TenantSsoServiceTest extends TestCase $this->assertTrue($result['ok']); } + // ── discoverTenantsByEmailDomain ──────────────────────────────── + + public function testDiscoverTenantsByEmailDomainReturnsMatchingMicrosoftTenant(): void + { + $tenantGateway = $this->createMock(AuthTenantGateway::class); + $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active', 'uuid' => 'abc', 'description' => 'Acme']); + + $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); + $microsoftRepo->method('findEnabledWithExplicitDomain')->with('acme.com')->willReturn([ + ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], + ]); + $microsoftRepo->method('findByTenantId')->willReturn([ + 'enabled' => 1, + 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', + 'use_shared_app' => 1, + ]); + + $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); + $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); + $ldapRepo->method('findByTenantId')->willReturn(['enabled' => 0]); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id'); + $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('secret'); + $settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + + $service = $this->newService($microsoftRepo, $tenantGateway, $settingsGateway, null, $ldapRepo); + $result = $service->discoverTenantsByEmailDomain('user@acme.com'); + + $this->assertCount(1, $result); + $this->assertSame(5, $result[0]['id']); + $this->assertFalse($result[0]['methods']['local']); + $this->assertTrue($result[0]['methods']['microsoft']); + } + + public function testDiscoverTenantsByEmailDomainReturnsEmptyForUnknownDomain(): void + { + $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); + $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([]); + + $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); + $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); + + $service = $this->newService($microsoftRepo, null, null, null, $ldapRepo); + $result = $service->discoverTenantsByEmailDomain('user@unknown.org'); + + $this->assertSame([], $result); + } + + public function testDiscoverTenantsByEmailDomainExcludesInactiveTenants(): void + { + $tenantGateway = $this->createMock(AuthTenantGateway::class); + $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'inactive']); + + $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); + $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ + ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], + ]); + + $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); + $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); + + $service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo); + $result = $service->discoverTenantsByEmailDomain('user@acme.com'); + + $this->assertSame([], $result); + } + + public function testDiscoverTenantsByEmailDomainForcesLocalFalse(): void + { + $tenantGateway = $this->createMock(AuthTenantGateway::class); + $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active', 'uuid' => 'abc', 'description' => 'Acme']); + + $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); + $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ + ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], + ]); + // Microsoft disabled, but LDAP should still work — and local must be false + $microsoftRepo->method('findByTenantId')->willReturn(['enabled' => 0]); + + $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); + $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([ + ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], + ]); + $ldapRepo->method('findByTenantId')->willReturn([ + 'enabled' => 1, + 'host' => 'ldap.acme.com', + 'base_dn' => 'DC=acme,DC=com', + 'bind_dn_enc' => '', + 'bind_password_enc' => '', + ]); + + $service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo); + $result = $service->discoverTenantsByEmailDomain('user@acme.com'); + + $this->assertCount(1, $result); + $this->assertFalse($result[0]['methods']['local']); + $this->assertTrue($result[0]['methods']['ldap']); + } + + public function testDiscoverTenantsByEmailDomainExcludesTenantWithNoFunctionalSso(): void + { + $tenantGateway = $this->createMock(AuthTenantGateway::class); + $tenantGateway->method('findById')->willReturn(['id' => 5, 'status' => 'active']); + + $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); + $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ + ['tenant_id' => 5, 'allowed_domains' => 'acme.com'], + ]); + // Microsoft enabled but misconfigured (invalid tenant ID) + $microsoftRepo->method('findByTenantId')->willReturn([ + 'enabled' => 1, + 'entra_tenant_id' => 'invalid', + 'use_shared_app' => 1, + ]); + + $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); + $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); + $ldapRepo->method('findByTenantId')->willReturn(['enabled' => 0]); + + $service = $this->newService($microsoftRepo, $tenantGateway, null, null, $ldapRepo); + $result = $service->discoverTenantsByEmailDomain('user@acme.com'); + + $this->assertSame([], $result); + } + + public function testDiscoverTenantsByEmailDomainReturnsEmptyForInvalidEmail(): void + { + $service = $this->newService(); + $this->assertSame([], $service->discoverTenantsByEmailDomain('not-an-email')); + $this->assertSame([], $service->discoverTenantsByEmailDomain('')); + } + + public function testDiscoverTenantsByEmailDomainReturnsMultipleTenants(): void + { + $tenantGateway = $this->createMock(AuthTenantGateway::class); + $tenantGateway->method('findById')->willReturnCallback(static function (int $id): ?array { + return match ($id) { + 5 => ['id' => 5, 'status' => 'active', 'uuid' => 'u5', 'description' => 'Tenant A'], + 7 => ['id' => 7, 'status' => 'active', 'uuid' => 'u7', 'description' => 'Tenant B'], + default => null, + }; + }); + + $microsoftRepo = $this->createMock(TenantMicrosoftAuthRepositoryInterface::class); + $microsoftRepo->method('findEnabledWithExplicitDomain')->willReturn([ + ['tenant_id' => 5, 'allowed_domains' => 'shared.com'], + ['tenant_id' => 7, 'allowed_domains' => 'shared.com'], + ]); + $microsoftRepo->method('findByTenantId')->willReturn([ + 'enabled' => 1, + 'entra_tenant_id' => '11111111-1111-1111-1111-111111111111', + 'use_shared_app' => 1, + ]); + + $ldapRepo = $this->createMock(TenantLdapAuthRepositoryInterface::class); + $ldapRepo->method('findEnabledWithExplicitDomain')->willReturn([]); + $ldapRepo->method('findByTenantId')->willReturn(['enabled' => 0]); + + $settingsGateway = $this->createMock(AuthSettingsGateway::class); + $settingsGateway->method('getMicrosoftSharedClientId')->willReturn('client-id'); + $settingsGateway->method('getMicrosoftSharedClientSecret')->willReturn('secret'); + $settingsGateway->method('getMicrosoftAuthority')->willReturn('https://login.microsoftonline.com/common/v2.0'); + + $service = $this->newService($microsoftRepo, $tenantGateway, $settingsGateway, null, $ldapRepo); + $result = $service->discoverTenantsByEmailDomain('user@shared.com'); + + $this->assertCount(2, $result); + $ids = array_column($result, 'id'); + $this->assertContains(5, $ids); + $this->assertContains(7, $ids); + } + // ── LDAP tests ──────────────────────────────────────────────────── public function testGetTenantLdapAuthReturnsDefaults(): void