mirror of
https://github.com/zitadel/zitadel.git
synced 2025-12-02 14:12: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
(cherry picked from commit d5d68aed4b)
This commit is contained in:
committed by
Livio Spring
parent
c126001a4b
commit
6661e3d7c6
@@ -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
|
||||
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({
|
||||
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