From 17f49323dd168624f00c66ae21a6d2cc184cd2b7 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Wed, 8 Apr 2026 10:25:43 +0200 Subject: [PATCH] fix(login): preserve OIDC request context during email verification (#11990) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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). --- apps/login/locales/ar.json | 1 + apps/login/locales/de.json | 1 + apps/login/locales/en.json | 1 + apps/login/locales/es.json | 1 + apps/login/locales/fr.json | 1 + apps/login/locales/it.json | 1 + apps/login/locales/ja.json | 1 + apps/login/locales/nl.json | 1 + apps/login/locales/pl.json | 1 + apps/login/locales/ru.json | 1 + apps/login/locales/tr.json | 1 + apps/login/locales/uk.json | 1 + apps/login/locales/zh.json | 1 + .../src/app/(login)/verify/success/page.tsx | 19 +++- .../components/verify-success-continue.tsx | 27 ++++++ apps/login/src/lib/server/verify.test.ts | 94 +++++++++++++++++++ apps/login/src/lib/server/verify.ts | 6 +- 17 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 apps/login/src/components/verify-success-continue.tsx diff --git a/apps/login/locales/ar.json b/apps/login/locales/ar.json index 159afca41b..de508fdebe 100644 --- a/apps/login/locales/ar.json +++ b/apps/login/locales/ar.json @@ -382,6 +382,7 @@ "userIdMissing": "لم يتم تقديم userId!", "successTitle": "تم التحقق من المستخدم", "successDescription": "تم التحقق من المستخدم بنجاح.", + "successContinue": "متابعة", "setupAuthenticator": "إعداد المصادق", "verify": { "title": "التحقق من المستخدم", diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index 5ab2233d5a..c70aab118d 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -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", diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 4d4c327aee..16bd5be53b 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -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", diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index 99f6c5793f..cd969eeff4 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -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", diff --git a/apps/login/locales/fr.json b/apps/login/locales/fr.json index fbd868be02..1e7c3522ae 100644 --- a/apps/login/locales/fr.json +++ b/apps/login/locales/fr.json @@ -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", diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index d07976905c..f4c0d2f9ea 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -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", diff --git a/apps/login/locales/ja.json b/apps/login/locales/ja.json index 25b5f940c9..c03d454462 100644 --- a/apps/login/locales/ja.json +++ b/apps/login/locales/ja.json @@ -376,6 +376,7 @@ "userIdMissing": "ユーザーIDが指定されていません!", "successTitle": "ユーザー確認完了", "successDescription": "ユーザーの確認が正常に完了しました。", + "successContinue": "続行", "setupAuthenticator": "認証の設定", "verify": { "title": "ユーザー確認", diff --git a/apps/login/locales/nl.json b/apps/login/locales/nl.json index 8fc3b18034..101056f5ee 100644 --- a/apps/login/locales/nl.json +++ b/apps/login/locales/nl.json @@ -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", diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 8a3d33d65c..bdc65f3aab 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -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", diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index f7d7db2db5..4d4732f97f 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -383,6 +383,7 @@ "userIdMissing": "Не указан userId!", "successTitle": "Пользователь подтверждён", "successDescription": "Пользователь успешно подтверждён.", + "successContinue": "Продолжить", "setupAuthenticator": "Настроить аутентификатор", "verify": { "title": "Подтверждение пользователя", diff --git a/apps/login/locales/tr.json b/apps/login/locales/tr.json index 6dc1983ee4..d24a32ea25 100644 --- a/apps/login/locales/tr.json +++ b/apps/login/locales/tr.json @@ -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", diff --git a/apps/login/locales/uk.json b/apps/login/locales/uk.json index f8c028f9fc..cb0705c851 100644 --- a/apps/login/locales/uk.json +++ b/apps/login/locales/uk.json @@ -383,6 +383,7 @@ "userIdMissing": "userId не надано!", "successTitle": "Користувача підтверджено", "successDescription": "Користувача успішно підтверджено.", + "successContinue": "Продовжити", "setupAuthenticator": "Налаштувати автентифікатор", "verify": { "title": "Підтвердити користувача", diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index a9ff5fcee8..5cb3bb1c87 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -383,6 +383,7 @@ "userIdMissing": "未提供用户 ID!", "successTitle": "用户已验证", "successDescription": "用户已成功验证。", + "successContinue": "继续", "setupAuthenticator": "设置认证器", "verify": { "title": "验证用户", diff --git a/apps/login/src/app/(login)/verify/success/page.tsx b/apps/login/src/app/(login)/verify/success/page.tsx index 0a9d0d1e68..17f46653e9 100644 --- a/apps/login/src/app/(login)/verify/success/page.tsx +++ b/apps/login/src/app/(login)/verify/success/page.tsx @@ -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 }) { 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 }) { } } + // 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 (
@@ -64,7 +79,7 @@ export default async function Page(props: { searchParams: Promise }) { ) )}
-
+
{continueUrl && }
); } diff --git a/apps/login/src/components/verify-success-continue.tsx b/apps/login/src/components/verify-success-continue.tsx new file mode 100644 index 0000000000..2fe4c75e33 --- /dev/null +++ b/apps/login/src/components/verify-success-continue.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/apps/login/src/lib/server/verify.test.ts b/apps/login/src/lib/server/verify.test.ts index 1dde16d081..32eadc5247 100644 --- a/apps/login/src/lib/server/verify.test.ts +++ b/apps/login/src/lib/server/verify.test.ts @@ -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: "test@example.com", 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("test@example.com"); + }); + + 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: "test@example.com", 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: "test@example.com" }, + }); + mockListAuthenticationMethodTypes.mockResolvedValue({ + authMethodTypes: [], + }); + mockCreateSessionAndUpdateCookie.mockResolvedValue({ + session: { + id: "new-session-id", + factors: { user: { id: "user-1", loginName: "test@example.com", 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: "test@example.com", + organization: undefined, + }); + }); }); describe("initialSendVerification", () => { diff --git a/apps/login/src/lib/server/verify.ts b/apps/login/src/lib/server/verify.ts index 387c233242..34df847879 100644 --- a/apps/login/src/lib/server/verify.ts +++ b/apps/login/src/lib/server/verify.ts @@ -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();