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