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;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
Reference in New Issue
Block a user