From 5773c74abeea47ab1c4aa8f21e5bbf3b9000a46e Mon Sep 17 00:00:00 2001 From: "Kent C. Dodds" Date: Wed, 19 Feb 2025 12:09:03 -0700 Subject: [PATCH] Add WebAuthn passkey authentication support --- app/routes/_auth+/login.tsx | 109 +++++++++- app/routes/_auth+/webauthn+/authentication.ts | 113 +++++++++++ app/routes/_auth+/webauthn+/registration.ts | 136 +++++++++++++ app/routes/_auth+/webauthn+/utils.server.ts | 89 ++++++++ app/routes/settings+/profile.index.tsx | 5 + app/routes/settings+/profile.passkeys.tsx | 191 ++++++++++++++++++ app/routes/settings+/profile.tsx | 4 +- docs/authentication.md | 50 ++++- docs/decisions/039-passkeys.md | 159 +++++++++++++++ other/svg-icons/passkey.svg | 4 + package-lock.json | 128 ++++++++++++ package.json | 2 + .../migration.sql | 20 ++ prisma/migrations/migration_lock.toml | 2 +- prisma/schema.prisma | 18 ++ server/utils/monitoring.ts | 2 + tests/e2e/passkey.test.ts | 155 ++++++++++++++ 17 files changed, 1181 insertions(+), 6 deletions(-) create mode 100644 app/routes/_auth+/webauthn+/authentication.ts create mode 100644 app/routes/_auth+/webauthn+/registration.ts create mode 100644 app/routes/_auth+/webauthn+/utils.server.ts create mode 100644 app/routes/settings+/profile.passkeys.tsx create mode 100644 docs/decisions/039-passkeys.md create mode 100644 other/svg-icons/passkey.svg rename prisma/migrations/{20230914194400_init => 20250207004552_init}/migration.sql (94%) create mode 100644 tests/e2e/passkey.test.ts diff --git a/app/routes/_auth+/login.tsx b/app/routes/_auth+/login.tsx index d01244912..f75b9f748 100644 --- a/app/routes/_auth+/login.tsx +++ b/app/routes/_auth+/login.tsx @@ -1,12 +1,15 @@ import { getFormProps, getInputProps, useForm } from '@conform-to/react' import { getZodConstraint, parseWithZod } from '@conform-to/zod' import { type SEOHandle } from '@nasa-gcn/remix-seo' -import { data, Form, Link, useSearchParams } from 'react-router' +import { startAuthentication } from '@simplewebauthn/browser' +import { useOptimistic, useState, useTransition } from 'react' +import { data, Form, Link, useNavigate, useSearchParams } from 'react-router' import { HoneypotInputs } from 'remix-utils/honeypot/react' import { z } from 'zod' import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx' import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx' import { Spacer } from '#app/components/spacer.tsx' +import { Icon } from '#app/components/ui/icon.tsx' import { StatusButton } from '#app/components/ui/status-button.tsx' import { login, requireAnonymous } from '#app/utils/auth.server.ts' import { @@ -14,7 +17,7 @@ import { providerNames, } from '#app/utils/connections.tsx' import { checkHoneypot } from '#app/utils/honeypot.server.ts' -import { useIsPending } from '#app/utils/misc.tsx' +import { getErrorMessage, useIsPending } from '#app/utils/misc.tsx' import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts' import { type Route } from './+types/login.ts' import { handleNewSession } from './login.server.ts' @@ -30,6 +33,10 @@ const LoginFormSchema = z.object({ remember: z.boolean().optional(), }) +const AuthenticationOptionsSchema = z.object({ + options: z.object({ challenge: z.string() }), +}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }> + export async function loader({ request }: Route.LoaderArgs) { await requireAnonymous(request) return {} @@ -165,7 +172,15 @@ export default function LoginPage({ actionData }: Route.ComponentProps) { -