refactor(passkey): show proper login error
This commit is contained in:
parent
f982855258
commit
250b1ca382
4 changed files with 86 additions and 16 deletions
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue