diff --git a/apps/login/src/lib/server/flow-initiation.test.ts b/apps/login/src/lib/server/flow-initiation.test.ts index e7f9fd246..aa32da1f2 100644 --- a/apps/login/src/lib/server/flow-initiation.test.ts +++ b/apps/login/src/lib/server/flow-initiation.test.ts @@ -32,6 +32,7 @@ vi.mock("@/lib/zitadel", () => ({ createResponse: vi.fn(), getActiveIdentityProviders: vi.fn(), getAuthRequest: vi.fn(), + getLoginSettings: vi.fn(), getOrgsByDomain: vi.fn(), getSAMLRequest: vi.fn(), getSecuritySettings: vi.fn(), @@ -184,6 +185,8 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { let mockGetAuthRequest: ReturnType; let mockConstructUrl: ReturnType; let mockFindValidSession: ReturnType; + let mockSendLoginname: ReturnType; + let mockGetLoginSettings: ReturnType; const orgSession = { id: "session-org", @@ -202,10 +205,14 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { const serviceUrl = await import("@/lib/service-url"); const session = await import("@/lib/session"); const authUtils = await import("@/lib/auth-utils"); + const loginname = await import("@/lib/server/loginname"); mockGetAuthRequest = vi.mocked(zitadel.getAuthRequest); mockConstructUrl = vi.mocked(serviceUrl.constructUrl); mockFindValidSession = vi.mocked(session.findValidSession); + mockSendLoginname = vi.mocked(loginname.sendLoginname); + mockGetLoginSettings = vi.mocked(zitadel.getLoginSettings); + mockGetLoginSettings.mockResolvedValue(undefined); vi.mocked(authUtils.getValidLocaleFromUILocales).mockReturnValue(null); mockConstructUrl.mockImplementation((_req: any, path: string) => { @@ -213,6 +220,9 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { }); mockFindValidSession.mockResolvedValue(null); + // Default: hint cannot be resolved to a redirect, exercising the prefilled + // /loginname fallback. Individual tests override this to assert the happy path. + mockSendLoginname.mockResolvedValue(undefined); }); afterEach(() => { @@ -242,7 +252,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { expect(location).not.toContain("/accounts"); }); - test("should include submit=true when loginHint is set and org scope filters out all sessions", async () => { + test("should resolve loginHint server-side (straight to next step) when org scope filters out all sessions", async () => { mockGetAuthRequest.mockResolvedValue({ authRequest: { id: "abc123", @@ -253,6 +263,36 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { }, }); + mockSendLoginname.mockResolvedValue({ + redirect: "/password?loginName=user%40example.com", + }); + + const res = await handleOIDCFlowInitiation( + makeBaseParams({ + sessions: [otherOrgSession] as any, + sessionCookies: [{ id: "session-other", token: "tok" }], + }), + ); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/password"); + expect(location).not.toContain("/loginname"); + expect(location).not.toContain("/accounts"); + }); + + test("should prefill /loginname WITHOUT submit=true when loginHint cannot be resolved and org scope filters out all sessions", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: ["urn:zitadel:iam:org:id:111111"], + prompt: [], + loginHint: "user@example.com", + }, + }); + + // mockSendLoginname resolves to undefined (default) → fallback path. + const res = await handleOIDCFlowInitiation( makeBaseParams({ sessions: [otherOrgSession] as any, @@ -263,7 +303,7 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { const location = res.headers.get("location") ?? ""; expect(location).toContain("/loginname"); expect(location).toContain("loginName=user%40example.com"); - expect(location).toContain("submit=true"); + expect(location).not.toContain("submit=true"); expect(location).not.toContain("/accounts"); }); @@ -289,6 +329,66 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { expect(location).toContain("/accounts"); }); + test("should resolve loginHint server-side when it matches no session but an unrelated session exists (default prompt)", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [], + loginHint: "user@example.com", + }, + }); + + // An unrelated session is present (so eligibleSessions is non-empty), but + // findValidSession filtered by the hint returns nothing. + mockFindValidSession.mockResolvedValue(null); + + mockSendLoginname.mockResolvedValue({ + redirect: "/password?loginName=user%40example.com", + }); + + const res = await handleOIDCFlowInitiation( + makeBaseParams({ + sessions: [otherOrgSession] as any, + sessionCookies: [{ id: "session-other", token: "tok" }], + }), + ); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/password"); + expect(location).not.toContain("/loginname"); + expect(location).not.toContain("/accounts"); + }); + + test("should fall back to prefilled /loginname (no submit) when loginHint cannot be resolved and an unrelated session exists (default prompt)", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [], + loginHint: "user@example.com", + }, + }); + + mockFindValidSession.mockResolvedValue(null); + // mockSendLoginname resolves to undefined (default) → fallback path. + + const res = await handleOIDCFlowInitiation( + makeBaseParams({ + sessions: [otherOrgSession] as any, + sessionCookies: [{ id: "session-other", token: "tok" }], + }), + ); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/loginname"); + expect(location).toContain("loginName=user%40example.com"); + expect(location).not.toContain("submit=true"); + expect(location).not.toContain("/accounts"); + }); + test("should redirect to /loginname when org scope filters out all sessions (SELECT_ACCOUNT prompt)", async () => { const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb"); @@ -354,6 +454,81 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { const location = res.headers.get("location") ?? ""; expect(location).toContain("/loginname"); }); + + test("should resolve loginHint server-side when there are no sessions (default prompt)", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [], + loginHint: "user@example.com", + }, + }); + + mockSendLoginname.mockResolvedValue({ + redirect: "/password?loginName=user%40example.com", + }); + + const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] })); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/password"); + expect(location).not.toContain("/loginname"); + }); + + test("should prefill /loginname WITHOUT submit=true when there are no sessions and loginHint cannot be resolved", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [], + loginHint: "user@example.com", + }, + }); + + // mockSendLoginname resolves to undefined (default) → fallback path. + + const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] })); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/loginname"); + expect(location).toContain("loginName=user%40example.com"); + expect(location).not.toContain("submit=true"); + }); + + test("should forward ignoreUnknownUsernames from login settings when resolving loginHint", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [], + loginHint: "unknown@example.com", + }, + }); + + // Org has enumeration protection enabled: sendLoginname must receive the + // flag so an unknown hint still redirects to the (fake) /password step. + mockGetLoginSettings.mockResolvedValue({ ignoreUnknownUsernames: true }); + mockSendLoginname.mockResolvedValue({ + redirect: "/password?loginName=unknown%40example.com", + }); + + const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] })); + + expect(mockSendLoginname).toHaveBeenCalledWith( + expect.objectContaining({ + loginName: "unknown@example.com", + ignoreUnknownUsernames: true, + }), + ); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/password"); + expect(location).not.toContain("/loginname"); + }); }); describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix", () => { diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index b55f1881c..034b2f035 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -5,7 +5,7 @@ import { getLanguageCookie, setLanguageCookie } from "@/lib/cookies"; import { shouldUILocalesOverrideCookie } from "@/lib/i18n"; import { idpTypeToSlug } from "@/lib/idp"; import { createLogger } from "@/lib/logger"; -import { sendLoginname, SendLoginnameCommand } from "@/lib/server/loginname"; +import { sendLoginname } from "@/lib/server/loginname"; import { constructUrl } from "@/lib/service-url"; import { findValidSession } from "@/lib/session"; import { @@ -13,6 +13,7 @@ import { createResponse, getActiveIdentityProviders, getAuthRequest, + getLoginSettings, getOrgsByDomain, getSAMLRequest, getSecuritySettings, @@ -95,9 +96,9 @@ const gotoLoginname = ({ const loginNameUrl = constructUrl(request, "/loginname"); loginNameUrl.searchParams.set("requestId", requestId); + // Prefill only, no auto-submit: see resolveLoginHint. if (loginHint) { loginNameUrl.searchParams.set("loginName", loginHint); - loginNameUrl.searchParams.set("submit", "true"); } if (organization) { loginNameUrl.searchParams.set("organization", organization); @@ -109,6 +110,57 @@ const gotoLoginname = ({ return NextResponse.redirect(loginNameUrl); }; +/** + * Resolve a login_hint server-side to a redirect straight to the next step + * (e.g. /password), skipping the /loginname screen entirely. Returns null when + * there is no hint or it can't be resolved (unknown/ambiguous user, transient + * error), in which case callers fall back to the prefilled /loginname screen. + */ +const resolveLoginHint = async ({ + serviceConfig, + request, + requestId, + loginHint, + organization, +}: { + serviceConfig: ServiceConfig; + request: NextRequest; + requestId: string; + loginHint?: string; + organization?: string; +}): Promise | null> => { + if (!loginHint) { + return null; + } + + try { + // Mirror the client-side loginname form: forward ignoreUnknownUsernames so + // an unknown hint redirects to the (fake) /password step like a known one, + // instead of falling back to /loginname and disclosing that the user does + // not exist. getLoginSettings is TTL-cached, so this adds no extra RPC. + const loginSettings = await getLoginSettings({ serviceConfig, organization: organization || undefined }); + + const res = await sendLoginname({ + loginName: loginHint, + requestId, + organization: organization || undefined, + ignoreUnknownUsernames: loginSettings?.ignoreUnknownUsernames, + }); + + if (res && "redirect" in res && res.redirect) { + return NextResponse.redirect(constructUrl(request, res.redirect)); + } + + if (res && "error" in res && res.error) { + logger.debug("login_hint could not be resolved, falling back to /loginname", { error: res.error }); + } + } catch (error) { + logger.error("Failed to resolve login_hint via sendLoginname:", { error }); + } + + return null; +}; + /** * Filter sessions by organization, matching the same logic used in * the accounts page and findValidSession. @@ -290,41 +342,25 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr orgDomain, }); } else if (authRequest.prompt.includes(Prompt.LOGIN)) { - if (authRequest.loginHint) { - try { - let command: SendLoginnameCommand = { - loginName: authRequest.loginHint, - requestId: requestId, - }; + const hintResponse = await resolveLoginHint({ + serviceConfig, + request, + requestId, + loginHint: authRequest.loginHint, + organization, + }); - if (organization) { - command = { ...command, organization }; - } - - const res = await sendLoginname(command); - - if (res && "redirect" in res && res?.redirect) { - const absoluteUrl = constructUrl(request, res.redirect); - return NextResponse.redirect(absoluteUrl.toString()); - } - } catch (error) { - logger.error("Failed to execute sendLoginname:", { error }); - } + if (hintResponse) { + return hintResponse; } - const loginNameUrl = constructUrl(request, "/loginname"); - loginNameUrl.searchParams.set("requestId", requestId); - - if (authRequest.loginHint) { - loginNameUrl.searchParams.set("loginName", authRequest.loginHint); - } - if (organization) { - loginNameUrl.searchParams.set("organization", organization); - } - if (orgDomain) { - loginNameUrl.searchParams.set("orgDomain", orgDomain); - } - return NextResponse.redirect(loginNameUrl); + return gotoLoginname({ + request, + requestId, + loginHint: authRequest.loginHint, + organization, + orgDomain, + }); } else if (authRequest.prompt.includes(Prompt.NONE)) { let securitySettings: SecuritySettings | undefined; try { @@ -376,9 +412,22 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr let selectedSession = await findValidSession({ serviceConfig, sessions, authRequest, organization }); if (!selectedSession || !selectedSession.id) { - // If no sessions are eligible for this organization, go directly to - // loginname instead of showing an empty accounts page. - if (eligibleSessions.length === 0) { + // login_hint matches no session: resolve it straight to the next step. + const hintResponse = await resolveLoginHint({ + serviceConfig, + request, + requestId, + loginHint: authRequest.loginHint, + organization, + }); + + if (hintResponse) { + return hintResponse; + } + + // Prefill loginname (not the account picker) for an unresolved hint or when + // no session is eligible: the picker would only list non-matching accounts. + if (authRequest.loginHint || eligibleSessions.length === 0) { return gotoLoginname({ request, requestId, @@ -448,23 +497,26 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr } } } else { - const loginNameUrl = constructUrl(request, "/loginname"); - loginNameUrl.searchParams.set("requestId", requestId); + // No session: resolve a login_hint straight to the next step if we can. + const hintResponse = await resolveLoginHint({ + serviceConfig, + request, + requestId, + loginHint: authRequest?.loginHint, + organization, + }); - if (authRequest?.loginHint) { - loginNameUrl.searchParams.set("loginName", authRequest.loginHint); - loginNameUrl.searchParams.set("submit", "true"); + if (hintResponse) { + return hintResponse; } - if (organization) { - loginNameUrl.searchParams.set("organization", organization); - } - - if (orgDomain) { - loginNameUrl.searchParams.set("orgDomain", orgDomain); - } - - return NextResponse.redirect(loginNameUrl); + return gotoLoginname({ + request, + requestId, + loginHint: authRequest?.loginHint, + organization, + orgDomain, + }); } }