mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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") };
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user