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:
2026-04-07 14:35:57 +02:00
parent cacd1fb6d1
commit 1c784efd5c
7 changed files with 325 additions and 11 deletions

View File

@@ -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