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 7380ff39..b5ce7117 100644 --- a/app/client/modules/auth/components/passkey-sign-in-button.tsx +++ b/app/client/modules/auth/components/passkey-sign-in-button.tsx @@ -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; }; +type PasskeySignInError = { + code?: string; + message?: string; + status?: number; + statusText?: string; +}; + +const LOGIN_ERROR_CODE_SET = new Set(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({ 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), }, }); }, diff --git a/app/client/modules/auth/routes/__tests__/login.test.tsx b/app/client/modules/auth/routes/__tests__/login.test.tsx index 5d9f1932..4c429d34 100644 --- a/app/client/modules/auth/routes/__tests__/login.test.tsx +++ b/app/client/modules/auth/routes/__tests__/login.test.tsx @@ -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(, { 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(, { 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(, { 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(, { 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(); diff --git a/app/client/modules/auth/routes/login.tsx b/app/client/modules/auth/routes/login.tsx index ae5f64cf..2b18ef2e 100644 --- a/app/client/modules/auth/routes/login.tsx +++ b/app/client/modules/auth/routes/login.tsx @@ -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({ resolver: zodResolver(loginSchema), diff --git a/app/lib/sso-errors.ts b/app/lib/sso-errors.ts index ad7bfb84..8c0f83eb 100644 --- a/app/lib/sso-errors.ts +++ b/app/lib/sso-errors.ts @@ -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"; diff --git a/vite.config.ts b/vite.config.ts index dbe0572d..c8a33187 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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,