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);