From f32acbfd24b1d6f2b6d12301992e746f3e2a5148 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Thu, 2 Jul 2026 09:18:43 +0200 Subject: [PATCH] fix(login): redirect to loginname instead of empty accounts page when org scope filters all sessions (#12346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11914 # Which Problems Are Solved When an OIDC auth request includes an organization scope (`urn:zitadel:iam:org:id:{id}` or `urn:zitadel:iam:org:domain:primary:{domain}`), users with existing browser sessions from *other* organizations were shown an empty Account Selection page with no selectable accounts. The only option was to click "Add another account", adding a confusing and unnecessary extra step. This happened because the `/login` route checked `sessions.length` (all browser sessions, unfiltered) to decide whether to enter the "reuse existing session" branch. Inside that branch, `findValidSession` correctly filtered by organization and returned no match — but the fallback redirected to `/accounts`, which also filters by org and rendered empty. # How the Problems Are Solved Before deciding to redirect to `/accounts`, pre-filter sessions by the requested organization using the same logic already used by the accounts page and `findValidSession`. If no sessions are eligible for the target organization, redirect directly to `/loginname` instead. This applies to both the **default prompt** and **`prompt=select_account`** branches. For `select_account`, this matches the behavior of major OIDC providers like Google and Microsoft, which skip the account chooser and go straight to the login input when there are no sessions to select from. The OIDC spec describes `select_account` as enabling selection "amongst multiple accounts that they might have current sessions for" — showing an empty picker serves no purpose. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../src/lib/server/flow-initiation.test.ts | 176 ++++++++++++++++++ apps/login/src/lib/server/flow-initiation.ts | 69 ++++++- 2 files changed, 243 insertions(+), 2 deletions(-) diff --git a/apps/login/src/lib/server/flow-initiation.test.ts b/apps/login/src/lib/server/flow-initiation.test.ts index 383924d8c..ff7f25ee6 100644 --- a/apps/login/src/lib/server/flow-initiation.test.ts +++ b/apps/login/src/lib/server/flow-initiation.test.ts @@ -179,3 +179,179 @@ describe("handleOIDCFlowInitiation — locale / cookie handling", () => { }); }); }); + +describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { + let mockGetAuthRequest: ReturnType; + let mockConstructUrl: ReturnType; + let mockFindValidSession: ReturnType; + + const orgSession = { + id: "session-org", + factors: { user: { id: "user1", organizationId: "111111", loginName: "user@org.com" } }, + }; + const otherOrgSession = { + id: "session-other", + factors: { user: { id: "user2", organizationId: "999999", loginName: "user@other.com" } }, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + + const zitadel = await import("@/lib/zitadel"); + const serviceUrl = await import("@/lib/service-url"); + const session = await import("@/lib/session"); + const authUtils = await import("@/lib/auth-utils"); + + mockGetAuthRequest = vi.mocked(zitadel.getAuthRequest); + mockConstructUrl = vi.mocked(serviceUrl.constructUrl); + mockFindValidSession = vi.mocked(session.findValidSession); + vi.mocked(authUtils.getValidLocaleFromUILocales).mockReturnValue(null); + + mockConstructUrl.mockImplementation((_req: any, path: string) => { + return new URL(`https://example.com${path}`); + }); + + mockFindValidSession.mockResolvedValue(null); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("should redirect to /loginname when org scope filters out all sessions (default prompt)", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: ["urn:zitadel:iam:org:id:111111"], + prompt: [], + loginHint: undefined, + }, + }); + + 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).not.toContain("/accounts"); + }); + + test("should include submit=true when loginHint is set 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", + }, + }); + + 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).toContain("submit=true"); + expect(location).not.toContain("/accounts"); + }); + + test("should redirect to /accounts when org-eligible sessions exist but none are valid (default prompt)", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: ["urn:zitadel:iam:org:id:111111"], + prompt: [], + loginHint: undefined, + }, + }); + + const res = await handleOIDCFlowInitiation( + makeBaseParams({ + sessions: [orgSession] as any, + sessionCookies: [{ id: "session-org", token: "tok" }], + }), + ); + + const location = res.headers.get("location") ?? ""; + expect(location).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"); + + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: ["urn:zitadel:iam:org:id:111111"], + prompt: [Prompt.SELECT_ACCOUNT], + loginHint: undefined, + }, + }); + + 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).not.toContain("/accounts"); + }); + + test("should redirect to /accounts when org-eligible sessions exist (SELECT_ACCOUNT prompt)", async () => { + const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb"); + + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: ["urn:zitadel:iam:org:id:111111"], + prompt: [Prompt.SELECT_ACCOUNT], + loginHint: undefined, + }, + }); + + const res = await handleOIDCFlowInitiation( + makeBaseParams({ + sessions: [orgSession] as any, + sessionCookies: [{ id: "session-org", token: "tok" }], + }), + ); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/accounts"); + }); + + test("should redirect to /loginname when no org scope and no sessions", async () => { + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [], + loginHint: undefined, + }, + }); + + const res = await handleOIDCFlowInitiation(makeBaseParams({ sessions: [] })); + + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/loginname"); + }); +}); diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 05b7f61ec..1333897e9 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -74,6 +74,47 @@ const gotoAccounts = ({ return NextResponse.redirect(accountsUrl); }; +const gotoLoginname = ({ + request, + requestId, + loginHint, + organization, + suffix, +}: { + request: NextRequest; + requestId: string; + loginHint?: string; + organization?: string; + suffix?: string; +}): NextResponse => { + const loginNameUrl = constructUrl(request, "/loginname"); + loginNameUrl.searchParams.set("requestId", requestId); + + if (loginHint) { + loginNameUrl.searchParams.set("loginName", loginHint); + loginNameUrl.searchParams.set("submit", "true"); + } + if (organization) { + loginNameUrl.searchParams.set("organization", organization); + } + if (suffix) { + loginNameUrl.searchParams.set("suffix", suffix); + } + + return NextResponse.redirect(loginNameUrl); +}; + +/** + * Filter sessions by organization, matching the same logic used in + * the accounts page and findValidSession. + */ +function getEligibleSessions(sessions: Session[], organization?: string): Session[] { + if (!organization) { + return sessions; + } + return sessions.filter((s) => s.factors?.user?.organizationId === organization); +} + export interface FlowInitiationParams { serviceConfig: ServiceConfig; requestId: string; @@ -225,7 +266,20 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr // use existing session and hydrate it for oidc if (authRequest && sessions.length) { + // Pre-filter sessions by organization so we don't show an empty accounts + // page when the org scope excludes all existing browser sessions. + const eligibleSessions = getEligibleSessions(sessions, organization); + if (authRequest.prompt.includes(Prompt.SELECT_ACCOUNT)) { + if (eligibleSessions.length === 0) { + return gotoLoginname({ + request, + requestId: `oidc_${authRequest.id}`, + loginHint: authRequest.loginHint, + organization, + suffix, + }); + } return gotoAccounts({ request, requestId: `oidc_${authRequest.id}`, @@ -318,6 +372,17 @@ 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) { + return gotoLoginname({ + request, + requestId: `oidc_${authRequest.id}`, + loginHint: authRequest.loginHint, + organization, + suffix, + }); + } return gotoAccounts({ request, requestId: `oidc_${authRequest.id}`, @@ -384,11 +449,11 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr } if (organization) { - loginNameUrl.searchParams.append("organization", organization); + loginNameUrl.searchParams.set("organization", organization); } if (suffix) { - loginNameUrl.searchParams.append("suffix", suffix); + loginNameUrl.searchParams.set("suffix", suffix); } return NextResponse.redirect(loginNameUrl);