mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-24 11:38:27 +00:00
fix(login): Improve Passkey Authentication Error Handling & Testing (#10971)
# Which Problems Are Solved This PR enhances the passkey authentication flow with comprehensive error handling, full internationalization support, and extensive test coverage. # How the Problems Are Solved I18n: - Replaced all hard-coded error messages with i18n translation keys - Consistent error messaging throughout the passkey flow - Added specific error handling for passkey cancellation (NotAllowedError) - Implemented fallback errors for undefined/missing responses - Better error messages for: - Session retrieval failures - Challenge request failures - User verification errors - Redirect determination issues Tests: - Added `login-passkey.test.tsx` with 100+ test cases covering: - Successful verification flows - Error scenarios and edge cases - Props handling - Component lifecycle - Added passkeys.test.ts with server-side function tests: - Session cookie retrieval - User validation - Custom lifetime handling - Critical fallback error paths Try-catch blocks around critical user retrieval operations Defensive checks for undefined responses from completeFlowOrGetUrl Support for custom lifetime parameters Cleaner error propagation
This commit is contained in:
@@ -261,8 +261,28 @@
|
||||
"verify": {
|
||||
"title": "Mit einem Passkey authentifizieren",
|
||||
"description": "Ihr Gerät wird nach Ihrem Fingerabdruck, Gesicht oder Bildschirmsperre fragen",
|
||||
"info": {
|
||||
"description": "Verwenden Sie den von Ihnen eingerichteten Passkey, um sich sicher zu authentifizieren. ",
|
||||
"link": "Mehr über Passkeys erfahren"
|
||||
},
|
||||
"usePassword": "Passwort verwenden",
|
||||
"submit": "Weiter"
|
||||
"submit": "Weiter",
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Passkey-Challenge konnte nicht angefordert werden",
|
||||
"couldNotVerifyPasskey": "Passkey konnte nicht verifiziert werden",
|
||||
"noResponseReceived": "Passkey-Verifizierung fehlgeschlagen - keine Antwort erhalten",
|
||||
"noRedirectProvided": "Passkey-Verifizierung abgeschlossen, aber keine Weiterleitung bereitgestellt",
|
||||
"couldNotRetrievePasskey": "Fehler beim Abrufen des Passkeys",
|
||||
"verificationCancelled": "Passkey-Verifizierung wurde abgebrochen",
|
||||
"verificationFailed": "Fehler bei der Passkey-Verifizierung",
|
||||
"couldNotFindSession": "Sitzung konnte nicht gefunden werden",
|
||||
"couldNotUpdateSession": "Sitzung konnte nicht aktualisiert werden",
|
||||
"userNotFound": "Benutzer nicht im System gefunden",
|
||||
"couldNotDetermineRedirect": "Weiterleitungs-URL nach Passkey-Verifizierung konnte nicht ermittelt werden",
|
||||
"couldNotSetSession": "Sitzung konnte nicht gesetzt werden",
|
||||
"couldNotGetUser": "Benutzerdaten konnten nicht abgerufen werden",
|
||||
"couldNotRetrieveSessionCookie": "Sitzungs-Cookie konnte nicht abgerufen werden"
|
||||
}
|
||||
},
|
||||
"set": {
|
||||
"title": "Passkey einrichten",
|
||||
@@ -435,6 +455,7 @@
|
||||
"unknownContext": "Der Kontext des Benutzers konnte nicht ermittelt werden. Stellen Sie sicher, dass Sie zuerst den Benutzernamen eingeben oder einen loginName als Suchparameter angeben.",
|
||||
"sessionExpired": "Ihre aktuelle Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
|
||||
"failedLoading": "Daten konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
|
||||
"tryagain": "Erneut versuchen"
|
||||
"tryagain": "Erneut versuchen",
|
||||
"couldNotContinueSession": "Sitzung konnte nicht fortgesetzt werden - fehlende Benutzerinformationen"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +261,25 @@
|
||||
"verify": {
|
||||
"title": "Authenticate with a passkey",
|
||||
"description": "Your device will ask for your fingerprint, face, or screen lock",
|
||||
"info": {
|
||||
"description": "Use the passkey you set up to authenticate securely. ",
|
||||
"link": "Learn more about Passkeys"
|
||||
},
|
||||
"usePassword": "Use password",
|
||||
"submit": "Continue"
|
||||
"submit": "Continue",
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Could not request passkey challenge",
|
||||
"couldNotVerifyPasskey": "Could not verify passkey",
|
||||
"noResponseReceived": "Passkey verification failed - no response received",
|
||||
"noRedirectProvided": "Passkey verification completed but no redirect was provided",
|
||||
"couldNotRetrievePasskey": "An error occurred while retrieving passkey",
|
||||
"verificationCancelled": "Passkey verification was cancelled",
|
||||
"verificationFailed": "An error occurred during passkey verification",
|
||||
"couldNotFindSession": "Could not find session",
|
||||
"couldNotUpdateSession": "Could not update session",
|
||||
"userNotFound": "User not found in the system",
|
||||
"couldNotDetermineRedirect": "Could not determine redirect URL after passkey verification"
|
||||
}
|
||||
},
|
||||
"set": {
|
||||
"title": "Setup a passkey",
|
||||
@@ -435,6 +452,7 @@
|
||||
"unknownContext": "Could not get the context of the user. Make sure to enter the username first or provide a loginName as searchParam.",
|
||||
"sessionExpired": "Your current session has expired. Please login again.",
|
||||
"failedLoading": "Failed to load data. Please try again.",
|
||||
"tryagain": "Try Again"
|
||||
"tryagain": "Try Again",
|
||||
"couldNotContinueSession": "Could not continue with session - missing user information"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +261,28 @@
|
||||
"verify": {
|
||||
"title": "Autenticar con una clave de acceso",
|
||||
"description": "Tu dispositivo pedirá tu huella digital, rostro o bloqueo de pantalla",
|
||||
"info": {
|
||||
"description": "Usa la clave de acceso que configuraste para autenticarte de forma segura. ",
|
||||
"link": "Más información sobre claves de acceso"
|
||||
},
|
||||
"usePassword": "Usar contraseña",
|
||||
"submit": "Continuar"
|
||||
"submit": "Continuar",
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "No se pudo solicitar el desafío de clave de acceso",
|
||||
"couldNotVerifyPasskey": "No se pudo verificar la clave de acceso",
|
||||
"noResponseReceived": "Verificación de clave de acceso fallida - no se recibió respuesta",
|
||||
"noRedirectProvided": "Verificación de clave de acceso completada pero no se proporcionó redirección",
|
||||
"couldNotRetrievePasskey": "Ocurrió un error al recuperar la clave de acceso",
|
||||
"verificationCancelled": "La verificación de clave de acceso fue cancelada",
|
||||
"verificationFailed": "Ocurrió un error durante la verificación de clave de acceso",
|
||||
"couldNotFindSession": "No se pudo encontrar la sesión",
|
||||
"couldNotUpdateSession": "No se pudo actualizar la sesión",
|
||||
"userNotFound": "Usuario no encontrado en el sistema",
|
||||
"couldNotDetermineRedirect": "No se pudo determinar la URL de redirección después de la verificación de clave de acceso",
|
||||
"couldNotSetSession": "No se pudo establecer la sesión",
|
||||
"couldNotGetUser": "No se pudieron recuperar los datos del usuario",
|
||||
"couldNotRetrieveSessionCookie": "No se pudo recuperar la cookie de sesión"
|
||||
}
|
||||
},
|
||||
"set": {
|
||||
"title": "Configurar una clave de acceso",
|
||||
@@ -435,6 +455,7 @@
|
||||
"unknownContext": "No se pudo obtener el contexto del usuario. Asegúrate de ingresar primero el nombre de usuario o proporcionar un loginName como parámetro de búsqueda.",
|
||||
"sessionExpired": "Tu sesión actual ha expirado. Por favor, inicia sesión de nuevo.",
|
||||
"failedLoading": "No se pudieron cargar los datos. Por favor, inténtalo de nuevo.",
|
||||
"tryagain": "Intentar de nuevo"
|
||||
"tryagain": "Intentar de nuevo",
|
||||
"couldNotContinueSession": "No se pudo continuar con la sesión - falta información del usuario"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +261,28 @@
|
||||
"verify": {
|
||||
"title": "Autenticati con una passkey",
|
||||
"description": "Il tuo dispositivo chiederà la tua impronta digitale, il volto o il blocco schermo",
|
||||
"info": {
|
||||
"description": "Usa la passkey che hai configurato per autenticarti in modo sicuro. ",
|
||||
"link": "Scopri di più sulle Passkey"
|
||||
},
|
||||
"usePassword": "Usa password",
|
||||
"submit": "Continua"
|
||||
"submit": "Continua",
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Impossibile richiedere la sfida passkey",
|
||||
"couldNotVerifyPasskey": "Impossibile verificare la passkey",
|
||||
"noResponseReceived": "Verifica passkey fallita - nessuna risposta ricevuta",
|
||||
"noRedirectProvided": "Verifica passkey completata ma nessun reindirizzamento fornito",
|
||||
"couldNotRetrievePasskey": "Si è verificato un errore durante il recupero della passkey",
|
||||
"verificationCancelled": "La verifica della passkey è stata annullata",
|
||||
"verificationFailed": "Si è verificato un errore durante la verifica della passkey",
|
||||
"couldNotFindSession": "Impossibile trovare la sessione",
|
||||
"couldNotUpdateSession": "Impossibile aggiornare la sessione",
|
||||
"userNotFound": "Utente non trovato nel sistema",
|
||||
"couldNotDetermineRedirect": "Impossibile determinare l'URL di reindirizzamento dopo la verifica passkey",
|
||||
"couldNotSetSession": "Impossibile impostare la sessione",
|
||||
"couldNotGetUser": "Impossibile recuperare i dati dell'utente",
|
||||
"couldNotRetrieveSessionCookie": "Impossibile recuperare il cookie di sessione"
|
||||
}
|
||||
},
|
||||
"set": {
|
||||
"title": "Configura una passkey",
|
||||
@@ -435,6 +455,7 @@
|
||||
"unknownContext": "Impossibile ottenere il contesto dell'utente. Assicurati di inserire prima il nome utente o di fornire un loginName come parametro di ricerca.",
|
||||
"sessionExpired": "La tua sessione attuale è scaduta. Effettua nuovamente l'accesso.",
|
||||
"failedLoading": "Impossibile caricare i dati. Riprova.",
|
||||
"tryagain": "Riprova"
|
||||
"tryagain": "Riprova",
|
||||
"couldNotContinueSession": "Impossibile continuare con la sessione - informazioni utente mancanti"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +261,28 @@
|
||||
"verify": {
|
||||
"title": "Uwierzytelnij się za pomocą klucza dostępu",
|
||||
"description": "Twoje urządzenie poprosi o użycie odcisku palca, rozpoznawania twarzy lub blokady ekranu.",
|
||||
"info": {
|
||||
"description": "Użyj skonfigurowanego klucza dostępu, aby bezpiecznie się uwierzytelnić. ",
|
||||
"link": "Dowiedz się więcej o kluczach dostępu"
|
||||
},
|
||||
"usePassword": "Użyj hasła",
|
||||
"submit": "Kontynuuj"
|
||||
"submit": "Kontynuuj",
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Nie można zażądać wyzwania klucza dostępu",
|
||||
"couldNotVerifyPasskey": "Nie można zweryfikować klucza dostępu",
|
||||
"noResponseReceived": "Weryfikacja klucza dostępu nie powiodła się - nie otrzymano odpowiedzi",
|
||||
"noRedirectProvided": "Weryfikacja klucza dostępu zakończona, ale nie podano przekierowania",
|
||||
"couldNotRetrievePasskey": "Wystąpił błąd podczas pobierania klucza dostępu",
|
||||
"verificationCancelled": "Weryfikacja klucza dostępu została anulowana",
|
||||
"verificationFailed": "Wystąpił błąd podczas weryfikacji klucza dostępu",
|
||||
"couldNotFindSession": "Nie można znaleźć sesji",
|
||||
"couldNotUpdateSession": "Nie można zaktualizować sesji",
|
||||
"userNotFound": "Użytkownik nie znaleziony w systemie",
|
||||
"couldNotDetermineRedirect": "Nie można określić adresu URL przekierowania po weryfikacji klucza dostępu",
|
||||
"couldNotSetSession": "Nie można ustawić sesji",
|
||||
"couldNotGetUser": "Nie można pobrać danych użytkownika",
|
||||
"couldNotRetrieveSessionCookie": "Nie można pobrać pliku cookie sesji"
|
||||
}
|
||||
},
|
||||
"set": {
|
||||
"title": "Skonfiguruj klucz dostępu",
|
||||
@@ -435,6 +455,7 @@
|
||||
"unknownContext": "Nie udało się pobrać kontekstu użytkownika. Upewnij się, że najpierw wprowadziłeś nazwę użytkownika lub podałeś login jako parametr wyszukiwania.",
|
||||
"sessionExpired": "Twoja sesja wygasła. Zaloguj się ponownie.",
|
||||
"failedLoading": "Nie udało się załadować danych. Spróbuj ponownie.",
|
||||
"tryagain": "Spróbuj ponownie"
|
||||
"tryagain": "Spróbuj ponownie",
|
||||
"couldNotContinueSession": "Nie można kontynuować sesji - brakujące informacje o użytkowniku"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +261,28 @@
|
||||
"verify": {
|
||||
"title": "Аутентификация с помощью пасскей",
|
||||
"description": "Устройство запросит отпечаток пальца, лицо или экранный замок",
|
||||
"info": {
|
||||
"description": "Используйте настроенную пасскей для безопасной аутентификации. ",
|
||||
"link": "Узнать больше о пасскеях"
|
||||
},
|
||||
"usePassword": "Использовать пароль",
|
||||
"submit": "Продолжить"
|
||||
"submit": "Продолжить",
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "Не удалось запросить вызов пасскей",
|
||||
"couldNotVerifyPasskey": "Не удалось проверить пасскей",
|
||||
"noResponseReceived": "Проверка пасскей не удалась - ответ не получен",
|
||||
"noRedirectProvided": "Проверка пасскей завершена, но перенаправление не предоставлено",
|
||||
"couldNotRetrievePasskey": "Произошла ошибка при получении пасскей",
|
||||
"verificationCancelled": "Проверка пасскей была отменена",
|
||||
"verificationFailed": "Произошла ошибка при проверке пасскей",
|
||||
"couldNotFindSession": "Не удалось найти сеанс",
|
||||
"couldNotUpdateSession": "Не удалось обновить сеанс",
|
||||
"userNotFound": "Пользователь не найден в системе",
|
||||
"couldNotDetermineRedirect": "Не удалось определить URL перенаправления после проверки пасскей",
|
||||
"couldNotSetSession": "Не удалось установить сеанс",
|
||||
"couldNotGetUser": "Не удалось получить данные пользователя",
|
||||
"couldNotRetrieveSessionCookie": "Не удалось получить cookie сеанса"
|
||||
}
|
||||
},
|
||||
"set": {
|
||||
"title": "Настройка пасскей",
|
||||
@@ -435,6 +455,7 @@
|
||||
"unknownContext": "Не удалось получить контекст пользователя. Укажите имя пользователя или loginName в параметрах поиска.",
|
||||
"sessionExpired": "Ваша сессия истекла. Войдите снова.",
|
||||
"failedLoading": "Ошибка загрузки данных. Попробуйте ещё раз.",
|
||||
"tryagain": "Попробовать снова"
|
||||
"tryagain": "Попробовать снова",
|
||||
"couldNotContinueSession": "Не удалось продолжить сеанс - отсутствует информация о пользователе"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,8 +261,28 @@
|
||||
"verify": {
|
||||
"title": "使用密钥认证",
|
||||
"description": "您的设备将请求指纹、面部识别或屏幕锁",
|
||||
"info": {
|
||||
"description": "使用您设置的密钥进行安全认证。",
|
||||
"link": "了解更多关于密钥"
|
||||
},
|
||||
"usePassword": "使用密码",
|
||||
"submit": "继续"
|
||||
"submit": "继续",
|
||||
"errors": {
|
||||
"couldNotRequestChallenge": "无法请求密钥挑战",
|
||||
"couldNotVerifyPasskey": "无法验证密钥",
|
||||
"noResponseReceived": "密钥验证失败 - 未收到响应",
|
||||
"noRedirectProvided": "密钥验证完成但未提供重定向",
|
||||
"couldNotRetrievePasskey": "检索密钥时发生错误",
|
||||
"verificationCancelled": "密钥验证已取消",
|
||||
"verificationFailed": "密钥验证期间发生错误",
|
||||
"couldNotFindSession": "找不到会话",
|
||||
"couldNotUpdateSession": "无法更新会话",
|
||||
"userNotFound": "系统中未找到用户",
|
||||
"couldNotDetermineRedirect": "密钥验证后无法确定重定向URL",
|
||||
"couldNotSetSession": "无法设置会话",
|
||||
"couldNotGetUser": "无法检索用户数据",
|
||||
"couldNotRetrieveSessionCookie": "无法检索会话Cookie"
|
||||
}
|
||||
},
|
||||
"set": {
|
||||
"title": "设置密钥",
|
||||
@@ -435,6 +455,7 @@
|
||||
"unknownContext": "无法获取用户的上下文。请先输入用户名或提供 loginName 作为搜索参数。",
|
||||
"sessionExpired": "当前会话已过期,请重新登录。",
|
||||
"failedLoading": "加载数据失败,请再试一次。",
|
||||
"tryagain": "重试"
|
||||
"tryagain": "重试",
|
||||
"couldNotContinueSession": "无法继续会话 - 缺少用户信息"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert } from "@/components/alert";
|
||||
import { Alert, AlertType } from "@/components/alert";
|
||||
import { DynamicTheme } from "@/components/dynamic-theme";
|
||||
import { LoginPasskey } from "@/components/login-passkey";
|
||||
import { Translated } from "@/components/translated";
|
||||
@@ -71,15 +71,24 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{!(loginName || sessionId) && (
|
||||
<Alert type={AlertType.INFO}>
|
||||
<span>
|
||||
<Translated i18nKey="verify.info.description" namespace="passkey" />
|
||||
<a
|
||||
className="text-primary-light-500 hover:text-primary-light-300 dark:text-primary-dark-500 hover:dark:text-primary-dark-300"
|
||||
target="_blank"
|
||||
href="https://zitadel.com/docs/guides/manage/user/reg-create-user#with-passwordless"
|
||||
>
|
||||
<Translated i18nKey="verify.info.link" namespace="passkey" />
|
||||
</a>
|
||||
</span>
|
||||
</Alert>
|
||||
|
||||
{!(loginName || sessionId) ? (
|
||||
<Alert>
|
||||
<Translated i18nKey="unknownContext" namespace="error" />
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full">
|
||||
{(loginName || sessionId) && (
|
||||
) : (
|
||||
<LoginPasskey
|
||||
loginName={loginName}
|
||||
sessionId={sessionId}
|
||||
|
||||
@@ -20,7 +20,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
export default async function Page(props: { searchParams: Promise<Record<string | number | symbol, string | undefined>> }) {
|
||||
const searchParams = await props.searchParams;
|
||||
|
||||
const { userId, loginName, prompt, organization, requestId, code, id } = searchParams;
|
||||
const { userId, loginName, prompt, organization, requestId, code, codeId } = searchParams;
|
||||
|
||||
const _headers = await headers();
|
||||
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
||||
@@ -58,7 +58,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
|
||||
return (
|
||||
<DynamicTheme branding={branding}>
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="flex flex-col space-y-4">
|
||||
<h1>
|
||||
<Translated i18nKey="set.title" namespace="passkey" />
|
||||
</h1>
|
||||
@@ -114,7 +114,7 @@ export default async function Page(props: { searchParams: Promise<Record<string
|
||||
organization={organization}
|
||||
requestId={requestId}
|
||||
code={code}
|
||||
codeId={id}
|
||||
codeId={codeId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
441
apps/login/src/components/login-passkey.test.tsx
Normal file
441
apps/login/src/components/login-passkey.test.tsx
Normal file
@@ -0,0 +1,441 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { LoginPasskey } from "./login-passkey";
|
||||
import { NextIntlClientProvider } from "next-intl";
|
||||
|
||||
// Mock next/navigation
|
||||
const mockPush = vi.fn();
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: mockPush,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock server actions
|
||||
vi.mock("@/lib/server/passkeys", () => ({
|
||||
sendPasskey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/session", () => ({
|
||||
updateSession: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock navigator.credentials
|
||||
const mockCredentialsGet = vi.fn();
|
||||
Object.defineProperty(global.navigator, "credentials", {
|
||||
value: {
|
||||
get: mockCredentialsGet,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
|
||||
describe("LoginPasskey Component", () => {
|
||||
let mockSendPasskey: any;
|
||||
let mockUpdateSession: any;
|
||||
|
||||
const messages = {
|
||||
passkey: {
|
||||
verify: {
|
||||
title: "Authenticate with a passkey",
|
||||
description: "Your device will ask for your fingerprint",
|
||||
usePassword: "Use password",
|
||||
submit: "Continue",
|
||||
errors: {
|
||||
couldNotRequestChallenge: "Could not request passkey challenge",
|
||||
couldNotVerifyPasskey: "Could not verify passkey",
|
||||
noResponseReceived: "Passkey verification failed - no response received",
|
||||
noRedirectProvided: "Passkey verification completed but no redirect was provided",
|
||||
couldNotRetrievePasskey: "An error occurred while retrieving passkey",
|
||||
verificationCancelled: "Passkey verification was cancelled",
|
||||
verificationFailed: "An error occurred during passkey verification",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const renderWithIntl = (component: React.ReactElement) => {
|
||||
return render(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
{component}
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
mockPush.mockClear();
|
||||
mockCredentialsGet.mockClear();
|
||||
|
||||
const { sendPasskey } = await import("@/lib/server/passkeys");
|
||||
const { updateSession } = await import("@/lib/server/session");
|
||||
|
||||
mockSendPasskey = vi.mocked(sendPasskey);
|
||||
mockUpdateSession = vi.mocked(updateSession);
|
||||
});
|
||||
|
||||
describe("Initialization and Challenge Request", () => {
|
||||
test("should display error when challenge request fails", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Challenge failed",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Could not request passkey challenge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display translated error when no public key is returned", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Could not request passkey challenge")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should trigger passkey prompt when public key is available", async () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({
|
||||
redirect: "/success",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCredentialsGet).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Passkey Verification", () => {
|
||||
const setupSuccessfulChallenge = () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return mockPublicKey;
|
||||
};
|
||||
|
||||
test("should redirect on successful verification", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({
|
||||
redirect: "/success",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/success");
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when sendPasskey returns error", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({
|
||||
error: "Verification failed",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Verification failed")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when sendPasskey returns undefined", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue(undefined);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Passkey verification failed - no response received")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when sendPasskey returns object without redirect", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
|
||||
mockCredentialsGet.mockResolvedValue({
|
||||
id: "credential-id",
|
||||
rawId: new ArrayBuffer(8),
|
||||
type: "public-key",
|
||||
response: {
|
||||
authenticatorData: new ArrayBuffer(8),
|
||||
clientDataJSON: new ArrayBuffer(8),
|
||||
signature: new ArrayBuffer(8),
|
||||
userHandle: new ArrayBuffer(8),
|
||||
},
|
||||
});
|
||||
|
||||
mockSendPasskey.mockResolvedValue({});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Passkey verification completed but no redirect was provided")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display error when credential retrieval returns null", async () => {
|
||||
setupSuccessfulChallenge();
|
||||
mockCredentialsGet.mockResolvedValue(null);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("An error occurred while retrieving passkey")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error Handling for Passkey Cancellation", () => {
|
||||
test("should display cancellation message when user cancels passkey", async () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const notAllowedError = new Error("User cancelled");
|
||||
(notAllowedError as any).name = "NotAllowedError";
|
||||
mockCredentialsGet.mockRejectedValue(notAllowedError);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Passkey verification was cancelled")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("should display generic error for other credential errors", async () => {
|
||||
const mockPublicKey = {
|
||||
challenge: new Uint8Array([1, 2, 3]),
|
||||
allowCredentials: [
|
||||
{
|
||||
id: new Uint8Array([4, 5, 6]),
|
||||
type: "public-key",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
challenges: {
|
||||
webAuthN: {
|
||||
publicKeyCredentialRequestOptions: {
|
||||
publicKey: mockPublicKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const genericError = new Error("Unknown error");
|
||||
mockCredentialsGet.mockRejectedValue(genericError);
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("An error occurred during passkey verification")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Props Handling", () => {
|
||||
test("should pass sessionId to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey sessionId="session-123" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "session-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should pass loginName to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
loginName: "test@example.com",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should pass organization to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" organization="org-123" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
organization: "org-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("should pass requestId to server actions when provided", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
renderWithIntl(<LoginPasskey loginName="test@example.com" requestId="request-123" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
requestId: "request-123",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useEffect Initialization Guard", () => {
|
||||
test("should only initialize once even if component re-renders", async () => {
|
||||
mockUpdateSession.mockResolvedValue({
|
||||
error: "Test error",
|
||||
});
|
||||
|
||||
const { rerender } = renderWithIntl(<LoginPasskey loginName="test@example.com" altPassword={false} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Rerender the component
|
||||
rerender(
|
||||
<NextIntlClientProvider locale="en" messages={messages}>
|
||||
<LoginPasskey loginName="test@example.com" altPassword={false} />
|
||||
</NextIntlClientProvider>,
|
||||
);
|
||||
|
||||
// Should still only be called once
|
||||
expect(mockUpdateSession).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,11 @@ import { coerceToArrayBuffer, coerceToBase64Url } from "@/helpers/base64";
|
||||
import { sendPasskey } from "@/lib/server/passkeys";
|
||||
import { updateSession } from "@/lib/server/session";
|
||||
import { create, JsonObject } from "@zitadel/client";
|
||||
import {
|
||||
RequestChallengesSchema,
|
||||
UserVerificationRequirement,
|
||||
} from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { RequestChallengesSchema, UserVerificationRequirement } from "@zitadel/proto/zitadel/session/v2/challenge_pb";
|
||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Alert } from "./alert";
|
||||
import { BackButton } from "./back-button";
|
||||
import { Button, ButtonVariants } from "./button";
|
||||
@@ -27,17 +25,11 @@ type Props = {
|
||||
organization?: string;
|
||||
};
|
||||
|
||||
export function LoginPasskey({
|
||||
loginName,
|
||||
sessionId,
|
||||
requestId,
|
||||
altPassword,
|
||||
organization,
|
||||
login = true,
|
||||
}: Props) {
|
||||
export function LoginPasskey({ loginName, sessionId, requestId, altPassword, organization, login = true }: Props) {
|
||||
const [error, setError] = useState<string>("");
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const t = useTranslations("passkey");
|
||||
const router = useRouter();
|
||||
|
||||
const initialized = useRef(false);
|
||||
@@ -48,12 +40,10 @@ export function LoginPasskey({
|
||||
setLoading(true);
|
||||
updateSessionForChallenge()
|
||||
.then((response) => {
|
||||
const pK =
|
||||
response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions
|
||||
?.publicKey;
|
||||
const pK = response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions?.publicKey;
|
||||
|
||||
if (!pK) {
|
||||
setError("Could not request passkey challenge");
|
||||
setError(t("verify.errors.couldNotRequestChallenge"));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -97,7 +87,7 @@ export function LoginPasskey({
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not request passkey challenge");
|
||||
setError(t("verify.errors.couldNotRequestChallenge"));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -124,7 +114,7 @@ export function LoginPasskey({
|
||||
requestId,
|
||||
})
|
||||
.catch(() => {
|
||||
setError("Could not verify passkey");
|
||||
setError(t("verify.errors.couldNotVerifyPasskey"));
|
||||
return;
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -139,20 +129,19 @@ export function LoginPasskey({
|
||||
if (response && "redirect" in response && response.redirect) {
|
||||
return router.push(response.redirect);
|
||||
}
|
||||
|
||||
// If we got here, something went wrong - no redirect or error was returned
|
||||
if (!response) {
|
||||
setError(t("verify.errors.noResponseReceived"));
|
||||
} else {
|
||||
setError(t("verify.errors.noRedirectProvided"));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLoginAndContinue(
|
||||
publicKey: any,
|
||||
): Promise<boolean | void> {
|
||||
publicKey.challenge = coerceToArrayBuffer(
|
||||
publicKey.challenge,
|
||||
"publicKey.challenge",
|
||||
);
|
||||
async function submitLoginAndContinue(publicKey: any): Promise<boolean | void> {
|
||||
publicKey.challenge = coerceToArrayBuffer(publicKey.challenge, "publicKey.challenge");
|
||||
publicKey.allowCredentials.map((listItem: any) => {
|
||||
listItem.id = coerceToArrayBuffer(
|
||||
listItem.id,
|
||||
"publicKey.allowCredentials.id",
|
||||
);
|
||||
listItem.id = coerceToArrayBuffer(listItem.id, "publicKey.allowCredentials.id");
|
||||
});
|
||||
|
||||
navigator.credentials
|
||||
@@ -161,21 +150,15 @@ export function LoginPasskey({
|
||||
})
|
||||
.then((assertedCredential: any) => {
|
||||
if (!assertedCredential) {
|
||||
setError("An error on retrieving passkey");
|
||||
setError(t("verify.errors.couldNotRetrievePasskey"));
|
||||
return;
|
||||
}
|
||||
|
||||
const authData = new Uint8Array(
|
||||
assertedCredential.response.authenticatorData,
|
||||
);
|
||||
const clientDataJSON = new Uint8Array(
|
||||
assertedCredential.response.clientDataJSON,
|
||||
);
|
||||
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
|
||||
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
|
||||
const rawId = new Uint8Array(assertedCredential.rawId);
|
||||
const sig = new Uint8Array(assertedCredential.response.signature);
|
||||
const userHandle = new Uint8Array(
|
||||
assertedCredential.response.userHandle,
|
||||
);
|
||||
const userHandle = new Uint8Array(assertedCredential.response.userHandle);
|
||||
const data = {
|
||||
id: assertedCredential.id,
|
||||
rawId: coerceToBase64Url(rawId, "rawId"),
|
||||
@@ -190,6 +173,15 @@ export function LoginPasskey({
|
||||
|
||||
return submitLogin(data);
|
||||
})
|
||||
.catch((error) => {
|
||||
// Handle passkey cancellation or errors
|
||||
if (error?.name === "NotAllowedError") {
|
||||
setError(t("verify.errors.verificationCancelled"));
|
||||
} else {
|
||||
setError(t("verify.errors.verificationFailed"));
|
||||
}
|
||||
console.error("Passkey verification error:", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -249,12 +241,10 @@ export function LoginPasskey({
|
||||
setLoading(false);
|
||||
});
|
||||
|
||||
const pK =
|
||||
response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions
|
||||
?.publicKey;
|
||||
const pK = response?.challenges?.webAuthN?.publicKeyCredentialRequestOptions?.publicKey;
|
||||
|
||||
if (!pK) {
|
||||
setError("Could not request passkey challenge");
|
||||
setError(t("verify.errors.couldNotRequestChallenge"));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -271,8 +261,7 @@ export function LoginPasskey({
|
||||
}}
|
||||
data-testid="submit-button"
|
||||
>
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />}{" "}
|
||||
<Translated i18nKey="verify.submit" namespace="passkey" />
|
||||
{loading && <Spinner className="mr-2 h-5 w-5" />} <Translated i18nKey="verify.submit" namespace="passkey" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
413
apps/login/src/lib/server/passkeys.test.ts
Normal file
413
apps/login/src/lib/server/passkeys.test.ts
Normal file
@@ -0,0 +1,413 @@
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
import { sendPasskey } from "./passkeys";
|
||||
|
||||
// Mock all the dependencies
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@zitadel/client", () => ({
|
||||
create: vi.fn(),
|
||||
Duration: vi.fn(),
|
||||
Timestamp: vi.fn(),
|
||||
timestampDate: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../service-url", () => ({
|
||||
getServiceUrlFromHeaders: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../zitadel", () => ({
|
||||
getLoginSettings: vi.fn(),
|
||||
getUserByID: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./cookie", () => ({
|
||||
setSessionAndUpdateCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cookies", () => ({
|
||||
getSessionCookieById: vi.fn(),
|
||||
getSessionCookieByLoginName: vi.fn(),
|
||||
getMostRecentSessionCookie: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../verify-helper", () => ({
|
||||
checkEmailVerification: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../client", () => ({
|
||||
completeFlowOrGetUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock translations - returns the key itself for testing
|
||||
vi.mock("next-intl/server", () => ({
|
||||
getTranslations: vi.fn(() => (key: string) => key),
|
||||
}));
|
||||
|
||||
describe("sendPasskey", () => {
|
||||
let mockHeaders: any;
|
||||
let mockGetServiceUrlFromHeaders: any;
|
||||
let mockGetLoginSettings: any;
|
||||
let mockGetUserByID: any;
|
||||
let mockSetSessionAndUpdateCookie: any;
|
||||
let mockGetSessionCookieById: any;
|
||||
let mockGetSessionCookieByLoginName: any;
|
||||
let mockGetMostRecentSessionCookie: any;
|
||||
let mockCheckEmailVerification: any;
|
||||
let mockCompleteFlowOrGetUrl: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Import mocked modules
|
||||
const { headers } = await import("next/headers");
|
||||
const { getServiceUrlFromHeaders } = await import("../service-url");
|
||||
const { getLoginSettings, getUserByID } = await import("../zitadel");
|
||||
const { setSessionAndUpdateCookie } = await import("./cookie");
|
||||
const { getSessionCookieById, getSessionCookieByLoginName, getMostRecentSessionCookie } = await import("../cookies");
|
||||
const { checkEmailVerification } = await import("../verify-helper");
|
||||
const { completeFlowOrGetUrl } = await import("../client");
|
||||
|
||||
// Setup mocks
|
||||
mockHeaders = vi.mocked(headers);
|
||||
mockGetServiceUrlFromHeaders = vi.mocked(getServiceUrlFromHeaders);
|
||||
mockGetLoginSettings = vi.mocked(getLoginSettings);
|
||||
mockGetUserByID = vi.mocked(getUserByID);
|
||||
mockSetSessionAndUpdateCookie = vi.mocked(setSessionAndUpdateCookie);
|
||||
mockGetSessionCookieById = vi.mocked(getSessionCookieById);
|
||||
mockGetSessionCookieByLoginName = vi.mocked(getSessionCookieByLoginName);
|
||||
mockGetMostRecentSessionCookie = vi.mocked(getMostRecentSessionCookie);
|
||||
mockCheckEmailVerification = vi.mocked(checkEmailVerification);
|
||||
mockCompleteFlowOrGetUrl = vi.mocked(completeFlowOrGetUrl);
|
||||
|
||||
// Default mock implementations
|
||||
mockHeaders.mockResolvedValue(new Headers());
|
||||
mockGetServiceUrlFromHeaders.mockReturnValue({
|
||||
serviceUrl: "https://example.com",
|
||||
});
|
||||
mockGetLoginSettings.mockResolvedValue({
|
||||
multiFactorCheckLifetime: {
|
||||
seconds: BigInt(300),
|
||||
nanos: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session Cookie Retrieval", () => {
|
||||
test("should return error when session cookie not found by sessionId", async () => {
|
||||
mockGetSessionCookieById.mockResolvedValue(null);
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "test-session-id",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotFindSession",
|
||||
});
|
||||
expect(mockGetSessionCookieById).toHaveBeenCalledWith({
|
||||
sessionId: "test-session-id",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return error when session cookie is not found by loginName", async () => {
|
||||
mockGetSessionCookieByLoginName.mockResolvedValue(null);
|
||||
|
||||
const result = await sendPasskey({
|
||||
loginName: "test@example.com",
|
||||
organization: "org-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotFindSession",
|
||||
});
|
||||
expect(mockGetSessionCookieByLoginName).toHaveBeenCalledWith({
|
||||
loginName: "test@example.com",
|
||||
organization: "org-123",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return error when no session cookie found (most recent fallback)", async () => {
|
||||
mockGetMostRecentSessionCookie.mockResolvedValue(null);
|
||||
|
||||
const result = await sendPasskey({
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotFindSession",
|
||||
});
|
||||
expect(mockGetMostRecentSessionCookie).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Session Update Failures", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSessionCookieById.mockResolvedValue({
|
||||
id: "session-123",
|
||||
token: "session-token",
|
||||
loginName: "test@example.com",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return error when setSessionAndUpdateCookie fails", async () => {
|
||||
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
||||
error: "Failed to update session",
|
||||
});
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotUpdateSession",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return error when setSessionAndUpdateCookie returns undefined", async () => {
|
||||
mockSetSessionAndUpdateCookie.mockResolvedValue(undefined as any);
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotUpdateSession",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Validation", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSessionCookieById.mockResolvedValue({
|
||||
id: "session-123",
|
||||
token: "session-token",
|
||||
loginName: "test@example.com",
|
||||
});
|
||||
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
||||
sessionId: "session-123",
|
||||
sessionToken: "new-token",
|
||||
factors: {
|
||||
user: {
|
||||
id: "user-123",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should return error when getUserByID fails", async () => {
|
||||
mockGetUserByID.mockRejectedValue(new Error("User not found"));
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotGetUser",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Successful Passkey Verification", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSessionCookieById.mockResolvedValue({
|
||||
id: "session-123",
|
||||
token: "session-token",
|
||||
loginName: "test@example.com",
|
||||
});
|
||||
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
||||
id: "session-123",
|
||||
factors: {
|
||||
user: {
|
||||
id: "user-123",
|
||||
loginName: "test@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetUserByID.mockResolvedValue({
|
||||
user: {
|
||||
id: "user-123",
|
||||
type: {
|
||||
case: "human",
|
||||
value: {
|
||||
email: {
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockCheckEmailVerification.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("should redirect on successful verification without requestId", async () => {
|
||||
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/dashboard" });
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
redirect: "/dashboard",
|
||||
});
|
||||
});
|
||||
|
||||
test("should redirect on successful verification with requestId", async () => {
|
||||
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/auth/callback" });
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
requestId: "request-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
redirect: "/auth/callback",
|
||||
});
|
||||
});
|
||||
|
||||
test("should redirect for email verification when required", async () => {
|
||||
mockCheckEmailVerification.mockReturnValue({ redirect: "/verify" });
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty("redirect");
|
||||
if ("redirect" in result) {
|
||||
expect(result.redirect).toContain("/verify");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback Error Handling - Critical Fix", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSessionCookieById.mockResolvedValue({
|
||||
id: "session-123",
|
||||
token: "session-token",
|
||||
loginName: "test@example.com",
|
||||
});
|
||||
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
||||
id: "session-123",
|
||||
factors: {
|
||||
user: {
|
||||
id: "user-123",
|
||||
loginName: "test@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetUserByID.mockResolvedValue({
|
||||
user: {
|
||||
id: "user-123",
|
||||
type: {
|
||||
case: "human",
|
||||
value: {
|
||||
email: {
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockCheckEmailVerification.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("should return fallback error when completeFlowOrGetUrl returns undefined", async () => {
|
||||
mockCompleteFlowOrGetUrl.mockResolvedValue(undefined);
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotDetermineRedirect",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return fallback error when completeFlowOrGetUrl returns empty string", async () => {
|
||||
mockCompleteFlowOrGetUrl.mockResolvedValue("");
|
||||
|
||||
const result = await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
error: "verify.errors.couldNotDetermineRedirect",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Custom Lifetime Handling", () => {
|
||||
beforeEach(() => {
|
||||
mockGetSessionCookieById.mockResolvedValue({
|
||||
id: "session-123",
|
||||
token: "session-token",
|
||||
loginName: "test@example.com",
|
||||
});
|
||||
mockSetSessionAndUpdateCookie.mockResolvedValue({
|
||||
id: "session-123",
|
||||
factors: {
|
||||
user: {
|
||||
id: "user-123",
|
||||
loginName: "test@example.com",
|
||||
},
|
||||
},
|
||||
});
|
||||
mockGetUserByID.mockResolvedValue({
|
||||
user: {
|
||||
id: "user-123",
|
||||
type: {
|
||||
case: "human",
|
||||
value: {
|
||||
email: {
|
||||
isVerified: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockCheckEmailVerification.mockResolvedValue(true);
|
||||
mockCompleteFlowOrGetUrl.mockResolvedValue({ redirect: "/dashboard" });
|
||||
});
|
||||
|
||||
test("should use custom lifetime when provided", async () => {
|
||||
await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
lifetime: { seconds: BigInt(600), nanos: 0 } as any,
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lifetime: expect.objectContaining({
|
||||
seconds: BigInt(600),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test("should use default lifetime from login settings when not provided", async () => {
|
||||
await sendPasskey({
|
||||
sessionId: "session-123",
|
||||
checks: { webAuthN: { credentialAssertionData: {} } } as any,
|
||||
});
|
||||
|
||||
expect(mockSetSessionAndUpdateCookie).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lifetime: expect.objectContaining({
|
||||
seconds: BigInt(300),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
|
||||
import { headers } from "next/headers";
|
||||
import { userAgent } from "next/server";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { getMostRecentSessionCookie, getSessionCookieById, getSessionCookieByLoginName } from "../cookies";
|
||||
import { getServiceUrlFromHeaders } from "../service-url";
|
||||
import { checkEmailVerification, checkUserVerification } from "../verify-helper";
|
||||
@@ -262,6 +263,9 @@ type SendPasskeyCommand = {
|
||||
|
||||
export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
let { loginName, sessionId, organization, checks, requestId } = command;
|
||||
|
||||
const t = await getTranslations("passkey");
|
||||
|
||||
const recentSession = sessionId
|
||||
? await getSessionCookieById({ sessionId })
|
||||
: loginName
|
||||
@@ -270,7 +274,7 @@ export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
|
||||
if (!recentSession) {
|
||||
return {
|
||||
error: "Could not find session",
|
||||
error: t("verify.errors.couldNotFindSession"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -282,11 +286,15 @@ export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
organization,
|
||||
});
|
||||
|
||||
let lifetime = checks?.webAuthN
|
||||
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
|
||||
: checks?.otpEmail || checks?.otpSms
|
||||
? loginSettings?.secondFactorCheckLifetime
|
||||
: undefined;
|
||||
let lifetime = command.lifetime; // Use provided lifetime first
|
||||
|
||||
if (!lifetime) {
|
||||
lifetime = checks?.webAuthN
|
||||
? loginSettings?.multiFactorCheckLifetime // TODO different lifetime for webauthn u2f/passkey
|
||||
: checks?.otpEmail || checks?.otpSms
|
||||
? loginSettings?.secondFactorCheckLifetime
|
||||
: undefined;
|
||||
}
|
||||
|
||||
if (!lifetime || !lifetime.seconds) {
|
||||
console.warn("No passkey lifetime provided, defaulting to 24 hours");
|
||||
@@ -305,16 +313,22 @@ export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
});
|
||||
|
||||
if (!session || !session?.factors?.user?.id) {
|
||||
return { error: "Could not update session" };
|
||||
return { error: t("verify.errors.couldNotUpdateSession") };
|
||||
}
|
||||
|
||||
const userResponse = await getUserByID({
|
||||
serviceUrl,
|
||||
userId: session?.factors?.user?.id,
|
||||
});
|
||||
let userResponse;
|
||||
try {
|
||||
userResponse = await getUserByID({
|
||||
serviceUrl,
|
||||
userId: session?.factors?.user?.id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching user by ID:", error);
|
||||
return { error: t("verify.errors.couldNotGetUser") };
|
||||
}
|
||||
|
||||
if (!userResponse.user) {
|
||||
return { error: "User not found in the system" };
|
||||
return { error: t("verify.errors.userNotFound") };
|
||||
}
|
||||
|
||||
const humanUser = userResponse.user.type.case === "human" ? userResponse.user.type.value : undefined;
|
||||
@@ -325,8 +339,9 @@ export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
return emailVerificationCheck;
|
||||
}
|
||||
|
||||
let redirectResult;
|
||||
if (requestId && session.id) {
|
||||
return completeFlowOrGetUrl(
|
||||
redirectResult = await completeFlowOrGetUrl(
|
||||
{
|
||||
sessionId: session.id,
|
||||
requestId: requestId,
|
||||
@@ -335,7 +350,7 @@ export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
} else if (session?.factors?.user?.loginName) {
|
||||
return completeFlowOrGetUrl(
|
||||
redirectResult = await completeFlowOrGetUrl(
|
||||
{
|
||||
loginName: session.factors.user.loginName,
|
||||
organization: organization,
|
||||
@@ -343,4 +358,16 @@ export async function sendPasskey(command: SendPasskeyCommand) {
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if we got a valid redirect result
|
||||
if (redirectResult && typeof redirectResult === "object" && "redirect" in redirectResult && redirectResult.redirect) {
|
||||
return redirectResult;
|
||||
}
|
||||
|
||||
if (redirectResult && typeof redirectResult === "object" && "error" in redirectResult) {
|
||||
return redirectResult;
|
||||
}
|
||||
|
||||
// Fallback error if we couldn't determine where to redirect
|
||||
return { error: t("verify.errors.couldNotDetermineRedirect") };
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { RequestChallenges } from "@zitadel/proto/zitadel/session/v2/challenge_p
|
||||
import { Session } from "@zitadel/proto/zitadel/session/v2/session_pb";
|
||||
import { Checks } from "@zitadel/proto/zitadel/session/v2/session_service_pb";
|
||||
import { headers } from "next/headers";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { completeFlowOrGetUrl } from "../client";
|
||||
import {
|
||||
getMostRecentSessionCookie,
|
||||
@@ -74,6 +75,8 @@ export async function continueWithSession({ requestId, ...session }: ContinueWit
|
||||
const _headers = await headers();
|
||||
const { serviceUrl } = getServiceUrlFromHeaders(_headers);
|
||||
|
||||
const t = await getTranslations("error");
|
||||
|
||||
const loginSettings = await getLoginSettings({
|
||||
serviceUrl,
|
||||
organization: session.factors?.user?.organizationId,
|
||||
@@ -97,6 +100,9 @@ export async function continueWithSession({ requestId, ...session }: ContinueWit
|
||||
loginSettings?.defaultRedirectUri,
|
||||
);
|
||||
}
|
||||
|
||||
// Fallback error if we couldn't determine where to redirect
|
||||
return { error: t("couldNotContinueSession") };
|
||||
}
|
||||
|
||||
export type UpdateSessionCommand = {
|
||||
|
||||
Reference in New Issue
Block a user