fix(login): Improve Passkey Authentication Error Handling & Testing (#10971)

# Which Problems Are Solved

This PR enhances the passkey authentication flow with comprehensive
error handling, full internationalization support, and extensive test
coverage.

# How the Problems Are Solved

I18n:
- Replaced all hard-coded error messages with i18n translation keys
- Consistent error messaging throughout the passkey flow
- Added specific error handling for passkey cancellation
(NotAllowedError)
- Implemented fallback errors for undefined/missing responses
- Better error messages for:
  - Session retrieval failures
  - Challenge request failures
  - User verification errors
  - Redirect determination issues

Tests:
- Added `login-passkey.test.tsx` with 100+ test cases covering:
  - Successful verification flows
  - Error scenarios and edge cases
  - Props handling
  - Component lifecycle
- Added passkeys.test.ts with server-side function tests:
  - Session cookie retrieval
  - User validation
  - Custom lifetime handling
  - Critical fallback error paths
  
Try-catch blocks around critical user retrieval operations
Defensive checks for undefined responses from completeFlowOrGetUrl
Support for custom lifetime parameters
Cleaner error propagation
This commit is contained in:
Max Peintner
2025-10-27 15:38:04 +01:00
committed by GitHub
parent c4221a9c0f
commit d5d68aed4b
14 changed files with 1112 additions and 83 deletions

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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": "Не удалось продолжить сеанс - отсутствует информация о пользователе"
}
}

View File

@@ -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": "无法继续会话 - 缺少用户信息"
}
}

View File

@@ -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}

View File

@@ -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>

View 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);
});
});
});

View File

@@ -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>

View 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),
}),
}),
);
});
});
});

View File

@@ -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") };
}

View File

@@ -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 = {