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

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

View File

@@ -10,4 +10,7 @@ interface TenantLdapAuthRepositoryInterface
public function listByTenantIds(array $tenantIds): array;
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;
}

View File

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

View File

@@ -10,4 +10,7 @@ interface TenantMicrosoftAuthRepositoryInterface
public function listByTenantIds(array $tenantIds): array;
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;
}

View File

@@ -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<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
{
$tenant = $this->tenantGateway->findById($tenantId);