fix: block login for 2fa users with un-verified passkeys (#934)

* fix: block login for 2fa users with un-verified passkeys

* refactor(passkey): show proper login error

* refactor: show passkey generic error on all failures
This commit is contained in:
Nico
2026-06-02 19:48:40 +02:00
committed by GitHub
parent ce23bded90
commit a488bbc754
12 changed files with 479 additions and 90 deletions

View File

@@ -0,0 +1,35 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { useServerFn } from "@tanstack/react-start";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
import { SsoLoginButtons } from "~/client/modules/sso/components/sso-login-buttons";
import { getLoginOptions } from "~/server/lib/functions/login-options";
import { PasskeySignInButton } from "./passkey-sign-in-button";
type AlternativeSignInSectionProps = {
onPasskeySignIn: () => Promise<void>;
};
export function AlternativeSignInSection({ onPasskeySignIn }: AlternativeSignInSectionProps) {
const getOptions = useServerFn(getLoginOptions);
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const { data: loginOptions } = useSuspenseQuery({
queryKey: ["login-options"],
queryFn: getOptions,
});
if (ssoProviders.providers.length === 0 && !loginOptions.hasPasskeySignIn) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{loginOptions.hasPasskeySignIn && <PasskeySignInButton onSignIn={onPasskeySignIn} />}
<SsoLoginButtons providers={ssoProviders.providers} />
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
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<void, PasskeySignInError>({
mutationFn: async () => {
const { error } = await authClient.signIn.passkey();
if (error) throw error;
await onSignIn();
},
onError: (error) => {
logger.error(error);
void navigate({
to: "/login",
search: {
error: getPasskeyLoginErrorCode(error.code),
},
});
},
});
return (
<Button
type="button"
variant="outline"
className="w-full"
loading={passkeyLoginMutation.isPending}
disabled={passkeyLoginMutation.isPending}
onClick={() => passkeyLoginMutation.mutate()}
>
<Fingerprint className="h-4 w-4 mr-2" />
Sign in with passkey
</Button>
);
}

View File

@@ -1,16 +1,49 @@
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";
import { PASSKEY_LOGIN_FAILED_ERROR } from "~/lib/sso-errors";
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) => {
const actual = await importOriginal<typeof import("@tanstack/react-router")>();
return {
...actual,
useNavigate: (() => vi.fn(async () => {})) as typeof actual.useNavigate,
useNavigate: (() => mockNavigate) as typeof actual.useNavigate,
};
});
vi.mock("@tanstack/react-start", async (importOriginal) => {
const actual = await importOriginal<typeof import("@tanstack/react-start")>();
return {
...actual,
useServerFn: (fn: unknown) => fn,
};
});
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.";
@@ -29,6 +62,12 @@ const mockSsoProvidersRequest = (
};
afterEach(() => {
mockGetLoginOptions.mockClear();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: false });
mockNavigate.mockClear();
mockPasskeySignIn.mockClear();
mockPasskeySignIn.mockResolvedValue({ data: null, error: null });
vi.unstubAllGlobals();
cleanup();
});
@@ -81,6 +120,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();
@@ -90,11 +141,141 @@ describe("LoginPage", () => {
expect(screen.queryByText(inviteOnlyMessage)).toBeNull();
});
test("renders available SSO providers from the real SSO section", async () => {
test("renders available SSO providers from the alternative sign-in section", async () => {
mockSsoProvidersRequest([{ providerId: "acme", organizationSlug: "acme-org" }]);
render(<LoginPage />, { withSuspense: true });
expect(await screen.findByRole("button", { name: "Log in with acme" })).toBeTruthy();
});
test("renders passkey sign-in when an active user has a passkey", async () => {
mockSsoProvidersRequest();
mockGetLoginOptions.mockResolvedValue({ hasPasskeySignIn: true });
render(<LoginPage />, { withSuspense: true });
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_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: {
error: "PASSKEY_LOGIN_FAILED",
},
});
});
});
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();
render(<LoginPage />, { withSuspense: true });
await screen.findByText("Login to your account");
expect(screen.queryByText("Alternative Sign-in")).toBeNull();
expect(screen.queryByRole("button", { name: "Sign in with passkey" })).toBeNull();
});
});

