mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-06-02 21:16:14 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
54
app/client/modules/sso/components/sso-login-buttons.tsx
Normal file
54
app/client/modules/sso/components/sso-login-buttons.tsx
Normal 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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()] : []),
|
||||
|
||||
6
app/server/lib/functions/login-options.ts
Normal file
6
app/server/lib/functions/login-options.ts
Normal 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(),
|
||||
}));
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user