Skip to content

Commit 687fb5d

Browse files
committed
working passkeys
1 parent fdeda42 commit 687fb5d

File tree

13 files changed

+838
-3
lines changed

13 files changed

+838
-3
lines changed

app/routes/_auth+/login.tsx

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
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 {
@@ -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,6 +172,9 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
165172
</StatusButton>
166173
</div>
167174
</Form>
175+
<div className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
176+
<PasskeyLogin />
177+
</div>
168178
<ul className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
169179
{providerNames.map((providerName) => (
170180
<li key={providerName}>
@@ -195,6 +205,77 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
195205
)
196206
}
197207

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

app/routes/settings+/profile.index.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,11 @@ export default function EditUserProfile({ loaderData }: Route.ComponentProps) {
154154
<Icon name="link-2">Manage connections</Icon>
155155
</Link>
156156
</div>
157+
<div>
158+
<Link to="passkeys">
159+
<Icon name="passkey">Manage passkeys</Icon>
160+
</Link>
161+
</div>
157162
<div>
158163
<Link
159164
reloadDocument

0 commit comments

Comments
 (0)