Skip to content

Commit 874d5b1

Browse files
authored
Add WebAuthn passkey authentication support (#936)
1 parent f99a9f1 commit 874d5b1

File tree

17 files changed

+1181
-6
lines changed

17 files changed

+1181
-6
lines changed

app/routes/_auth+/login.tsx

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,23 @@
11
import { getFormProps, getInputProps, useForm } from '@conform-to/react'
22
import { getZodConstraint, parseWithZod } from '@conform-to/zod'
33
import { type SEOHandle } from '@nasa-gcn/remix-seo'
4-
import { data, Form, Link, useSearchParams } from 'react-router'
4+
import { startAuthentication } from '@simplewebauthn/browser'
5+
import { useOptimistic, useState, useTransition } from 'react'
6+
import { data, Form, Link, useNavigate, useSearchParams } from 'react-router'
57
import { HoneypotInputs } from 'remix-utils/honeypot/react'
68
import { z } from 'zod'
79
import { GeneralErrorBoundary } from '#app/components/error-boundary.tsx'
810
import { CheckboxField, ErrorList, Field } from '#app/components/forms.tsx'
911
import { Spacer } from '#app/components/spacer.tsx'
12+
import { Icon } from '#app/components/ui/icon.tsx'
1013
import { StatusButton } from '#app/components/ui/status-button.tsx'
1114
import { login, requireAnonymous } from '#app/utils/auth.server.ts'
1215
import {
1316
ProviderConnectionForm,
1417
providerNames,
1518
} from '#app/utils/connections.tsx'
1619
import { checkHoneypot } from '#app/utils/honeypot.server.ts'
17-
import { useIsPending } from '#app/utils/misc.tsx'
20+
import { getErrorMessage, useIsPending } from '#app/utils/misc.tsx'
1821
import { PasswordSchema, UsernameSchema } from '#app/utils/user-validation.ts'
1922
import { type Route } from './+types/login.ts'
2023
import { handleNewSession } from './login.server.ts'
@@ -30,6 +33,10 @@ const LoginFormSchema = z.object({
3033
remember: z.boolean().optional(),
3134
})
3235

36+
const AuthenticationOptionsSchema = z.object({
37+
options: z.object({ challenge: z.string() }),
38+
}) satisfies z.ZodType<{ options: PublicKeyCredentialRequestOptionsJSON }>
39+
3340
export async function loader({ request }: Route.LoaderArgs) {
3441
await requireAnonymous(request)
3542
return {}
@@ -165,7 +172,15 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
165172
</StatusButton>
166173
</div>
167174
</Form>
168-
<ul className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
175+
<hr className="my-4" />
176+
<div className="flex flex-col gap-5">
177+
<PasskeyLogin
178+
redirectTo={redirectTo}
179+
remember={fields.remember.value === 'on'}
180+
/>
181+
</div>
182+
<hr className="my-4" />
183+
<ul className="flex flex-col gap-5">
169184
{providerNames.map((providerName) => (
170185
<li key={providerName}>
171186
<ProviderConnectionForm
@@ -195,6 +210,94 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
195210
)
196211
}
197212

213+
const VerificationResponseSchema = z.discriminatedUnion('status', [
214+
z.object({
215+
status: z.literal('success'),
216+
location: z.string(),
217+
}),
218+
z.object({
219+
status: z.literal('error'),
220+
error: z.string(),
221+
}),
222+
])
223+
224+
function PasskeyLogin({
225+
redirectTo,
226+
remember,
227+
}: {
228+
redirectTo: string | null
229+
remember: boolean
230+
}) {
231+
const [isPending] = useTransition()
232+
const [error, setError] = useState<string | null>(null)
233+
const [passkeyMessage, setPasskeyMessage] = useOptimistic<string | null>(
234+
'Login with a passkey',
235+
)
236+
const navigate = useNavigate()
237+
238+
async function handlePasskeyLogin() {
239+
try {
240+
setPasskeyMessage('Generating Authentication Options')
241+
// Get authentication options from the server
242+
const optionsResponse = await fetch('/webauthn/authentication')
243+
const json = await optionsResponse.json()
244+
const { options } = AuthenticationOptionsSchema.parse(json)
245+
246+
setPasskeyMessage('Requesting your authorization')
247+
const authResponse = await startAuthentication({ optionsJSON: options })
248+
setPasskeyMessage('Verifying your passkey')
249+
250+
// Verify the authentication with the server
251+
const verificationResponse = await fetch('/webauthn/authentication', {
252+
method: 'POST',
253+
headers: { 'Content-Type': 'application/json' },
254+
body: JSON.stringify({ authResponse, remember, redirectTo }),
255+
})
256+
257+
const verificationJson = await verificationResponse.json().catch(() => ({
258+
status: 'error',
259+
error: 'Unknown error',
260+
}))
261+
262+
const parsedResult =
263+
VerificationResponseSchema.safeParse(verificationJson)
264+
if (!parsedResult.success) {
265+
throw new Error(parsedResult.error.message)
266+
} else if (parsedResult.data.status === 'error') {
267+
throw new Error(parsedResult.data.error)
268+
}
269+
const { location } = parsedResult.data
270+
271+
setPasskeyMessage("You're logged in! Navigating...")
272+
await navigate(location ?? '/')
273+
} catch (e) {
274+
const errorMessage = getErrorMessage(e)
275+
setError(`Failed to authenticate with passkey: ${errorMessage}`)
276+
}
277+
}
278+
279+
return (
280+
<form action={handlePasskeyLogin}>
281+
<StatusButton
282+
id="passkey-login-button"
283+
aria-describedby="passkey-login-button-error"
284+
className="w-full"
285+
status={isPending ? 'pending' : error ? 'error' : 'idle'}
286+
type="submit"
287+
disabled={isPending}
288+
>
289+
<span className="inline-flex items-center gap-1.5">
290+
<Icon name="passkey" />
291+
<span>{passkeyMessage}</span>
292+
</span>
293+
</StatusButton>
294+
<div className="mt-2">
295+
<ErrorList errors={[error]} id="passkey-login-button-error" />
296+
</div>
297+
</form>
298+
)
299+
}
300+
198301
export const meta: Route.MetaFunction = () => {
199302
return [{ title: 'Login to Epic Notes' }]
200303
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import {
2+
generateAuthenticationOptions,
3+
verifyAuthenticationResponse,
4+
} from '@simplewebauthn/server'
5+
import { getSessionExpirationDate } from '#app/utils/auth.server.ts'
6+
import { prisma } from '#app/utils/db.server.ts'
7+
import { handleNewSession } from '../login.server.ts'
8+
import { type Route } from './+types/authentication.ts'
9+
import {
10+
PasskeyLoginBodySchema,
11+
getWebAuthnConfig,
12+
passkeyCookie,
13+
} from './utils.server.ts'
14+
15+
export async function loader({ request }: Route.LoaderArgs) {
16+
const config = getWebAuthnConfig(request)
17+
const options = await generateAuthenticationOptions({
18+
rpID: config.rpID,
19+
userVerification: 'preferred',
20+
})
21+
22+
const cookieHeader = await passkeyCookie.serialize({
23+
challenge: options.challenge,
24+
})
25+
26+
return Response.json({ options }, { headers: { 'Set-Cookie': cookieHeader } })
27+
}
28+
29+
export async function action({ request }: Route.ActionArgs) {
30+
const cookieHeader = request.headers.get('Cookie')
31+
const cookie = await passkeyCookie.parse(cookieHeader)
32+
const deletePasskeyCookie = await passkeyCookie.serialize('', { maxAge: 0 })
33+
try {
34+
if (!cookie?.challenge) {
35+
throw new Error('Authentication challenge not found')
36+
}
37+
38+
const body = await request.json()
39+
const result = PasskeyLoginBodySchema.safeParse(body)
40+
if (!result.success) {
41+
throw new Error('Invalid authentication response')
42+
}
43+
const { authResponse, remember, redirectTo } = result.data
44+
45+
const passkey = await prisma.passkey.findUnique({
46+
where: { id: authResponse.id },
47+
include: { user: true },
48+
})
49+
if (!passkey) {
50+
throw new Error('Passkey not found')
51+
}
52+
53+
const config = getWebAuthnConfig(request)
54+
55+
const verification = await verifyAuthenticationResponse({
56+
response: authResponse,
57+
expectedChallenge: cookie.challenge,
58+
expectedOrigin: config.origin,
59+
expectedRPID: config.rpID,
60+
credential: {
61+
id: authResponse.id,
62+
publicKey: passkey.publicKey,
63+
counter: Number(passkey.counter),
64+
},
65+
})
66+
67+
if (!verification.verified) {
68+
throw new Error('Authentication verification failed')
69+
}
70+
71+
// Update the authenticator's counter in the DB to the newest count
72+
await prisma.passkey.update({
73+
where: { id: passkey.id },
74+
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
75+
})
76+
77+
const session = await prisma.session.create({
78+
select: { id: true, expirationDate: true, userId: true },
79+
data: {
80+
expirationDate: getSessionExpirationDate(),
81+
userId: passkey.userId,
82+
},
83+
})
84+
85+
const response = await handleNewSession(
86+
{
87+
request,
88+
session,
89+
remember,
90+
redirectTo: redirectTo ?? undefined,
91+
},
92+
{ headers: { 'Set-Cookie': deletePasskeyCookie } },
93+
)
94+
95+
return Response.json(
96+
{
97+
status: 'success',
98+
location: response.headers.get('Location'),
99+
},
100+
{ headers: response.headers },
101+
)
102+
} catch (error) {
103+
if (error instanceof Response) throw error
104+
105+
return Response.json(
106+
{
107+
status: 'error',
108+
error: error instanceof Error ? error.message : 'Verification failed',
109+
} as const,
110+
{ status: 400, headers: { 'Set-Cookie': deletePasskeyCookie } },
111+
)
112+
}
113+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import {
2+
generateRegistrationOptions,
3+
verifyRegistrationResponse,
4+
} from '@simplewebauthn/server'
5+
import { requireUserId } from '#app/utils/auth.server.ts'
6+
import { prisma } from '#app/utils/db.server.ts'
7+
import { getDomainUrl, getErrorMessage } from '#app/utils/misc.tsx'
8+
import { type Route } from './+types/registration.ts'
9+
import {
10+
PasskeyCookieSchema,
11+
RegistrationResponseSchema,
12+
passkeyCookie,
13+
getWebAuthnConfig,
14+
} from './utils.server.ts'
15+
16+
export async function loader({ request }: Route.LoaderArgs) {
17+
const userId = await requireUserId(request)
18+
const passkeys = await prisma.passkey.findMany({
19+
where: { userId },
20+
select: { id: true },
21+
})
22+
const user = await prisma.user.findUniqueOrThrow({
23+
where: { id: userId },
24+
select: { email: true, name: true, username: true },
25+
})
26+
27+
const config = getWebAuthnConfig(request)
28+
const options = await generateRegistrationOptions({
29+
rpName: config.rpName,
30+
rpID: config.rpID,
31+
userName: user.username,
32+
userID: new TextEncoder().encode(userId),
33+
userDisplayName: user.name ?? user.email,
34+
attestationType: 'none',
35+
excludeCredentials: passkeys,
36+
authenticatorSelection: {
37+
residentKey: 'preferred',
38+
userVerification: 'preferred',
39+
},
40+
})
41+
42+
return Response.json(
43+
{ options },
44+
{
45+
headers: {
46+
'Set-Cookie': await passkeyCookie.serialize(
47+
PasskeyCookieSchema.parse({
48+
challenge: options.challenge,
49+
userId: options.user.id,
50+
}),
51+
),
52+
},
53+
},
54+
)
55+
}
56+
57+
export async function action({ request }: Route.ActionArgs) {
58+
try {
59+
const userId = await requireUserId(request)
60+
61+
const body = await request.json()
62+
const result = RegistrationResponseSchema.safeParse(body)
63+
if (!result.success) {
64+
throw new Error('Invalid registration response')
65+
}
66+
67+
const data = result.data
68+
69+
// Get challenge from cookie
70+
const passkeyCookieData = await passkeyCookie.parse(
71+
request.headers.get('Cookie'),
72+
)
73+
const parsedPasskeyCookieData =
74+
PasskeyCookieSchema.safeParse(passkeyCookieData)
75+
if (!parsedPasskeyCookieData.success) {
76+
throw new Error('No challenge found')
77+
}
78+
const { challenge, userId: webauthnUserId } = parsedPasskeyCookieData.data
79+
80+
const domain = new URL(getDomainUrl(request)).hostname
81+
const rpID = domain
82+
const origin = getDomainUrl(request)
83+
84+
const verification = await verifyRegistrationResponse({
85+
response: data,
86+
expectedChallenge: challenge,
87+
expectedOrigin: origin,
88+
expectedRPID: rpID,
89+
requireUserVerification: true,
90+
})
91+
92+
const { verified, registrationInfo } = verification
93+
if (!verified || !registrationInfo) {
94+
throw new Error('Registration verification failed')
95+
}
96+
const { credential, credentialDeviceType, credentialBackedUp, aaguid } =
97+
registrationInfo
98+
99+
const existingPasskey = await prisma.passkey.findUnique({
100+
where: { id: credential.id },
101+
select: { id: true },
102+
})
103+
104+
if (existingPasskey) {
105+
throw new Error('This passkey has already been registered')
106+
}
107+
108+
// Create new passkey in database
109+
await prisma.passkey.create({
110+
data: {
111+
id: credential.id,
112+
aaguid,
113+
publicKey: Buffer.from(credential.publicKey),
114+
userId,
115+
webauthnUserId,
116+
counter: credential.counter,
117+
deviceType: credentialDeviceType,
118+
backedUp: credentialBackedUp,
119+
transports: credential.transports?.join(','),
120+
},
121+
})
122+
123+
return Response.json({ status: 'success' } as const, {
124+
headers: {
125+
'Set-Cookie': await passkeyCookie.serialize('', { maxAge: 0 }),
126+
},
127+
})
128+
} catch (error) {
129+
if (error instanceof Response) throw error
130+
131+
return Response.json(
132+
{ status: 'error', error: getErrorMessage(error) } as const,
133+
{ status: 400 },
134+
)
135+
}
136+
}

0 commit comments

Comments
 (0)