From 92ea32a34d1c1197fd4bfc8bffc2eb73e9987eee Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Fri, 3 Jul 2026 14:18:01 +0200 Subject: [PATCH] fix(login): use correct requestId with oidc_ prefix in Prompt.LOGIN + loginHint flow (#12376) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11946 # Which Problems Are Solved - OIDC redirect is broken when `Prompt.LOGIN` + `loginHint` is used — the user lands on `/signedin` instead of being redirected to the OIDC client's callback URL (fixes #11946) # How the Problems Are Solved - In `handleOIDCFlowInitiation`, the `Prompt.LOGIN` + `loginHint` code path was passing `authRequest.id` (raw ID without `oidc_` prefix) to `sendLoginname`. Without the prefix, `completeFlowOrGetUrl` does not recognize the flow as OIDC and falls through to the "Regular flow" path, redirecting to `/signedin` instead of calling `createCallback`. Fixed by using the `requestId` parameter which already carries the `oidc_` prefix. # Additional Changes - Standardized all request ID references in `handleOIDCFlowInitiation` to consistently use the `requestId` parameter instead of reconstructing it with `` `oidc_${authRequest.id}` ``. This eliminates the inconsistency that caused the bug and prevents similar issues in the future. - Removed a redundant `if (authRequest.id)` guard in the LDAP redirect path, since `requestId` is always present as a required parameter. - Added regression tests verifying that `sendLoginname` receives the `requestId` with the `oidc_` prefix in the `Prompt.LOGIN` + `loginHint` code path. --- .../src/lib/server/flow-initiation.test.ts | 100 ++++++++++++++++++ apps/login/src/lib/server/flow-initiation.ts | 16 ++- 2 files changed, 107 insertions(+), 9 deletions(-) diff --git a/apps/login/src/lib/server/flow-initiation.test.ts b/apps/login/src/lib/server/flow-initiation.test.ts index ff7f25ee6..e7f9fd246 100644 --- a/apps/login/src/lib/server/flow-initiation.test.ts +++ b/apps/login/src/lib/server/flow-initiation.test.ts @@ -355,3 +355,103 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => { expect(location).toContain("/loginname"); }); }); + +describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix", () => { + let mockGetAuthRequest: ReturnType; + let mockConstructUrl: ReturnType; + let mockSendLoginname: ReturnType; + + const existingSession = { + id: "session-1", + factors: { user: { id: "user1", organizationId: "org1", loginName: "user@example.com" } }, + }; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + + const zitadel = await import("@/lib/zitadel"); + const serviceUrl = await import("@/lib/service-url"); + const authUtils = await import("@/lib/auth-utils"); + const loginname = await import("@/lib/server/loginname"); + + mockGetAuthRequest = vi.mocked(zitadel.getAuthRequest); + mockConstructUrl = vi.mocked(serviceUrl.constructUrl); + mockSendLoginname = vi.mocked(loginname.sendLoginname); + vi.mocked(authUtils.getValidLocaleFromUILocales).mockReturnValue(null); + + mockConstructUrl.mockImplementation((_req: any, path: string) => { + return new URL(`https://example.com${path}`); + }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + test("should pass requestId with oidc_ prefix to sendLoginname (not authRequest.id)", async () => { + const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb"); + + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [Prompt.LOGIN], + loginHint: "user@example.com", + }, + }); + + mockSendLoginname.mockResolvedValue({ + redirect: "/password?loginName=user%40example.com", + }); + + await handleOIDCFlowInitiation( + makeBaseParams({ + requestId: "oidc_abc123", + sessions: [existingSession] as any, + sessionCookies: [{ id: "session-1", token: "tok" }], + }), + ); + + expect(mockSendLoginname).toHaveBeenCalledWith( + expect.objectContaining({ + loginName: "user@example.com", + requestId: "oidc_abc123", + }), + ); + }); + + test("should NOT pass raw authRequest.id without oidc_ prefix to sendLoginname", async () => { + const { Prompt } = await import("@zitadel/proto/zitadel/oidc/v2/authorization_pb"); + + mockGetAuthRequest.mockResolvedValue({ + authRequest: { + id: "abc123", + uiLocales: [], + scope: [], + prompt: [Prompt.LOGIN], + loginHint: "user@example.com", + }, + }); + + mockSendLoginname.mockResolvedValue({ + redirect: "/password?loginName=user%40example.com", + }); + + await handleOIDCFlowInitiation( + makeBaseParams({ + requestId: "oidc_abc123", + sessions: [existingSession] as any, + sessionCookies: [{ id: "session-1", token: "tok" }], + }), + ); + + // Ensure the raw authRequest.id is NOT what's passed + expect(mockSendLoginname).not.toHaveBeenCalledWith( + expect.objectContaining({ + requestId: "abc123", + }), + ); + }); +}); diff --git a/apps/login/src/lib/server/flow-initiation.ts b/apps/login/src/lib/server/flow-initiation.ts index 1333897e9..70f46892d 100644 --- a/apps/login/src/lib/server/flow-initiation.ts +++ b/apps/login/src/lib/server/flow-initiation.ts @@ -187,9 +187,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr if (identityProviderType === IdentityProviderType.LDAP) { const ldapUrl = constructUrl(request, "/ldap"); - if (authRequest.id) { - ldapUrl.searchParams.set("requestId", `oidc_${authRequest.id}`); - } + ldapUrl.searchParams.set("requestId", requestId); if (organization) { ldapUrl.searchParams.set("organization", organization); } @@ -274,7 +272,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr if (eligibleSessions.length === 0) { return gotoLoginname({ request, - requestId: `oidc_${authRequest.id}`, + requestId, loginHint: authRequest.loginHint, organization, suffix, @@ -282,7 +280,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr } return gotoAccounts({ request, - requestId: `oidc_${authRequest.id}`, + requestId, organization, }); } else if (authRequest.prompt.includes(Prompt.LOGIN)) { @@ -290,7 +288,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr try { let command: SendLoginnameCommand = { loginName: authRequest.loginHint, - requestId: authRequest.id, + requestId: requestId, }; if (organization) { @@ -377,7 +375,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr if (eligibleSessions.length === 0) { return gotoLoginname({ request, - requestId: `oidc_${authRequest.id}`, + requestId, loginHint: authRequest.loginHint, organization, suffix, @@ -385,7 +383,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr } return gotoAccounts({ request, - requestId: `oidc_${authRequest.id}`, + requestId, organization, }); } @@ -395,7 +393,7 @@ export async function handleOIDCFlowInitiation(params: FlowInitiationParams): Pr if (!cookie || !cookie.id || !cookie.token) { return gotoAccounts({ request, - requestId: `oidc_${authRequest.id}`, + requestId, organization, }); }