mirror of
https://github.com/zitadel/zitadel.git
synced 2026-11-02 20:30:51 -06:00
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:
1 parent
fbb6a406c8
commit
17f49323dd
17 files changed
+156
-3
No files matched your search
@@ -382,6 +382,7 @@
|
||||
"userIdMissing": "لم يتم تقديم userId!",
|
||||
"successTitle": "تم التحقق من المستخدم",
|
||||
"successDescription": "تم التحقق من المستخدم بنجاح.",
|
||||
"successContinue": "متابعة",
|
||||
"setupAuthenticator": "إعداد المصادق",
|
||||
"verify": {
|
||||
"title": "التحقق من المستخدم",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -376,6 +376,7 @@
|
||||
"userIdMissing": "ユーザーIDが指定されていません!",
|
||||
"successTitle": "ユーザー確認完了",
|
||||
"successDescription": "ユーザーの確認が正常に完了しました。",
|
||||
"successContinue": "続行",
|
||||
"setupAuthenticator": "認証の設定",
|
||||
"verify": {
|
||||
"title": "ユーザー確認",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -383,6 +383,7 @@
|
||||
"userIdMissing": "Не указан userId!",
|
||||
"successTitle": "Пользователь подтверждён",
|
||||
"successDescription": "Пользователь успешно подтверждён.",
|
||||
"successContinue": "Продолжить",
|
||||
"setupAuthenticator": "Настроить аутентификатор",
|
||||
"verify": {
|
||||
"title": "Подтверждение пользователя",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -383,6 +383,7 @@
|
||||
"userIdMissing": "userId не надано!",
|
||||
"successTitle": "Користувача підтверджено",
|
||||
"successDescription": "Користувача успішно підтверджено.",
|
||||
"successContinue": "Продовжити",
|
||||
"setupAuthenticator": "Налаштувати автентифікатор",
|
||||
"verify": {
|
||||
"title": "Підтвердити користувача",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user