fix(login): use correct requestId with oidc_ prefix in Prompt.LOGIN + loginHint flow (#12376)

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.
This commit is contained in:
Max Peintner
2026-07-03 14:18:01 +02:00
committed by GitHub
parent f32acbfd24
commit 92ea32a34d
2 changed files with 107 additions and 9 deletions
@@ -355,3 +355,103 @@ describe("handleOIDCFlowInitiation — org-scoped session filtering", () => {
expect(location).toContain("/loginname");
});
});
describe("handleOIDCFlowInitiation — Prompt.LOGIN + loginHint requestId prefix", () => {
let mockGetAuthRequest: ReturnType<typeof vi.fn>;
let mockConstructUrl: ReturnType<typeof vi.fn>;
let mockSendLoginname: ReturnType<typeof vi.fn>;
const existingSession = {
id: "session-1",
factors: { user: { id: "user1", organizationId: "org1", loginName: "[email protected]" } },
};
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: "[email protected]",
},
});
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: "[email protected]",
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: "[email protected]",
},
});
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",
}),
);
});
});