refactor(passkey): show proper login error

This commit is contained in:
Nicolas Meienberger 2026-06-02 18:01:04 +02:00
parent f982855258
commit 250b1ca382
No known key found for this signature in database
4 changed files with 86 additions and 16 deletions

View file

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

View file

@ -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(<LoginPage error={"PASSKEY_LOGIN_FAILED"} />, { 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(<LoginPage />, { 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();

View file

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

View file

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