fix(login): Organization Discovery for Login Without Org Context (#10996)

# Which Problems Are Solved

When users accessed the login page without an organization context and
entered a login name with a domain suffix (e.g., [user@company.com], the
system would return "user not found" instead of performing organization
discovery.

# How the Problems Are Solved

Added organization discovery logic that triggers after a global user
search returns no results. When no organization context is provided:

- Extracts the domain suffix from the loginName (e.g., @company.com)
- Queries for organizations with that domain as their primary domain
- If exactly one organization is found with allowDomainDiscovery
enabled, uses it as the discovered organization
- Redirects users to the appropriate flow (IDP, registration, or
password) with the discovered organization context

---------

Co-authored-by: Ramon <mail@conblem.me>
This commit is contained in:
Max Peintner
2025-11-12 13:14:09 +01:00
committed by GitHub
co-authored by Ramon
parent 2953366b4c
commit 75791361f3
3 changed files with 238 additions and 41 deletions
+160
View File
@@ -62,6 +62,7 @@ describe("sendLoginname", () => {
let mockGetActiveIdentityProviders: any;
let mockGetIDPByID: any;
let mockIdpTypeToSlug: any;
let mockGetOrgsByDomain: any;
beforeEach(async () => {
vi.clearAllMocks();
@@ -77,6 +78,7 @@ describe("sendLoginname", () => {
listIDPLinks,
startIdentityProviderFlow,
getActiveIdentityProviders,
getOrgsByDomain,
} = await import("../zitadel");
const { createSessionAndUpdateCookie } = await import("./cookie");
const { getOriginalHost } = await import("./host");
@@ -96,6 +98,7 @@ describe("sendLoginname", () => {
mockGetActiveIdentityProviders = vi.mocked(getActiveIdentityProviders);
mockGetIDPByID = vi.mocked(getIDPByID);
mockIdpTypeToSlug = vi.mocked(idpTypeToSlug);
mockGetOrgsByDomain = vi.mocked(getOrgsByDomain);
// Default mock implementations
mockHeaders.mockResolvedValue({} as any);
@@ -107,6 +110,8 @@ describe("sendLoginname", () => {
name: "Google",
type: "GOOGLE",
});
// Default: org discovery returns empty result
mockGetOrgsByDomain.mockResolvedValue({ result: [] });
});
afterEach(() => {
@@ -482,6 +487,161 @@ describe("sendLoginname", () => {
expect(result).toEqual({ error: "errors.userNotFound" });
});
test("should discover organization from domain suffix when user not found without org context", async () => {
// Mock login settings for instance level (no org context)
mockGetLoginSettings
.mockResolvedValueOnce({
allowRegister: true,
allowUsernamePassword: true,
ignoreUnknownUsernames: false,
})
// Mock login settings for discovered org - must include all necessary flags
.mockResolvedValueOnce({
allowDomainDiscovery: true,
allowRegister: true,
allowUsernamePassword: true,
ignoreUnknownUsernames: false,
});
// Mock org discovery to return one org with matching domain
mockGetOrgsByDomain.mockResolvedValue({
result: [{ id: "discovered-org-123", name: "Example Org" }],
});
const result = await sendLoginname({
loginName: "user@example.com",
requestId: "req123",
// No organization parameter - this is the key test scenario
});
expect(result).toBeDefined();
expect(result?.redirect).toMatch(/^\/register\?/);
expect(result?.redirect).toContain("organization=discovered-org-123");
expect(result?.redirect).toContain("requestId=req123");
expect(result?.redirect).toContain("email=user%40example.com");
// Verify org discovery was called with correct domain
expect(mockGetOrgsByDomain).toHaveBeenCalledWith({
serviceUrl: "https://api.example.com",
domain: "example.com",
});
});
test("should redirect to IDP with discovered org when user not found and only IDP allowed", async () => {
// Mock login settings for instance level (no org context)
mockGetLoginSettings
.mockResolvedValueOnce({
allowRegister: true,
allowUsernamePassword: false,
})
// Mock login settings for discovered org - must include all necessary flags
.mockResolvedValueOnce({
allowDomainDiscovery: true,
allowRegister: true,
allowUsernamePassword: false,
});
// Mock org discovery to return one org with matching domain
mockGetOrgsByDomain.mockResolvedValue({
result: [{ id: "discovered-org-456", name: "Example Org" }],
});
mockGetActiveIdentityProviders.mockResolvedValue({
identityProviders: [{ id: "idp123", type: "OIDC" }],
});
mockStartIdentityProviderFlow.mockResolvedValue("https://idp.example.com/auth?org=discovered-org-456");
const result = await sendLoginname({
loginName: "user@company.com",
requestId: "req123",
// No organization parameter
});
expect(result).toEqual({ redirect: "https://idp.example.com/auth?org=discovered-org-456" });
// Verify org discovery was called
expect(mockGetOrgsByDomain).toHaveBeenCalledWith({
serviceUrl: "https://api.example.com",
domain: "company.com",
});
// Verify IDP redirect was called with discovered org
expect(mockGetActiveIdentityProviders).toHaveBeenCalledWith({
serviceUrl: "https://api.example.com",
orgId: "discovered-org-456",
});
});
test("should not discover org if domain discovery is disabled", async () => {
mockGetLoginSettings
.mockResolvedValueOnce({
allowRegister: true,
allowUsernamePassword: true,
ignoreUnknownUsernames: false,
})
// Mock login settings for org with domain discovery disabled
.mockResolvedValueOnce({
allowDomainDiscovery: false,
});
mockGetOrgsByDomain.mockResolvedValue({
result: [{ id: "10987654321", name: "Example Org" }],
});
const result = await sendLoginname({
loginName: "user@example.com",
// No organization parameter
});
// Should return error since discovery is disabled and no org context
expect(result).toEqual({ error: "errors.userNotFound" });
});
test("should not discover org if multiple orgs match the domain", async () => {
mockGetLoginSettings.mockResolvedValue({
allowRegister: true,
allowUsernamePassword: true,
ignoreUnknownUsernames: false,
});
// Mock org discovery to return multiple orgs
mockGetOrgsByDomain.mockResolvedValue({
result: [
{ id: "12345678910", name: "Example Org 1" },
{ id: "10987654321", name: "Example Org 2" },
],
});
const result = await sendLoginname({
loginName: "user@example.com",
// No organization parameter
});
// Should return error since multiple orgs match
expect(result).toEqual({ error: "errors.userNotFound" });
});
test("should use provided organization instead of discovering when org context exists", async () => {
mockGetLoginSettings.mockResolvedValue({
allowRegister: true,
allowUsernamePassword: true,
ignoreUnknownUsernames: false,
});
const result = await sendLoginname({
loginName: "user@example.com",
organization: "123456",
requestId: "req123",
});
expect(result).toBeDefined();
expect(result?.redirect).toMatch(/^\/register\?/);
expect(result?.redirect).toContain("organization=123456");
// Verify org discovery was NOT called since org was provided
expect(mockGetOrgsByDomain).not.toHaveBeenCalled();
});
});
describe("Edge cases", () => {
+74 -39
View File
@@ -59,16 +59,31 @@ export async function sendLoginname(command: SendLoginnameCommand) {
const searchResult = await searchUsers(searchUsersRequest);
// Safety check: ensure searchResult is defined
if (!searchResult) {
console.error("searchUsers returned undefined or null");
return { error: t("errors.couldNotSearchUsers") };
}
if ("error" in searchResult && searchResult.error) {
console.log("searchUsers returned error, returning early:", searchResult.error);
return searchResult;
}
if (!("result" in searchResult)) {
console.log("searchUsers has no result field");
return { error: t("errors.couldNotSearchUsers") };
}
const { result: potentialUsers } = searchResult;
// Additional safety check: treat undefined result as empty array
const users = potentialUsers ?? [];
if (users.length === 0) {
console.log("No users found, will proceed with org discovery");
}
const redirectUserToIDP = async (userId?: string, organization?: string) => {
// If userId is provided, check for user-specific IDP links first
let identityProviders: IDPLink[] = [];
@@ -194,11 +209,12 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}
};
if (potentialUsers.length > 1) {
if (users.length > 1) {
console.log("multiple users found, returning error");
return { error: t("errors.moreThanOneUserFound") };
} else if (potentialUsers.length == 1 && potentialUsers[0].userId) {
const user = potentialUsers[0];
const userId = potentialUsers[0].userId;
} else if (users.length == 1 && users[0].userId) {
const user = users[0];
const userId = users[0].userId;
const userLoginSettings = await getLoginSettings({
serviceUrl,
@@ -208,7 +224,7 @@ export async function sendLoginname(command: SendLoginnameCommand) {
// compare with the concatenated suffix when set
const concatLoginname = command.suffix ? `${command.loginName}@${command.suffix}` : command.loginName;
const humanUser = potentialUsers[0].type.case === "human" ? potentialUsers[0].type.value : undefined;
const humanUser = users[0].type.case === "human" ? users[0].type.value : undefined;
// recheck login settings after user discovery, as the search might have been done without org scope
if (userLoginSettings?.disableLoginWithEmail && userLoginSettings?.disableLoginWithPhone) {
@@ -391,44 +407,58 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}
}
// user not found, check if register is enabled on instance / organization context
if (loginSettingsByContext?.allowRegister && !loginSettingsByContext?.allowUsernamePassword) {
const resp = await redirectUserToIDP(undefined, command.organization);
if (resp) {
return resp;
}
return { error: t("errors.userNotFound") };
} else if (loginSettingsByContext?.allowRegister && loginSettingsByContext?.allowUsernamePassword) {
let orgToRegisterOn: string | undefined = command.organization;
console.log("user not found (0 potential users), checking registration options");
if (
!loginSettingsByContext?.ignoreUnknownUsernames &&
!orgToRegisterOn &&
command.loginName &&
ORG_SUFFIX_REGEX.test(command.loginName)
) {
const matched = ORG_SUFFIX_REGEX.exec(command.loginName);
const suffix = matched?.[1] ?? "";
// user not found, perform organization discovery if no org context provided
let discoveredOrganization = command.organization;
let effectiveLoginSettings = loginSettingsByContext;
// this just returns orgs where the suffix is set as primary domain
const orgs = await getOrgsByDomain({
serviceUrl,
domain: suffix,
});
const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined;
if (!discoveredOrganization && command.loginName && ORG_SUFFIX_REGEX.test(command.loginName)) {
const matched = ORG_SUFFIX_REGEX.exec(command.loginName);
const suffix = matched?.[1] ?? "";
// this just returns orgs where the suffix is set as primary domain
const orgs = await getOrgsByDomain({
serviceUrl,
domain: suffix,
});
const orgToCheckForDiscovery = orgs.result && orgs.result.length === 1 ? orgs.result[0].id : undefined;
if (orgToCheckForDiscovery) {
const orgLoginSettings = await getLoginSettings({
serviceUrl,
organization: orgToCheckForDiscovery,
});
if (orgLoginSettings?.allowDomainDiscovery) {
orgToRegisterOn = orgToCheckForDiscovery;
}
}
if (orgLoginSettings?.allowDomainDiscovery) {
console.log("org discovery successful, using org:", orgToCheckForDiscovery);
discoveredOrganization = orgToCheckForDiscovery;
// Use the discovered organization's login settings for subsequent checks
effectiveLoginSettings = orgLoginSettings;
} else {
console.log("org does not allow domain discovery");
}
} else {
console.log("no single org found for discovery");
}
}
// user not found, check if register is enabled on instance / organization context
if (effectiveLoginSettings?.allowRegister && !effectiveLoginSettings?.allowUsernamePassword) {
console.log("redirecting to IDP (register allowed, password not allowed)");
const resp = await redirectUserToIDP(undefined, discoveredOrganization);
if (resp) {
return resp;
}
console.log("IDP redirect failed, returning user not found");
return { error: t("errors.userNotFound") };
} else if (effectiveLoginSettings?.allowRegister && effectiveLoginSettings?.allowUsernamePassword) {
console.log("register and password both allowed");
// do not register user if ignoreUnknownUsernames is set
if (orgToRegisterOn && !loginSettingsByContext?.ignoreUnknownUsernames) {
const params = new URLSearchParams({ organization: orgToRegisterOn });
if (discoveredOrganization && !effectiveLoginSettings?.ignoreUnknownUsernames) {
console.log("redirecting to registration page with org:", discoveredOrganization);
const params = new URLSearchParams({ organization: discoveredOrganization });
if (command.requestId) {
params.set("requestId", command.requestId);
@@ -439,10 +469,16 @@ export async function sendLoginname(command: SendLoginnameCommand) {
}
return { redirect: "/register?" + params };
} else {
console.log("not redirecting to register:", {
hasDiscoveredOrg: !!discoveredOrganization,
ignoreUnknownUsernames: effectiveLoginSettings?.ignoreUnknownUsernames,
});
}
}
if (loginSettingsByContext?.ignoreUnknownUsernames) {
if (effectiveLoginSettings?.ignoreUnknownUsernames) {
console.log("ignoreUnknownUsernames is true, redirecting to password");
const paramsPasswordDefault = new URLSearchParams({
loginName: command.loginName,
});
@@ -451,14 +487,13 @@ export async function sendLoginname(command: SendLoginnameCommand) {
paramsPasswordDefault.append("requestId", command.requestId);
}
if (command.organization) {
paramsPasswordDefault.append("organization", command.organization);
if (discoveredOrganization) {
paramsPasswordDefault.append("organization", discoveredOrganization);
}
return { redirect: "/password?" + paramsPasswordDefault };
}
// fallbackToPassword
console.log("no valid registration option found, returning user not found");
return { error: t("errors.userNotFound") };
}
+4 -2
View File
@@ -658,7 +658,8 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga
const emailAndPhoneQueries: SearchQuery[] = [];
if (loginSettings.disableLoginWithEmail && loginSettings.disableLoginWithPhone) {
return { error: t("errors.userNotFound") };
// Both email and phone login are disabled, return empty result
return { result: [] };
} else if (loginSettings.disableLoginWithEmail && searchValue.length <= 20) {
const phoneQuery = PhoneQuery(searchValue);
emailAndPhoneQueries.push(phoneQuery);
@@ -718,7 +719,8 @@ export async function searchUsers({ serviceUrl, searchValue, loginSettings, orga
return emailOrPhoneResult;
}
return { error: t("errors.userNotFound") };
// No users found - return empty result, not an error
return { result: [] };
}
export async function getDefaultOrg({ serviceUrl }: { serviceUrl: string }): Promise<Organization | null> {