From 250b1ca382552f884f10ce2929f2c0c2a3ef4c13 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 2 Jun 2026 18:01:04 +0200 Subject: [PATCH] refactor(passkey): show proper login error --- .../components/passkey-sign-in-button.tsx | 20 ++++++- .../auth/routes/__tests__/login.test.tsx | 58 ++++++++++++++++++- app/lib/sso-errors.ts | 6 ++ app/server/lib/auth.ts | 18 +++--- 4 files changed, 86 insertions(+), 16 deletions(-) diff --git a/app/client/modules/auth/components/passkey-sign-in-button.tsx b/app/client/modules/auth/components/passkey-sign-in-button.tsx index eda2abe6..7380ff39 100644 --- a/app/client/modules/auth/components/passkey-sign-in-button.tsx +++ b/app/client/modules/auth/components/passkey-sign-in-button.tsx @@ -1,6 +1,6 @@ import { useMutation } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { Fingerprint } from "lucide-react"; -import { toast } from "sonner"; import { Button } from "~/client/components/ui/button"; import { authClient } from "~/client/lib/auth-client"; import { logger } from "~/client/lib/logger"; @@ -10,6 +10,7 @@ type PasskeySignInButtonProps = { }; export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) { + const navigate = useNavigate(); const passkeyLoginMutation = useMutation({ mutationFn: async () => { const { error } = await authClient.signIn.passkey(); @@ -19,7 +20,22 @@ export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) { }, onError: (error) => { logger.error(error); - toast.error("Passkey login failed", { description: error.message }); + + let errorCode: string | undefined = undefined; + + if ("code" in error && typeof error.code === "string") { + errorCode = error.code; + if (error.code === "AUTHENTICATION_FAILED") { + errorCode = "PASSKEY_LOGIN_FAILED"; + } + } + + void navigate({ + to: "/login", + search: { + error: errorCode, + }, + }); }, }); diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index 597db17b..5d9f1932 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -1,9 +1,16 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { HttpResponse, http, server } from "~/test/msw/server"; -import { cleanup, render, screen } from "~/test/test-utils"; +import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils"; -const { mockGetLoginOptions } = vi.hoisted(() => ({ +const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({ mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })), + mockNavigate: vi.fn(async () => {}), + mockPasskeySignIn: vi.fn( + async (): Promise<{ + data: unknown; + error: { code: string; message: string } | null; + }> => ({ data: null, error: null }), + ), })); vi.mock("@tanstack/react-router", async (importOriginal) => { @@ -11,7 +18,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { return { ...actual, - useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate, + useNavigate: (() => mockNavigate) as typeof actual.useNavigate, }; }); @@ -28,6 +35,14 @@ vi.mock("~/server/lib/functions/login-options", () => ({ getLoginOptions: mockGetLoginOptions, })); +vi.mock("~/client/lib/auth-client", () => ({ + authClient: { + signIn: { + passkey: mockPasskeySignIn, + }, + }, +})); + import { LoginPage } from "../login"; const inviteOnlyMessage = "Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO."; @@ -48,6 +63,9 @@ const mockSsoProvidersRequest = ( afterEach(() => { mockGetLoginOptions.mockClear(); mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false }); + mockNavigate.mockClear(); + mockPasskeySignIn.mockClear(); + mockPasskeySignIn.mockResolvedValue({ data: null, error: null }); cleanup(); }); @@ -100,6 +118,18 @@ describe("LoginPage", () => { expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy(); }); + test("shows passkey login failure message when passkey returns the login error code", async () => { + mockSsoProvidersRequest(); + + render(, { withSuspense: true }); + + expect( + await screen.findByText( + "Passkey sign-in failed. If 2FA is enabled, use a passkey protected by a PIN, biometrics, or screen lock, or sign in with your password and authenticator code.", + ), + ).toBeTruthy(); + }); + test("does not show error message for invalid error codes", async () => { mockSsoProvidersRequest(); @@ -126,6 +156,28 @@ describe("LoginPage", () => { expect(await screen.findByRole("button", { name: "Sign in with passkey" })).toBeTruthy(); }); + test("redirects passkey verification failures to the login error box", async () => { + mockSsoProvidersRequest(); + mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true }); + mockPasskeySignIn.mockResolvedValue({ + data: null, + error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" }, + }); + + render(, { withSuspense: true }); + + await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" })); + + await waitFor(() => { + expect(mockNavigate).toHaveBeenCalledWith({ + to: "/login", + search: { + error: "PASSKEY_LOGIN_FAILED", + }, + }); + }); + }); + test("hides alternative sign-in when no SSO providers or passkeys are available", async () => { mockSsoProvidersRequest(); diff --git a/app/lib/sso-errors.ts b/app/lib/sso-errors.ts index df3f8a48..ad7bfb84 100644 --- a/app/lib/sso-errors.ts +++ b/app/lib/sso-errors.ts @@ -4,6 +4,8 @@ export const LOGIN_ERROR_CODES = [ "INVITE_REQUIRED", "BANNED_USER", "SSO_LOGIN_FAILED", + "PASSKEY_LOGIN_FAILED", + "ERROR_INVALID_RP_ID", ] as const; export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number]; @@ -20,5 +22,9 @@ export function getLoginErrorDescription(errorCode: LoginErrorCode): string { return "You have been banned from this application. Please contact support if you believe this is an error."; case "SSO_LOGIN_FAILED": return "SSO authentication failed. Please try again."; + case "PASSKEY_LOGIN_FAILED": + return "Passkey sign-in failed. If 2FA is enabled, use a passkey protected by a PIN, biometrics, or screen lock, or sign in with your password and authenticator code."; + case "ERROR_INVALID_RP_ID": + return "You can only sign in with a passkey on the domain set by the BASE_URL environment variable"; } } diff --git a/app/server/lib/auth.ts b/app/server/lib/auth.ts index e01c8eac..48cbca27 100644 --- a/app/server/lib/auth.ts +++ b/app/server/lib/auth.ts @@ -10,10 +10,8 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins"; import { passkey } from "@better-auth/passkey"; import { createAuthMiddleware } from "better-auth/api"; -import { eq } from "drizzle-orm"; import { config } from "../core/config"; import { db } from "../db/db"; -import { passkey as passkeyTable, usersTable } from "../db/schema"; import { cryptoUtils } from "../utils/crypto"; import { authService } from "../modules/auth/auth.service"; import { tanstackStartCookies } from "better-auth/tanstack-start"; @@ -174,7 +172,7 @@ export const auth = betterAuth({ }, }), passkey({ - rpID: "zerobyte.localhost", + rpID: new URL(config.baseUrl).hostname, rpName: "Zerobyte", authentication: { afterVerification: async ({ verification, clientData }) => { @@ -182,17 +180,15 @@ export const auth = betterAuth({ return; } - const [user] = await db - .select({ twoFactorEnabled: usersTable.twoFactorEnabled }) - .from(passkeyTable) - .innerJoin(usersTable, eq(passkeyTable.userId, usersTable.id)) - .where(eq(passkeyTable.credentialID, clientData.id)) - .limit(1); + const passkeyRecord = await db.query.passkey.findFirst({ + where: { credentialID: clientData.id }, + with: { usersTable: { columns: { twoFactorEnabled: true } } }, + }); - if (user?.twoFactorEnabled) { + if (passkeyRecord?.usersTable?.twoFactorEnabled) { throw new APIError("UNAUTHORIZED", { message: - "This passkey cannot securely validate your identiy and since you have 2FA enabled, Zerobyte cannot validate the login request", + "Your passkey was accepted, but it did not confirm your identity with a PIN, biometrics, or screen lock. Because 2FA is enabled on this account, please use a verified passkey or sign in with your password and authenticator code.", }); } },