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 { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { Fingerprint } from "lucide-react";
|
import { Fingerprint } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { Button } from "~/client/components/ui/button";
|
import { Button } from "~/client/components/ui/button";
|
||||||
import { authClient } from "~/client/lib/auth-client";
|
import { authClient } from "~/client/lib/auth-client";
|
||||||
import { logger } from "~/client/lib/logger";
|
import { logger } from "~/client/lib/logger";
|
||||||
|
|
@ -10,6 +10,7 @@ type PasskeySignInButtonProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
|
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
const passkeyLoginMutation = useMutation({
|
const passkeyLoginMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const { error } = await authClient.signIn.passkey();
|
const { error } = await authClient.signIn.passkey();
|
||||||
|
|
@ -19,7 +20,22 @@ export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
logger.error(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 { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
import { HttpResponse, http, server } from "~/test/msw/server";
|
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 })),
|
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) => {
|
vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||||
|
|
@ -11,7 +18,7 @@ vi.mock("@tanstack/react-router", async (importOriginal) => {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...actual,
|
...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,
|
getLoginOptions: mockGetLoginOptions,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/client/lib/auth-client", () => ({
|
||||||
|
authClient: {
|
||||||
|
signIn: {
|
||||||
|
passkey: mockPasskeySignIn,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
import { LoginPage } from "../login";
|
import { LoginPage } from "../login";
|
||||||
const inviteOnlyMessage =
|
const inviteOnlyMessage =
|
||||||
"Access is invite-only. Ask an organization admin to send you an invitation before signing in with SSO.";
|
"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(() => {
|
afterEach(() => {
|
||||||
mockGetLoginOptions.mockClear();
|
mockGetLoginOptions.mockClear();
|
||||||
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
|
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
|
||||||
|
mockNavigate.mockClear();
|
||||||
|
mockPasskeySignIn.mockClear();
|
||||||
|
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
|
||||||
cleanup();
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -100,6 +118,18 @@ describe("LoginPage", () => {
|
||||||
expect(await screen.findByText("SSO authentication failed. Please try again.")).toBeTruthy();
|
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 () => {
|
test("does not show error message for invalid error codes", async () => {
|
||||||
mockSsoProvidersRequest();
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
|
@ -126,6 +156,28 @@ describe("LoginPage", () => {
|
||||||
expect(await screen.findByRole("button", { name: "Sign in with passkey" })).toBeTruthy();
|
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 () => {
|
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
|
||||||
mockSsoProvidersRequest();
|
mockSsoProvidersRequest();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ export const LOGIN_ERROR_CODES = [
|
||||||
"INVITE_REQUIRED",
|
"INVITE_REQUIRED",
|
||||||
"BANNED_USER",
|
"BANNED_USER",
|
||||||
"SSO_LOGIN_FAILED",
|
"SSO_LOGIN_FAILED",
|
||||||
|
"PASSKEY_LOGIN_FAILED",
|
||||||
|
"ERROR_INVALID_RP_ID",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
|
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.";
|
return "You have been banned from this application. Please contact support if you believe this is an error.";
|
||||||
case "SSO_LOGIN_FAILED":
|
case "SSO_LOGIN_FAILED":
|
||||||
return "SSO authentication failed. Please try again.";
|
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 { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
|
||||||
import { passkey } from "@better-auth/passkey";
|
import { passkey } from "@better-auth/passkey";
|
||||||
import { createAuthMiddleware } from "better-auth/api";
|
import { createAuthMiddleware } from "better-auth/api";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import { config } from "../core/config";
|
import { config } from "../core/config";
|
||||||
import { db } from "../db/db";
|
import { db } from "../db/db";
|
||||||
import { passkey as passkeyTable, usersTable } from "../db/schema";
|
|
||||||
import { cryptoUtils } from "../utils/crypto";
|
import { cryptoUtils } from "../utils/crypto";
|
||||||
import { authService } from "../modules/auth/auth.service";
|
import { authService } from "../modules/auth/auth.service";
|
||||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||||
|
|
@ -174,7 +172,7 @@ export const auth = betterAuth({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
passkey({
|
passkey({
|
||||||
rpID: "zerobyte.localhost",
|
rpID: new URL(config.baseUrl).hostname,
|
||||||
rpName: "Zerobyte",
|
rpName: "Zerobyte",
|
||||||
authentication: {
|
authentication: {
|
||||||
afterVerification: async ({ verification, clientData }) => {
|
afterVerification: async ({ verification, clientData }) => {
|
||||||
|
|
@ -182,17 +180,15 @@ export const auth = betterAuth({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [user] = await db
|
const passkeyRecord = await db.query.passkey.findFirst({
|
||||||
.select({ twoFactorEnabled: usersTable.twoFactorEnabled })
|
where: { credentialID: clientData.id },
|
||||||
.from(passkeyTable)
|
with: { usersTable: { columns: { twoFactorEnabled: true } } },
|
||||||
.innerJoin(usersTable, eq(passkeyTable.userId, usersTable.id))
|
});
|
||||||
.where(eq(passkeyTable.credentialID, clientData.id))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (user?.twoFactorEnabled) {
|
if (passkeyRecord?.usersTable?.twoFactorEnabled) {
|
||||||
throw new APIError("UNAUTHORIZED", {
|
throw new APIError("UNAUTHORIZED", {
|
||||||
message:
|
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