refactor: show passkey generic error on all failures

This commit is contained in:
Nicolas Meienberger 2026-06-02 18:43:47 +02:00
parent 250b1ca382
commit fe6f0c49d7
No known key found for this signature in database
5 changed files with 145 additions and 19 deletions

View file

@ -4,14 +4,32 @@ import { Fingerprint } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { LOGIN_ERROR_CODES, PASSKEY_LOGIN_FAILED_ERROR, type LoginErrorCode } from "~/lib/sso-errors";
type PasskeySignInButtonProps = {
onSignIn: () => Promise<void>;
};
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
const LOGIN_ERROR_CODE_SET = new Set<string>(LOGIN_ERROR_CODES);
function getPasskeyLoginErrorCode(code: string | undefined): LoginErrorCode {
if (code && LOGIN_ERROR_CODE_SET.has(code)) {
return code as LoginErrorCode;
}
return PASSKEY_LOGIN_FAILED_ERROR;
}
export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
const navigate = useNavigate();
const passkeyLoginMutation = useMutation({
const passkeyLoginMutation = useMutation<void, PasskeySignInError>({
mutationFn: async () => {
const { error } = await authClient.signIn.passkey();
if (error) throw error;
@ -21,19 +39,10 @@ export function PasskeySignInButton({ onSignIn }: PasskeySignInButtonProps) {
onError: (error) => {
logger.error(error);
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,
error: getPasskeyLoginErrorCode(error.code),
},
});
},

View file

@ -1,6 +1,7 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { HttpResponse, http, server } from "~/test/msw/server";
import { cleanup, render, screen, userEvent, waitFor } from "~/test/test-utils";
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
const { mockGetLoginOptions, mockNavigate, mockPasskeySignIn } = vi.hoisted(() => ({
mockGetLoginOptions: vi.fn(async () => ({ hasPasskeySignIn: false })),
@ -66,6 +67,7 @@ afterEach(() => {
mockNavigate.mockClear();
mockPasskeySignIn.mockClear();
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
vi.unstubAllGlobals();
cleanup();
});
@ -169,6 +171,75 @@ describe("LoginPage", () => {
await userEvent.click(await screen.findByRole("button", { name: "Sign in with passkey" }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
});
});
test("redirects unauthorized passkey failures to the login error box", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "UNAUTHORIZED", message: "Unauthorized" },
});
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_ERROR,
},
});
});
});
test("preserves specific passkey login error codes", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "ERROR_INVALID_RP_ID", message: "Auth cancelled" },
});
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: "ERROR_INVALID_RP_ID",
},
});
});
});
test("redirects conditional passkey autofill failures to the login error box", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTHENTICATION_FAILED", message: "Authentication failed" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
expect(mockNavigate).toHaveBeenCalledWith({
to: "/login",
search: {
@ -178,6 +249,26 @@ describe("LoginPage", () => {
});
});
test("ignores conditional passkey autofill cancellation", async () => {
mockSsoProvidersRequest();
mockPasskeySignIn.mockResolvedValue({
data: null,
error: { code: "AUTH_CANCELLED", message: "Authentication cancelled" },
});
vi.stubGlobal("PublicKeyCredential", {
isConditionalMediationAvailable: vi.fn(async () => true),
});
render(<LoginPage />, { withSuspense: true });
await waitFor(() => {
expect(mockPasskeySignIn).toHaveBeenCalledWith({
autoFill: true,
});
});
expect(mockNavigate).not.toHaveBeenCalled();
});
test("hides alternative sign-in when no SSO providers or passkeys are available", async () => {
mockSsoProvidersRequest();

View file

@ -12,6 +12,7 @@ import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { RECOVERY_KEY_DOWNLOAD_SKIPPED_COOKIE_NAME } from "~/lib/recovery-key-skip";
import { decodeLoginError, getLoginErrorDescription } from "~/client/lib/sso-errors";
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
import { ResetPasswordDialog } from "../components/reset-password-dialog";
import { useNavigate } from "@tanstack/react-router";
import { normalizeUsername } from "~/lib/username";
@ -33,6 +34,17 @@ type LoginPageProps = {
error?: string;
};
type PasskeySignInError = {
code?: string;
message?: string;
status?: number;
statusText?: string;
};
function isPasskeyVerificationFailure(error: PasskeySignInError | null) {
return error?.code === "AUTHENTICATION_FAILED" || error?.code === "UNAUTHORIZED";
}
function hasSkippedRecoveryKeyDownload(userId: string) {
return document.cookie
.split(";")
@ -74,16 +86,27 @@ export function LoginPage({ error }: LoginPageProps = {}) {
return;
}
await authClient.signIn.passkey({
const { data, error } = await authClient.signIn.passkey({
autoFill: true,
fetchOptions: {
onResponse: navigateAfterLogin,
},
});
if (isPasskeyVerificationFailure(error)) {
void navigate({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
return;
}
if (data) {
await navigateAfterLogin();
}
};
void autoSignIn();
}, [navigateAfterLogin]);
}, [navigate, navigateAfterLogin]);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),

View file

@ -1,10 +1,12 @@
export const PASSKEY_LOGIN_FAILED_ERROR = "PASSKEY_LOGIN_FAILED";
export const LOGIN_ERROR_CODES = [
"ACCOUNT_LINK_REQUIRED",
"EMAIL_NOT_VERIFIED",
"INVITE_REQUIRED",
"BANNED_USER",
"SSO_LOGIN_FAILED",
"PASSKEY_LOGIN_FAILED",
PASSKEY_LOGIN_FAILED_ERROR,
"ERROR_INVALID_RP_ID",
] as const;
@ -22,7 +24,7 @@ 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":
case PASSKEY_LOGIN_FAILED_ERROR:
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

@ -6,6 +6,7 @@ import viteReact, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";
export default defineConfig({
clearScreen: false,
plugins: [
tanstackStart({
srcDirectory: "app",
@ -30,7 +31,7 @@ export default defineConfig({
server: {
host: "0.0.0.0",
port: 3000,
allowedHosts: [".ts.net"]
allowedHosts: [".ts.net"],
},
fmt: {
printWidth: 120,