Skip to content

Commit 4312db2

Browse files
committed
feat: Enhance passkey authentication and registration flow
- Refactored PasskeyLogin component to support redirectTo and remember options - Updated WebAuthn authentication and registration routes with improved error handling - Moved WebAuthn utility functions to a dedicated server-side utils file - Simplified response schemas and added more robust type checking - Removed deprecated webauthn.server.ts utility file
1 parent 687fb5d commit 4312db2

File tree

5 files changed

+152
-184
lines changed

5 files changed

+152
-184
lines changed

app/routes/_auth+/login.tsx

Lines changed: 36 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -172,10 +172,15 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
172172
</StatusButton>
173173
</div>
174174
</Form>
175-
<div className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
176-
<PasskeyLogin />
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+
/>
177181
</div>
178-
<ul className="mt-5 flex flex-col gap-5 border-b-2 border-t-2 border-border py-3">
182+
<hr className="my-4" />
183+
<ul className="flex flex-col gap-5">
179184
{providerNames.map((providerName) => (
180185
<li key={providerName}>
181186
<ProviderConnectionForm
@@ -205,12 +210,24 @@ export default function LoginPage({ actionData }: Route.ComponentProps) {
205210
)
206211
}
207212

208-
const VerificationResponseSchema = z.object({
209-
status: z.enum(['success', 'error']),
210-
error: z.string().optional(),
211-
})
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+
])
212223

