From d5d68aed4b8541608acacf385f39b67025f87733 Mon Sep 17 00:00:00 2001 From: Max Peintner Date: Mon, 27 Oct 2025 15:38:04 +0100 Subject: [PATCH] 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 --- apps/login/locales/de.json | 25 +- apps/login/locales/en.json | 22 +- apps/login/locales/es.json | 25 +- apps/login/locales/it.json | 25 +- apps/login/locales/pl.json | 25 +- apps/login/locales/ru.json | 25 +- apps/login/locales/zh.json | 25 +- apps/login/src/app/(login)/passkey/page.tsx | 23 +- .../src/app/(login)/passkey/set/page.tsx | 6 +- .../src/components/login-passkey.test.tsx | 441 ++++++++++++++++++ apps/login/src/components/login-passkey.tsx | 79 ++-- apps/login/src/lib/server/passkeys.test.ts | 413 ++++++++++++++++ apps/login/src/lib/server/passkeys.ts | 55 ++- apps/login/src/lib/server/session.ts | 6 + 14 files changed, 1112 insertions(+), 83 deletions(-) create mode 100644 apps/login/src/components/login-passkey.test.tsx create mode 100644 apps/login/src/lib/server/passkeys.test.ts diff --git a/apps/login/locales/de.json b/apps/login/locales/de.json index edef348d0..ebb34b342 100644 --- a/apps/login/locales/de.json +++ b/apps/login/locales/de.json @@ -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" } } diff --git a/apps/login/locales/en.json b/apps/login/locales/en.json index 88a32244b..4f26be7d2 100644 --- a/apps/login/locales/en.json +++ b/apps/login/locales/en.json @@ -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" } } diff --git a/apps/login/locales/es.json b/apps/login/locales/es.json index b1593d1b0..af0d5967e 100644 --- a/apps/login/locales/es.json +++ b/apps/login/locales/es.json @@ -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" } } diff --git a/apps/login/locales/it.json b/apps/login/locales/it.json index 3e0ab4608..5e4c64a84 100644 --- a/apps/login/locales/it.json +++ b/apps/login/locales/it.json @@ -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" } } diff --git a/apps/login/locales/pl.json b/apps/login/locales/pl.json index 2f33f309f..6302f6b4f 100644 --- a/apps/login/locales/pl.json +++ b/apps/login/locales/pl.json @@ -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" } } diff --git a/apps/login/locales/ru.json b/apps/login/locales/ru.json index 377ed0204..e6111e952 100644 --- a/apps/login/locales/ru.json +++ b/apps/login/locales/ru.json @@ -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": "Не удалось продолжить сеанс - отсутствует информация о пользователе" } } diff --git a/apps/login/locales/zh.json b/apps/login/locales/zh.json index 842b38e84..09d9fb5e4 100644 --- a/apps/login/locales/zh.json +++ b/apps/login/locales/zh.json @@ -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": "无法继续会话 - 缺少用户信息" } } diff --git a/apps/login/src/app/(login)/passkey/page.tsx b/apps/login/src/app/(login)/passkey/page.tsx index ab1c9d76f..214c4eb9e 100644 --- a/apps/login/src/app/(login)/passkey/page.tsx +++ b/apps/login/src/app/(login)/passkey/page.tsx @@ -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
- {!(loginName || sessionId) && ( + + + + + + + + + + {!(loginName || sessionId) ? ( - )} -
- -
- {(loginName || sessionId) && ( + ) : ( { export default async function Page(props: { searchParams: Promise> }) { 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 -
+

@@ -114,7 +114,7 @@ export default async function Page(props: { searchParams: Promise )}
diff --git a/apps/login/src/components/login-passkey.test.tsx b/apps/login/src/components/login-passkey.test.tsx new file mode 100644 index 000000000..a1e8c96bf --- /dev/null +++ b/apps/login/src/components/login-passkey.test.tsx @@ -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( + + {component} + , + ); + }; + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + await waitFor(() => { + expect(mockUpdateSession).toHaveBeenCalledTimes(1); + }); + + // Rerender the component + rerender( + + + , + ); + + // Should still only be called once + expect(mockUpdateSession).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/apps/login/src/components/login-passkey.tsx b/apps/login/src/components/login-passkey.tsx index 968f861ff..5a4234e99 100644 --- a/apps/login/src/components/login-passkey.tsx +++ b/apps/login/src/components/login-passkey.tsx @@ -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(""); const [loading, setLoading] = useState(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 { - publicKey.challenge = coerceToArrayBuffer( - publicKey.challenge, - "publicKey.challenge", - ); + async function submitLoginAndContinue(publicKey: any): Promise { + 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 && }{" "} - + {loading && }
diff --git a/apps/login/src/lib/server/passkeys.test.ts b/apps/login/src/lib/server/passkeys.test.ts new file mode 100644 index 000000000..b22d20479 --- /dev/null +++ b/apps/login/src/lib/server/passkeys.test.ts @@ -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), + }), + }), + ); + }); + }); +}); diff --git a/apps/login/src/lib/server/passkeys.ts b/apps/login/src/lib/server/passkeys.ts index 753f296d7..b6fc46c7a 100644 --- a/apps/login/src/lib/server/passkeys.ts +++ b/apps/login/src/lib/server/passkeys.ts @@ -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") }; } diff --git a/apps/login/src/lib/server/session.ts b/apps/login/src/lib/server/session.ts index 18ebe10e1..43839cf78 100644 --- a/apps/login/src/lib/server/session.ts +++ b/apps/login/src/lib/server/session.ts @@ -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 = {