View File

@@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout";
@@ -12,11 +12,12 @@ 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";
import { cn } from "~/client/lib/utils";
import { SsoLoginSection } from "~/client/modules/sso/components/sso-login-section";
import { AlternativeSignInSection } from "../components/alternative-sign-in-section";
import { z } from "zod";
const loginSchema = z.object({
@@ -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(";")
@@ -50,6 +62,20 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorCode = decodeLoginError(error);
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const navigateAfterLogin = useCallback(async () => {
const session = await authClient.getSession();
if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
}, [navigate]);
useEffect(() => {
const autoSignIn = async () => {
if (
@@ -60,28 +86,27 @@ export function LoginPage({ error }: LoginPageProps = {}) {
return;
}
await authClient.signIn.passkey({
const { data, error } = await authClient.signIn.passkey({
autoFill: true,
fetchOptions: {
onResponse: async () => {
const session = await authClient.getSession();
if (
session.data?.user &&
!session.data.user.hasDownloadedResticPassword &&
!hasSkippedRecoveryKeyDownload(session.data.user.id)
) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
},
},
});
if (isPasskeyVerificationFailure(error)) {
void navigate({
to: "/login",
search: {
error: PASSKEY_LOGIN_FAILED_ERROR,
},
});
return;
}
if (data) {
await navigateAfterLogin();
}
};
void autoSignIn();
}, [navigate]);
}, [navigate, navigateAfterLogin]);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
@@ -116,12 +141,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
return;
}
const d = await authClient.getSession();
if (data.user && !d.data?.user.hasDownloadedResticPassword && !hasSkippedRecoveryKeyDownload(data.user.id)) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
await navigateAfterLogin();
};
const handleVerify2FA = async () => {
@@ -305,7 +325,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</form>
</Form>
<SsoLoginSection />
<AlternativeSignInSection onPasskeySignIn={navigateAfterLogin} />
<ResetPasswordDialog open={showResetDialog} onOpenChange={setShowResetDialog} />
</AuthLayout>

View File

@@ -0,0 +1,54 @@
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
type SsoProvider = {
providerId: string;
};
type SsoLoginButtonsProps = {
providers: SsoProvider[];
};
export function SsoLoginButtons({ providers }: SsoLoginButtonsProps) {
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
return (
<>
{providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</>
);
}

View File

@@ -1,58 +0,0 @@
import { useMutation, useSuspenseQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { getPublicSsoProvidersOptions } from "~/client/api-client/@tanstack/react-query.gen";
export function SsoLoginSection() {
const { data: ssoProviders } = useSuspenseQuery({
...getPublicSsoProvidersOptions(),
});
const ssoLoginMutation = useMutation({
mutationFn: async (providerId: string) => {
const callbackPath = "/login";
const { data, error } = await authClient.signIn.sso({
providerId: providerId,
callbackURL: callbackPath,
errorCallbackURL: "/api/v1/auth/login-error",
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
window.location.href = data.url;
},
onError: (error) => {
logger.error(error);
toast.error("SSO Login failed", { description: error.message });
},
});
if (ssoProviders.providers.length === 0) {
return null;
}
return (
<div className="pt-4 border-t border-border/60 space-y-3">
<p className="text-sm font-medium">Alternative Sign-in</p>
<div className="flex flex-col gap-2">
{ssoProviders.providers.map((provider) => (
<Button
key={provider.providerId}
type="button"
variant="outline"
className="w-full"
loading={ssoLoginMutation.isPending}
disabled={ssoLoginMutation.isPending}
onClick={() => ssoLoginMutation.mutate(provider.providerId)}
>
Log in with {provider.providerId}
</Button>
))}
</div>
</div>
);
}

View File

@@ -1,9 +1,13 @@
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_ERROR,
"ERROR_INVALID_RP_ID",
] as const;
export type LoginErrorCode = (typeof LOGIN_ERROR_CODES)[number];
@@ -20,5 +24,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_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

@@ -174,6 +174,25 @@ export const auth = betterAuth({
passkey({
rpID: new URL(config.baseUrl).hostname,
rpName: "Zerobyte",
authentication: {
afterVerification: async ({ verification, clientData }) => {
if (verification.authenticationInfo.userVerified) {
return;
}
const passkeyRecord = await db.query.passkey.findFirst({
where: { credentialID: clientData.id },
with: { usersTable: { columns: { twoFactorEnabled: true } } },
});
if (passkeyRecord?.usersTable?.twoFactorEnabled) {
throw new APIError("UNAUTHORIZED", {
message:
"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.",
});
}
},
},
}),
tanstackStartCookies(),
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),

View File

@@ -0,0 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import { hasActivePasskeyUser } from "~/server/modules/auth/helpers";
export const getLoginOptions = createServerFn({ method: "GET" }).handler(async () => ({
hasPasskeySignIn: await hasActivePasskeyUser(),
}));

View File

@@ -1,8 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { eq } from "drizzle-orm";
import { db } from "~/server/db/db";
import { account, usersTable } from "~/server/db/schema";
import { account, passkey, usersTable } from "~/server/db/schema";
import { createUser, randomId, randomSlug } from "~/test/helpers/user-org";
import { userHasCredentialPassword, verifyUserPassword } from "../helpers";
import { hasActivePasskeyUser, userHasCredentialPassword, verifyUserPassword } from "../helpers";
const { verifyPassword } = vi.hoisted(() => ({
verifyPassword: vi.fn(async ({ hash }: { hash: string }) => hash === "credential-password-hash"),
@@ -30,9 +31,24 @@ async function createAccount({
});
}
async function createPasskey(userId: string) {
await db.insert(passkey).values({
id: randomId(),
name: "Test passkey",
publicKey: randomSlug("public-key"),
userId,
credentialID: randomSlug("credential"),
counter: 0,
deviceType: "singleDevice",
backedUp: false,
transports: "internal",
});
}
describe("verifyUserPassword", () => {
beforeEach(async () => {
verifyPassword.mockClear();
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
@@ -64,6 +80,7 @@ describe("verifyUserPassword", () => {
describe("userHasCredentialPassword", () => {
beforeEach(async () => {
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
@@ -89,3 +106,32 @@ describe("userHasCredentialPassword", () => {
await expect(userHasCredentialPassword(userId)).resolves.toBe(false);
});
});
describe("hasActivePasskeyUser", () => {
beforeEach(async () => {
await db.delete(passkey);
await db.delete(account);
await db.delete(usersTable);
});
test("returns true when a non-banned user has a passkey", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await createPasskey(userId);
await expect(hasActivePasskeyUser()).resolves.toBe(true);
});
test("returns false when only banned users have passkeys", async () => {
const userId = await createUser(`${randomSlug("user")}@example.com`);
await db.update(usersTable).set({ banned: true }).where(eq(usersTable.id, userId));
await createPasskey(userId);
await expect(hasActivePasskeyUser()).resolves.toBe(false);
});
test("returns false when no users have passkeys", async () => {
await createUser(`${randomSlug("user")}@example.com`);
await expect(hasActivePasskeyUser()).resolves.toBe(false);
});
});

View File

@@ -1,5 +1,7 @@
import { eq } from "drizzle-orm";
import { verifyPassword } from "better-auth/crypto";
import { db } from "~/server/db/db";
import { passkey, usersTable } from "~/server/db/schema";
type PasswordVerificationBody = {
userId: string;
@@ -31,3 +33,14 @@ export const userHasCredentialPassword = async (userId: string) => {
return Boolean(userAccount?.password);
};
export const hasActivePasskeyUser = async () => {
const [user] = await db
.select({ id: usersTable.id })
.from(passkey)
.innerJoin(usersTable, eq(passkey.userId, usersTable.id))
.where(eq(usersTable.banned, false))
.limit(1);
return Boolean(user);
};

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,