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) <noreply@anthropic.com>
This commit is contained in:
@@ -102,4 +102,36 @@ class TenantLdapAuthRepository implements TenantLdapAuthRepositoryInterface
|
|||||||
|
|
||||||
return $result !== false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ interface TenantLdapAuthRepositoryInterface
|
|||||||
public function listByTenantIds(array $tenantIds): array;
|
public function listByTenantIds(array $tenantIds): array;
|
||||||
|
|
||||||
public function upsertByTenantId(int $tenantId, array $data): bool;
|
public function upsertByTenantId(int $tenantId, array $data): bool;
|
||||||
|
|
||||||
|
/** @return list<array{tenant_id: int, allowed_domains: string}> Enabled configs with explicit domain match */
|
||||||
|
public function findEnabledWithExplicitDomain(string $domain): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,4 +86,36 @@ class TenantMicrosoftAuthRepository implements TenantMicrosoftAuthRepositoryInte
|
|||||||
|
|
||||||
return $result !== false;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,4 +10,7 @@ interface TenantMicrosoftAuthRepositoryInterface
|
|||||||
public function listByTenantIds(array $tenantIds): array;
|
public function listByTenantIds(array $tenantIds): array;
|
||||||
|
|
||||||
public function upsertByTenantId(int $tenantId, array $data): bool;
|
public function upsertByTenantId(int $tenantId, array $data): bool;
|
||||||
|
|
||||||
|
/** @return list<array{tenant_id: int, allowed_domains: string}> Enabled configs with explicit domain match */
|
||||||
|
public function findEnabledWithExplicitDomain(string $domain): array;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,64 @@ class TenantSsoService
|
|||||||
return true;
|
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<array{id: int, methods: array}> 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
|
public function resolveTenantLoginMethods(int $tenantId): array
|
||||||
{
|
{
|
||||||
$tenant = $this->tenantGateway->findById($tenantId);
|
$tenant = $this->tenantGateway->findById($tenantId);
|
||||||
|
|||||||
@@ -85,18 +85,28 @@ $resolveLoginCandidates = static function (string $inputEmail) use ($userReadRep
|
|||||||
}
|
}
|
||||||
|
|
||||||
$user = $userReadRepository->findByEmail($emailValue);
|
$user = $userReadRepository->findByEmail($emailValue);
|
||||||
if (!$user || (int) ($user['active'] ?? 0) !== 1) {
|
$isKnownActiveUser = $user && (int) ($user['active'] ?? 0) === 1 && (int) ($user['id'] ?? 0) > 0;
|
||||||
return ['ok' => false];
|
|
||||||
|
$tenants = [];
|
||||||
|
$discovery = false;
|
||||||
|
|
||||||
|
if ($isKnownActiveUser) {
|
||||||
|
$userId = (int) $user['id'];
|
||||||
|
$tenants = $userTenantContextService->getAssignedActiveTenants($userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
$userId = (int) ($user['id'] ?? 0);
|
// Fallback: domain-based SSO discovery for users not yet provisioned locally
|
||||||
if ($userId <= 0) {
|
|
||||||
return ['ok' => false];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenants = $userTenantContextService->getAssignedActiveTenants($userId);
|
|
||||||
if (!$tenants) {
|
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 = [];
|
$candidates = [];
|
||||||
@@ -110,7 +120,9 @@ $resolveLoginCandidates = static function (string $inputEmail) use ($userReadRep
|
|||||||
if ($tenantSlug === '') {
|
if ($tenantSlug === '') {
|
||||||
continue;
|
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'] ?? '');
|
$tenantUuid = (string) ($tenant['uuid'] ?? '');
|
||||||
$hasAvatar = $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid);
|
$hasAvatar = $tenantUuid !== '' && $tenantAvatarService->hasAvatar($tenantUuid);
|
||||||
$candidate = [
|
$candidate = [
|
||||||
@@ -135,9 +147,10 @@ $resolveLoginCandidates = static function (string $inputEmail) use ($userReadRep
|
|||||||
return [
|
return [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'email' => $emailValue,
|
'email' => $emailValue,
|
||||||
'user' => $user,
|
'user' => $isKnownActiveUser ? $user : null,
|
||||||
'candidates' => $candidates,
|
'candidates' => $candidates,
|
||||||
'candidate_by_id' => $candidateById,
|
'candidate_by_id' => $candidateById,
|
||||||
|
'discovery' => $discovery,
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -459,6 +459,179 @@ class TenantSsoServiceTest extends TestCase
|
|||||||
$this->assertTrue($result['ok']);
|
$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 ────────────────────────────────────────────────────
|
// ── LDAP tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
public function testGetTenantLdapAuthReturnsDefaults(): void
|
public function testGetTenantLdapAuthReturnsDefaults(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user