213-
function PasskeyLogin() {
224+
function PasskeyLogin({
225+
redirectTo,
226+
remember,
227+
}: {
228+
redirectTo: string | null
229+
remember: boolean
230+
}) {
214231
const [isPending] = useTransition()
215232
const [error, setError] = useState<string | null>(null)
216233
const [passkeyMessage, setPasskeyMessage] = useOptimistic<string | null>(
@@ -234,21 +251,21 @@ function PasskeyLogin() {
234251
const verificationResponse = await fetch('/webauthn/authentication', {
235252
method: 'POST',
236253
headers: { 'Content-Type': 'application/json' },
237-
body: JSON.stringify(authResponse),
254+
body: JSON.stringify({ authResponse, remember, redirectTo }),
238255
})
239256

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-
}
257+
if (!verificationResponse.ok) {
258+
throw new Error('Failed to authenticate with passkey')
259+
}
260+
261+
const verificationJson = await verificationResponse.json()
262+
const verification = VerificationResponseSchema.parse(verificationJson)
263+
if (verification.status === 'error') {
264+
throw new Error(verification.error)
248265
}
249266

250-
setPasskeyMessage("You're logged in! Navigating to your account page.")
251-
await navigate(verificationResponse.headers.get('Location') ?? '/')
267+
setPasskeyMessage("You're logged in! Navigating...")
268+
await navigate(verification.location ?? '/')
252269
} catch (e) {
253270
setError(
254271
e instanceof Error ? e.message : 'Failed to authenticate with passkey',

app/routes/_auth+/webauthn.authentication.ts renamed to app/routes/_auth+/webauthn+/authentication.ts

Lines changed: 21 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
import {
22
generateAuthenticationOptions,
33
verifyAuthenticationResponse,
4-
type AuthenticationResponseJSON,
54
} from '@simplewebauthn/server'
6-
import { z } from 'zod'
75
import { getSessionExpirationDate } from '#app/utils/auth.server.ts'
86
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'
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'
1214

1315
export async function loader({ request }: Route.LoaderArgs) {
1416
const config = getWebAuthnConfig(request)
@@ -24,27 +26,6 @@ export async function loader({ request }: Route.LoaderArgs) {
2426
return Response.json({ options }, { headers: { 'Set-Cookie': cookieHeader } })
2527
}
2628

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-
4829
export async function action({ request }: Route.ActionArgs) {
4930
const cookieHeader = request.headers.get('Cookie')
5031
const cookie = await passkeyCookie.parse(cookieHeader)
@@ -55,13 +36,14 @@ export async function action({ request }: Route.ActionArgs) {
5536
}
5637

5738
const body = await request.json()
58-
const result = AuthenticationResponseSchema.safeParse(body)
39+
const result = PasskeyLoginBodySchema.safeParse(body)
5940
if (!result.success) {
6041
throw new Error('Invalid authentication response')
6142
}
43+
const { authResponse, remember, redirectTo } = result.data
6244

6345
const passkey = await prisma.passkey.findUnique({
64-
where: { id: result.data.id },
46+
where: { id: authResponse.id },
6547
include: { user: true },
6648
})
6749
if (!passkey) {
@@ -71,12 +53,12 @@ export async function action({ request }: Route.ActionArgs) {
7153
const config = getWebAuthnConfig(request)
7254

7355
const verification = await verifyAuthenticationResponse({
74-
response: result.data,
56+
response: authResponse,
7557
expectedChallenge: cookie.challenge,
7658
expectedOrigin: config.origin,
7759
expectedRPID: config.rpID,
7860
credential: {
79-
id: result.data.id,
61+
id: authResponse.id,
8062
publicKey: passkey.publicKey,
8163
counter: Number(passkey.counter),
8264
},
@@ -104,14 +86,19 @@ export async function action({ request }: Route.ActionArgs) {
10486
{
10587
request,
10688
session,
107-
// TODO: handle remember and redirectTo
108-
remember: false,
109-
redirectTo: '/',
89+
remember,
90+
redirectTo: redirectTo ?? undefined,
11091
},
11192
{ headers: { 'Set-Cookie': deletePasskeyCookie } },
11293
)
11394

114-
return response
95+
return Response.json(
96+
{
97+
status: 'success',
98+
location: response.headers.get('Location'),
99+
},
100+
{ headers: response.headers },
101+
)
115102
} catch (error) {
116103
if (error instanceof Response) throw error
117104

app/routes/_auth+/webauthn.registration.ts renamed to app/routes/_auth+/webauthn+/registration.ts

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,13 @@ import {
55
import { requireUserId } from '#app/utils/auth.server.ts'
66
import { prisma } from '#app/utils/db.server.ts'
77
import { getDomainUrl, getErrorMessage } from '#app/utils/misc.tsx'
8+
import { type Route } from './+types/registration.ts'
89
import {
910
PasskeyCookieSchema,
1011
RegistrationResponseSchema,
11-
parseAttestationObject,
1212
passkeyCookie,
1313
getWebAuthnConfig,
14-
} from '#app/utils/webauthn.server.js'
15-
import { type Route } from './+types/webauthn.registration.ts'
14+
} from './utils.server.ts'
1615

1716
export async function loader({ request }: Route.LoaderArgs) {
1817
const userId = await requireUserId(request)
@@ -63,12 +62,6 @@ export async function action({ request }: Route.ActionArgs) {
6362
}
6463

6564
const data = result.data
66-
let aaguid: string
67-
68-
const parsedAttestation = parseAttestationObject(
69-
data.response.attestationObject,
70-
)
71-
aaguid = parsedAttestation.authData.aaguid
7265

7366
// Get challenge from cookie
7467
const passkeyCookieData = await passkeyCookie.parse(
@@ -97,7 +90,7 @@ export async function action({ request }: Route.ActionArgs) {
9790
if (!verified || !registrationInfo) {
9891
throw new Error('Registration verification failed')
9992
}
100-
const { credential, credentialDeviceType, credentialBackedUp } =
93+
const { credential, credentialDeviceType, credentialBackedUp, aaguid } =
10194
registrationInfo
10295

10396
const existingPasskey = await prisma.passkey.findUnique({
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
type AuthenticationResponseJSON,
3+
type RegistrationResponseJSON,
4+
} from '@simplewebauthn/server'
5+
import { createCookie } from 'react-router'
6+
import { z } from 'zod'
7+
import { getDomainUrl } from '#app/utils/misc.tsx'
8+
9+
export const passkeyCookie = createCookie('webauthn-challenge', {
10+
path: '/',
11+
sameSite: 'lax',
12+
httpOnly: true,
13+
maxAge: 60 * 60 * 2,
14+
secure: process.env.NODE_ENV === 'production',
15+
secrets: [process.env.SESSION_SECRET],
16+
})
17+
18+
export const PasskeyCookieSchema = z.object({
19+
challenge: z.string(),
20+
userId: z.string(),
21+
})
22+
23+
export const RegistrationResponseSchema = z.object({
24+
id: z.string(),
25+
rawId: z.string(),
26+
response: z.object({
27+
clientDataJSON: z.string(),
28+
attestationObject: z.string(),
29+
transports: z
30+
.array(
31+
z.enum([
32+
'ble',
33+
'cable',
34+
'hybrid',
35+
'internal',
36+
'nfc',
37+
'smart-card',
38+
'usb',
39+
]),
40+
)
41+
.optional(),
42+
}),
43+
authenticatorAttachment: z.enum(['cross-platform', 'platform']).optional(),
44+
clientExtensionResults: z.object({
45+
credProps: z
46+
.object({
47+
rk: z.boolean(),
48+
})
49+
.optional(),
50+
}),
51+
type: z.literal('public-key'),
52+
}) satisfies z.ZodType<RegistrationResponseJSON>
53+
54+
const AuthenticationResponseSchema = z.object({
55+
id: z.string(),
56+
rawId: z.string(),
57+
response: z.object({
58+
clientDataJSON: z.string(),
59+
authenticatorData: z.string(),
60+
signature: z.string(),
61+
userHandle: z.string().optional(),
62+
}),
63+
type: z.literal('public-key'),
64+
clientExtensionResults: z.object({
65+
appid: z.boolean().optional(),
66+
credProps: z
67+
.object({
68+
rk: z.boolean().optional(),
69+
})
70+
.optional(),
71+
hmacCreateSecret: z.boolean().optional(),
72+
}),
73+
}) satisfies z.ZodType<AuthenticationResponseJSON>
74+
75+
export const PasskeyLoginBodySchema = z.object({
76+
authResponse: AuthenticationResponseSchema,
77+
remember: z.boolean().default(false),
78+
redirectTo: z.string().nullable(),
79+
})
80+
81+
export function getWebAuthnConfig(request: Request) {
82+
const url = new URL(getDomainUrl(request))
83+
return {
84+
rpName: url.hostname,
85+
rpID: url.hostname,
86+
origin: url.origin,
87+
authenticatorSelection: {
88+
residentKey: 'preferred',
89+
userVerification: 'preferred',
90+
},
91+
} as const
92+
}

0 commit comments

Comments
 (0)