Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
2d49fb5
feat: add passkey autofill welcome back
cursoragent Feb 23, 2026
9c6d875
fix: require discoverable passkeys for autofill
cursoragent Feb 23, 2026
2fd8fa0
test(e2e): cover passkey form autofill login
cursoragent Feb 23, 2026
a72c83d
test(e2e): make passkey autofill assertion strict
cursoragent Feb 23, 2026
d68f8da
test(e2e): validate passkey login + autofill markup
cursoragent Feb 23, 2026
928aead
test(e2e): target login email field id
cursoragent Feb 23, 2026
27977ce
test(e2e): assert WebAuthn virtual authenticator stores credential
cursoragent Feb 23, 2026
f40682d
test(e2e): assert residentKey required in registration options
cursoragent Feb 23, 2026
8765eb2
test(e2e): force resident credential in CDP authenticator
cursoragent Feb 23, 2026
c0c2dd9
test(e2e): gate verify request to assert login markup
cursoragent Feb 23, 2026
4b2e8b1
test(e2e): fix deferred verify gate typing
cursoragent Feb 23, 2026
179d9b5
test(e2e): add optional demo pauses
cursoragent Feb 23, 2026
be4281e
test(e2e): optionally record video for walkthrough
cursoragent Feb 23, 2026
ace7bcf
Fix passkey autofill error reset
cursoragent Feb 23, 2026
2df57d5
fix: recover from passkey autofill errors
cursoragent Feb 23, 2026
de676e0
Restart passkey autofill after manual attempt
cursoragent Feb 23, 2026
b30f9ad
Guard passkey autofill effect on unmount
cursoragent Feb 23, 2026
6c3f035
fix: harden passkey login responses and cancel UX
cursoragent Feb 23, 2026
ee23e5a
fix: prevent passkey autofill/manual ceremony races
cursoragent Feb 23, 2026
b5f611a
refactor passkey verification helper
cursoragent Feb 24, 2026
b3d9e92
docs: note e2e db setup
cursoragent Feb 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 156 additions & 21 deletions app/routes/login.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { invariantResponse } from '@epic-web/invariant'
import { startAuthentication } from '@simplewebauthn/browser'
import {
WebAuthnAbortService,
browserSupportsWebAuthnAutofill,
startAuthentication,
} from '@simplewebauthn/browser'
import { type PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server'
import clsx from 'clsx'
import { AnimatePresence, motion } from 'framer-motion'
Expand Down Expand Up @@ -176,9 +180,54 @@ export async function action({ request }: Route.ActionArgs) {
}

const AuthenticationOptionsSchema = z.object({
options: z.object({ challenge: z.string() }),
// Preserve all server-sent fields (rpId, userVerification, timeout, etc.).
options: z.object({ challenge: z.string() }).passthrough(),
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>

type PasskeyVerificationJson =
| { status: 'success' }
| { status: 'error'; error: string }

async function verifyPasskeyWithServer(
authResponse: unknown,
{
shouldAbort,
}: {
shouldAbort?: () => boolean
} = {},
) {
const verificationResponse = await fetch(
'/resources/webauthn/verify-authentication',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResponse),
},
)

// Used by the autofill effect to bail out silently on unmount/cancel without
// parsing/throwing.
if (shouldAbort?.()) return { type: 'aborted' as const }

const verificationJson = (await verificationResponse.json().catch(() => {
return null
})) as PasskeyVerificationJson | null

// Keep validation order stable to avoid behavior drift:
// 1) JSON parse success 2) server "error" status 3) HTTP ok.
if (!verificationJson) {
throw new Error('Failed to verify passkey')
}
if (verificationJson.status === 'error') {
throw new Error(verificationJson.error)
}
if (!verificationResponse.ok) {
throw new Error('Failed to verify passkey')
}

return { type: 'success' as const }
}

function Login({ loaderData: data }: Route.ComponentProps) {
const inputRef = React.useRef<HTMLInputElement>(null)
const passwordRef = React.useRef<HTMLInputElement>(null)
Expand All @@ -188,56 +237,136 @@ function Login({ loaderData: data }: Route.ComponentProps) {
const [passkeyMessage, setPasskeyMessage] = React.useState<null | string>(
null,
)
const [passkeyAutofillSupported, setPasskeyAutofillSupported] =
React.useState(false)
const [passkeyAutofillResetKey, setPasskeyAutofillResetKey] =
React.useState(0)
const autofillCancelledRef = React.useRef(false)

const [formValues, setFormValues] = React.useState({
email: data.email ?? '',
})

const formIsValid = formValues.email.match(/.+@.+/)

React.useEffect(() => {
let isMounted = true
autofillCancelledRef.current = false

async function setupPasskeyAutofill() {
try {
const supports = await browserSupportsWebAuthnAutofill()
if (!supports) return
if (!isMounted || autofillCancelledRef.current) return
setPasskeyAutofillSupported(true)

// Fetch a challenge on page load and keep the request pending until
// the user selects a passkey from the browser's autofill UI.
const optionsResponse = await fetch(
'/resources/webauthn/generate-authentication-options',
{ method: 'POST' },
)
if (!isMounted || autofillCancelledRef.current) return
if (!optionsResponse.ok) {
throw new Error('Failed to generate authentication options')
}
const json = await optionsResponse.json()
const { options } = AuthenticationOptionsSchema.parse(json)

if (!isMounted || autofillCancelledRef.current) return

const authResponse = await startAuthentication({
optionsJSON: options,
useBrowserAutofill: true,
})

if (!isMounted || autofillCancelledRef.current) return

setPasskeyMessage('Verifying your passkey')
const verificationResult = await verifyPasskeyWithServer(authResponse, {
shouldAbort: () => !isMounted || autofillCancelledRef.current,
})
if (verificationResult.type === 'aborted') return

setPasskeyMessage('Welcome back! Navigating to your account page.')
void revalidate()
void navigate('/me')
} catch (e) {
if (!isMounted) return

// Autofill flow should fail silently when the user cancels or chooses a
// password instead.
if (
e instanceof Error &&
(e.name === 'NotAllowedError' || e.name === 'AbortError')
) {
return
}

setPasskeyMessage(null)
console.error(e)
setError(
e instanceof Error ? e.message : 'Failed to authenticate with passkey',
)
}
}

void setupPasskeyAutofill()

return () => {
isMounted = false
autofillCancelledRef.current = true
WebAuthnAbortService.cancelCeremony()
}
}, [navigate, passkeyAutofillResetKey, revalidate])

async function handlePasskeyLogin() {
let didSucceed = false
try {
autofillCancelledRef.current = true
// Avoid collisions with a pending conditional UI ceremony.
WebAuthnAbortService.cancelCeremony()
setError(undefined)
setPasskeyMessage('Generating Authentication Options')
// Get authentication options from the server
const optionsResponse = await fetch(
'/resources/webauthn/generate-authentication-options',
{ method: 'POST' },
)
if (!optionsResponse.ok) {
throw new Error('Failed to generate authentication options')
}
const json = await optionsResponse.json()
const { options } = AuthenticationOptionsSchema.parse(json)

setPasskeyMessage('Requesting your authorization')
const authResponse = await startAuthentication({ optionsJSON: options })
setPasskeyMessage('Verifying your passkey')

// Verify the authentication with the server
const verificationResponse = await fetch(
'/resources/webauthn/verify-authentication',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(authResponse),
},
)
await verifyPasskeyWithServer(authResponse)

const verificationJson = (await verificationResponse.json()) as {
status: 'error'
error: string
}
if (verificationJson.status === 'error') {
throw new Error(verificationJson.error)
}

setPasskeyMessage("You're logged in! Navigating to your account page.")
setPasskeyMessage('Welcome back! Navigating to your account page.')
didSucceed = true

void revalidate()
void navigate('/me')
} catch (e) {
setPasskeyMessage(null)
if (
e instanceof Error &&
(e.name === 'NotAllowedError' || e.name === 'AbortError')
) {
// User dismissed the prompt or the ceremony was intentionally aborted.
return
}
console.error(e)
setError(
e instanceof Error ? e.message : 'Failed to authenticate with passkey',
)
} finally {
if (!didSucceed) {
setPasskeyAutofillResetKey((key) => key + 1)
}
}
}

Expand All @@ -259,6 +388,12 @@ function Login({ loaderData: data }: Route.ComponentProps) {
>
Login with Passkey <PasskeyIcon />
</Button>
{passkeyAutofillSupported ? (
<p className="text-secondary mt-2 text-sm">
Tip: You can also sign in with a passkey from the email field
autofill prompt.
</p>
) : null}
{error ? (
<div className="mt-2">
<InputError id="passkey-login-error">{error}</InputError>
Expand Down Expand Up @@ -308,7 +443,7 @@ function Login({ loaderData: data }: Route.ComponentProps) {
id="email-address"
name="email"
type="email"
autoComplete="email"
autoComplete="username webauthn"
defaultValue={formValues.email}
required
placeholder="Email address"
Expand Down
16 changes: 12 additions & 4 deletions app/routes/resources/webauthn/verify-authentication.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {
verifyAuthenticationResponse,
type AuthenticationResponseJSON,
verifyAuthenticationResponse,
} from '@simplewebauthn/server'
import { data as json } from 'react-router'
import { z } from 'zod'
import { getLoginInfoSession } from '#app/utils/login.server.ts'
import { prisma } from '#app/utils/prisma.server.ts'
import { getSession } from '#app/utils/session.server.ts'
import { getWebAuthnConfig, passkeyCookie } from '#app/utils/webauthn.server.ts'
Expand Down Expand Up @@ -80,9 +81,16 @@ export async function action({ request }: Route.ActionArgs) {
const session = await getSession(request)
await session.signIn(passkey.user)

return json({ status: 'success' } as const, {
headers: await session.getHeaders({ 'Set-Cookie': deletePasskeyCookie }),
})
const headers = new Headers({ 'Set-Cookie': deletePasskeyCookie })

// Passkey sign-in should also clear any stored email/error from the traditional
// password login flow.
const loginSession = await getLoginInfoSession(request)
loginSession.clean()
await loginSession.getHeaders(headers)
await session.getHeaders(headers)

return json({ status: 'success' } as const, { headers })
} catch (error) {
console.error('Error during authentication verification:', error)
return json(
Expand Down
4 changes: 3 additions & 1 deletion app/utils/webauthn.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ export function getWebAuthnConfig(request: Request) {
origin: url.origin,
// Common options for both registration and authentication
authenticatorSelection: {
residentKey: 'preferred',
// Required for discoverable credentials, which enables privacy-friendly
// passkey sign-in via form autofill / conditional UI.
residentKey: 'required',
userVerification: 'preferred',
},
} as const
Expand Down
2 changes: 2 additions & 0 deletions docs/agents/project-context.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ reference:
sufficient.
- SQLite is file-based: the database file lives at `prisma/sqlite.db`. No
external database server is required.
- If Playwright E2E tests fail with Prisma "table does not exist" errors, run the
DB reset + seed command from the table above to apply migrations and seed data.
- Cache database: a separate SQLite cache DB is created at `other/cache.db`.
It's populated on first request or via `npm run prime-cache:mocks`.
- Content is filesystem-based: blog posts are MDX files in `content/blog/`.
Expand Down
Loading
Loading