fix(login): preserve OIDC request context during email verification (#11990)

# Which Problems Are Solved

When a user needed to verify their email during an OIDC login flow, the
requestId was lost, preventing the flow from completing with a redirect
back to the relying party.

# How the Problems Are Solved

In `sendVerification()`, the session cookie lookup used `"loginName" in
command` to decide whether to fall back to `user.preferredLoginName`.
Since the `loginName` property key always exists in the command object
(even when its value is undefined), the fallback was never triggered.
This caused the lookup to search for loginName === undefined, finding no
session — and ultimately redirecting to a dead-end success page.

- /authenticator/set redirect: Added missing requestId to the URL params
when redirecting users who need to set up a primary auth method.
- /verify/success page: Added a "Continue" button that re-enters the
login flow with requestId preserved, handling the edge case where no
session cookie exists (e.g. email link opened in a different browser).
This commit is contained in:
Max Peintner
2026-04-08 10:25:43 +02:00
committed by GitHub
parent fbb6a406c8
commit 17f49323dd
17 changed files with 156 additions and 3 deletions
+1
View File
@@ -382,6 +382,7 @@
"userIdMissing": "لم يتم تقديم userId!",
"successTitle": "تم التحقق من المستخدم",
"successDescription": "تم التحقق من المستخدم بنجاح.",
"successContinue": "متابعة",
"setupAuthenticator": "إعداد المصادق",
"verify": {
"title": "التحقق من المستخدم",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "Keine Benutzer-ID angegeben!",
"successTitle": "Benutzer verifiziert",
"successDescription": "Der Benutzer wurde erfolgreich verifiziert.",
"successContinue": "Weiter",
"setupAuthenticator": "Authentifikator einrichten",
"verify": {
"title": "Benutzer verifizieren",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "No userId provided!",
"successTitle": "User verified",
"successDescription": "The user has been verified successfully.",
"successContinue": "Continue",
"setupAuthenticator": "Setup authenticator",
"verify": {
"title": "Verify user",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "¡No se proporcionó userId!",
"successTitle": "Usuario verificado",
"successDescription": "El usuario ha sido verificado con éxito.",
"successContinue": "Continuar",
"setupAuthenticator": "Configurar autenticador",
"verify": {
"title": "Verificar usuario",
+1
View File
@@ -351,6 +351,7 @@
"userIdMissing": "Aucun identifiant utilisateur fourni !",
"successTitle": "Utilisateur vérifié",
"successDescription": "L'utilisateur a été vérifié avec succès.",
"successContinue": "Continuer",
"setupAuthenticator": "Configurer l'authentificateur",
"verify": {
"title": "Vérifier l'utilisateur",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "Nessun userId fornito!",
"successTitle": "Utente verificato",
"successDescription": "L'utente è stato verificato con successo.",
"successContinue": "Continua",
"setupAuthenticator": "Configura autenticatore",
"verify": {
"title": "Verifica utente",
+1
View File
@@ -376,6 +376,7 @@
"userIdMissing": "ユーザーIDが指定されていません!",
"successTitle": "ユーザー確認完了",
"successDescription": "ユーザーの確認が正常に完了しました。",
"successContinue": "続行",
"setupAuthenticator": "認証の設定",
"verify": {
"title": "ユーザー確認",
+1
View File
@@ -381,6 +381,7 @@
"userIdMissing": "Geen gebruikers-ID opgegeven!",
"successTitle": "Gebruiker geverifieerd",
"successDescription": "De gebruiker is succesvol geverifieerd.",
"successContinue": "Doorgaan",
"setupAuthenticator": "Authenticator instellen",
"verify": {
"title": "Gebruiker verifiëren",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "Nie podano identyfikatora użytkownika!",
"successTitle": "Weryfikacja zakończona",
"successDescription": "Użytkownik został pomyślnie zweryfikowany.",
"successContinue": "Kontynuuj",
"setupAuthenticator": "Skonfiguruj uwierzytelnianie",
"verify": {
"title": "Zweryfikuj użytkownika",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "Не указан userId!",
"successTitle": "Пользователь подтверждён",
"successDescription": "Пользователь успешно подтверждён.",
"successContinue": "Продолжить",
"setupAuthenticator": "Настроить аутентификатор",
"verify": {
"title": "Подтверждение пользователя",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "userId sağlanmadı!",
"successTitle": "Kullanıcı doğrulandı",
"successDescription": "Kullanıcı başarıyla doğrulandı.",
"successContinue": "Devam",
"setupAuthenticator": "Kimlik doğrulayıcı kur",
"verify": {
"title": "Kullanıcıyı doğrula",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "userId не надано!",
"successTitle": "Користувача підтверджено",
"successDescription": "Користувача успішно підтверджено.",
"successContinue": "Продовжити",
"setupAuthenticator": "Налаштувати автентифікатор",
"verify": {
"title": "Підтвердити користувача",
+1
View File
@@ -383,6 +383,7 @@
"userIdMissing": "未提供用户 ID!",
"successTitle": "用户已验证",
"successDescription": "用户已成功验证。",
"successContinue": "继续",
"setupAuthenticator": "设置认证器",
"verify": {
"title": "验证用户",
@@ -1,6 +1,7 @@
import { DynamicTheme } from "@/components/dynamic-theme";
import { Translated } from "@/components/translated";
import { UserAvatar } from "@/components/user-avatar";
import { VerifySuccessContinue } from "@/components/verify-success-continue";
import { getServiceConfig } from "@/lib/service-url";
import { loadMostRecentSession } from "@/lib/session";
import { getBrandingSettings, getUserByID } from "@/lib/zitadel";
@@ -13,7 +14,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
const _headers = await headers();
const { serviceConfig } = getServiceConfig(_headers);
const { loginName, organization, userId } = searchParams;
const { loginName, organization, userId, requestId } = searchParams;
const branding = await getBrandingSettings({ serviceConfig, organization });
@@ -41,6 +42,20 @@ export default async function Page(props: { searchParams: Promise<any> }) {
}
}
// Build continue URL to re-enter the login flow with requestId preserved
let continueUrl: string | undefined;
if (requestId) {
const params = new URLSearchParams();
if (loginName || user?.preferredLoginName) {
params.set("loginName", loginName ?? user?.preferredLoginName ?? "");
}
if (organization) {
params.set("organization", organization);
}
params.set("requestId", requestId);
continueUrl = `/loginname?${params}`;
}
return (
<DynamicTheme branding={branding}>
<div className="flex flex-col space-y-4">
@@ -64,7 +79,7 @@ export default async function Page(props: { searchParams: Promise<any> }) {
)
)}
</div>
<div className="w-full"></div>
<div className="w-full">{continueUrl && <VerifySuccessContinue continueUrl={continueUrl} />}</div>
</DynamicTheme>
);
}
@@ -0,0 +1,27 @@
"use client";
import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation";
import { Button, ButtonVariants } from "./button";
type Props = {
continueUrl: string;
};
export function VerifySuccessContinue({ continueUrl }: Props) {
const router = useRouter();
const t = useTranslations("verify");
return (
<div className="mt-8 flex w-full flex-row items-center justify-end">
<Button
type="button"
variant={ButtonVariants.Primary}
onClick={() => router.push(continueUrl)}
data-testid="continue-button"
>
{t("successContinue")}
</Button>
</div>
);
}
+94
View File
@@ -123,6 +123,100 @@ describe("sendVerification", () => {
// Verify creation was attempted
expect(mockCreateSessionAndUpdateCookie).toHaveBeenCalled();
});
test("should include requestId in /authenticator/set redirect when provided", async () => {
// 1. No existing session cookie
mockGetSessionCookieByLoginName.mockResolvedValue(undefined);
// 2. No auth methods (user needs setup)
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [],
});
// 3. Mock session creation success
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: {
id: "new-session-id",
factors: { user: { id: "user-1", loginName: "[email protected]", organizationId: "org-1" } },
},
});
const result = await sendVerification({
userId: "user-1",
code: "123456",
isInvite: false,
requestId: "oidc_auth-req-123",
});
// Expect redirect to include requestId
expect(result).toEqual({
redirect: expect.stringContaining("/authenticator/set?"),
});
const redirectUrl = (result as { redirect: string }).redirect;
const params = new URLSearchParams(redirectUrl.split("?")[1]);
expect(params.get("requestId")).toBe("oidc_auth-req-123");
expect(params.get("sessionId")).toBe("new-session-id");
expect(params.get("loginName")).toBe("[email protected]");
});
test("should NOT include requestId in /authenticator/set redirect when not provided", async () => {
// 1. No existing session cookie
mockGetSessionCookieByLoginName.mockResolvedValue(undefined);
// 2. No auth methods (user needs setup)
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [],
});
// 3. Mock session creation success
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: {
id: "new-session-id",
factors: { user: { id: "user-1", loginName: "[email protected]", organizationId: "org-1" } },
},
});
const result = await sendVerification({
userId: "user-1",
code: "123456",
isInvite: false,
// no requestId
});
const redirectUrl = (result as { redirect: string }).redirect;
const params = new URLSearchParams(redirectUrl.split("?")[1]);
expect(params.get("requestId")).toBeNull();
});
test("should fall back to user.preferredLoginName for session cookie lookup when loginName is undefined", async () => {
// Simulate the email-link scenario: loginName is undefined but userId is provided
mockGetSessionCookieByLoginName.mockResolvedValue(undefined);
mockGetUserByID.mockResolvedValue({
user: { userId: "user-1", preferredLoginName: "[email protected]" },
});
mockListAuthenticationMethodTypes.mockResolvedValue({
authMethodTypes: [],
});
mockCreateSessionAndUpdateCookie.mockResolvedValue({
session: {
id: "new-session-id",
factors: { user: { id: "user-1", loginName: "[email protected]", organizationId: "org-1" } },
},
});
await sendVerification({
userId: "user-1",
code: "123456",
isInvite: false,
loginName: undefined, // explicitly undefined, like from email link
});
// The key assertion: session cookie lookup should use preferredLoginName as fallback
expect(mockGetSessionCookieByLoginName).toHaveBeenCalledWith({
loginName: "[email protected]",
organization: undefined,
});
});
});
describe("initialSendVerification", () => {
+5 -1
View File
@@ -92,7 +92,7 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
const user = userResponse.user;
const sessionCookie = await getSessionCookieByLoginName({
loginName: "loginName" in command ? command.loginName : user.preferredLoginName,
loginName: command.loginName ?? user.preferredLoginName,
organization: command.organization,
});
@@ -156,6 +156,10 @@ export async function sendVerification(command: VerifyUserByEmailCommand) {
params.set("loginName", session.factors?.user?.loginName);
}
if (command.requestId) {
params.set("requestId", command.requestId);
}
// set hash of userId and userAgentId to prevent attacks, checks are done for users with invalid sessions and invalid userAgentId
const cookiesList = await cookies();
const userAgentId = await getOrSetFingerprintId();