1
1
import type { Uuid } from "@hackerspub/models/uuid" ;
2
+ import {
3
+ type AuthenticationResponseJSON ,
4
+ type PublicKeyCredentialRequestOptionsJSON ,
5
+ startAuthentication ,
6
+ } from "@simplewebauthn/browser" ;
2
7
import { graphql } from "relay-runtime" ;
3
- import { createSignal , Show } from "solid-js" ;
8
+ import { createSignal , onMount , Show } from "solid-js" ;
4
9
import { getRequestEvent } from "solid-js/web" ;
5
10
import { createMutation } from "solid-relay" ;
6
11
import { getRequestProtocol , setCookie } from "vinxi/http" ;
@@ -19,15 +24,18 @@ import {
19
24
TextFieldInput ,
20
25
TextFieldLabel ,
21
26
} from "~/components/ui/text-field.tsx" ;
27
+ import { showToast } from "~/components/ui/toast.tsx" ;
22
28
import { useLingui } from "~/lib/i18n/macro.d.ts" ;
23
29
import type {
24
30
signByEmailMutation ,
25
31
} from "./__generated__/signByEmailMutation.graphql.ts" ;
32
+ import type { signByPasskeyMutation } from "./__generated__/signByPasskeyMutation.graphql.ts" ;
26
33
import type {
27
34
signByUsernameMutation ,
28
35
signByUsernameMutation$data ,
29
36
} from "./__generated__/signByUsernameMutation.graphql.ts" ;
30
37
import type { signCompleteMutation } from "./__generated__/signCompleteMutation.graphql.ts" ;
38
+ import type { signGetPasskeyAuthenticationOptionsMutation } from "./__generated__/signGetPasskeyAuthenticationOptionsMutation.graphql.ts" ;
31
39
32
40
const signByEmailMutation = graphql `
33
41
mutation signByEmailMutation($locale: Locale!, $email: String!, $verifyUrl: URITemplate!) {
@@ -75,6 +83,20 @@ const signCompleteMutation = graphql`
75
83
}
76
84
` ;
77
85
86
+ const signGetPasskeyAuthenticationOptionsMutation = graphql `
87
+ mutation signGetPasskeyAuthenticationOptionsMutation($sessionId: UUID!) {
88
+ getPasskeyAuthenticationOptions(sessionId: $sessionId)
89
+ }
90
+ ` ;
91
+
92
+ const signByPasskeyMutation = graphql `
93
+ mutation signByPasskeyMutation($sessionId: UUID!, $authenticationResponse: JSON!) {
94
+ loginByPasskey(sessionId: $sessionId, authenticationResponse: $authenticationResponse) {
95
+ id
96
+ }
97
+ }
98
+ ` ;
99
+
78
100
const setSessionCookie = async ( sessionId : Uuid ) => {
79
101
"use server" ;
80
102
const event = getRequestEvent ( ) ;
@@ -115,7 +137,25 @@ export default function SignPage() {
115
137
const [ complete ] = createMutation < signCompleteMutation > (
116
138
signCompleteMutation ,
117
139
) ;
140
+ const [ getPasskeyOptions ] = createMutation <
141
+ signGetPasskeyAuthenticationOptionsMutation
142
+ > (
143
+ signGetPasskeyAuthenticationOptionsMutation ,
144
+ ) ;
145
+ const [ loginByPasskey ] = createMutation < signByPasskeyMutation > (
146
+ signByPasskeyMutation ,
147
+ ) ;
118
148
const [ completing , setCompleting ] = createSignal ( false ) ;
149
+ const [ passkeyAuthenticating , setPasskeyAuthenticating ] = createSignal ( false ) ;
150
+ const [ autoPasskeyAttempted , setAutoPasskeyAttempted ] = createSignal ( false ) ;
151
+
152
+ onMount ( ( ) => {
153
+ // Automatically attempt passkey authentication when page loads
154
+ if ( ! autoPasskeyAttempted ( ) ) {
155
+ setAutoPasskeyAttempted ( true ) ;
156
+ onPasskeyLogin ( false ) ;
157
+ }
158
+ } ) ;
119
159
120
160
function onInput ( ) {
121
161
if ( emailInput == null ) return ;
@@ -238,6 +278,80 @@ export default function SignPage() {
238
278
}
239
279
}
240
280
281
+ async function onPasskeyLogin ( showError : boolean ) {
282
+ setPasskeyAuthenticating ( true ) ;
283
+
284
+ try {
285
+ // Generate a temporary session ID for this authentication attempt
286
+ const tempSessionId = crypto . randomUUID ( ) ;
287
+
288
+ // Get authentication options
289
+ const optionsResponse = await new Promise <
290
+ signGetPasskeyAuthenticationOptionsMutation [ "response" ]
291
+ > ( ( resolve , reject ) => {
292
+ getPasskeyOptions ( {
293
+ variables : { sessionId : tempSessionId } ,
294
+ onCompleted : resolve ,
295
+ onError : reject ,
296
+ } ) ;
297
+ } ) ;
298
+
299
+ const options = optionsResponse . getPasskeyAuthenticationOptions ;
300
+ if ( ! options || typeof options !== "object" ) {
301
+ throw new Error ( "Invalid authentication options" ) ;
302
+ }
303
+
304
+ // Start WebAuthn authentication
305
+ let authenticationResponse : AuthenticationResponseJSON ;
306
+ try {
307
+ authenticationResponse = await startAuthentication ( {
308
+ optionsJSON : options as PublicKeyCredentialRequestOptionsJSON ,
309
+ } ) ;
310
+ } catch ( error ) {
311
+ throw new Error (
312
+ error instanceof Error ? error . message : "Authentication failed" ,
313
+ ) ;
314
+ }
315
+
316
+ // Verify authentication and get session
317
+ const loginResponse = await new Promise <
318
+ signByPasskeyMutation [ "response" ]
319
+ > ( ( resolve , reject ) => {
320
+ loginByPasskey ( {
321
+ variables : {
322
+ sessionId : tempSessionId ,
323
+ authenticationResponse,
324
+ } ,
325
+ onCompleted : resolve ,
326
+ onError : reject ,
327
+ } ) ;
328
+ } ) ;
329
+
330
+ if ( loginResponse . loginByPasskey ?. id ) {
331
+ const success = await setSessionCookie ( loginResponse . loginByPasskey . id ) ;
332
+ if ( success ) {
333
+ const searchParams = location == null
334
+ ? new URLSearchParams ( )
335
+ : new URL ( location . href ) . searchParams ;
336
+ window . location . href = searchParams . get ( "next" ) ?? "/" ;
337
+ } else {
338
+ throw new Error ( "Failed to set session cookie" ) ;
339
+ }
340
+ } else {
341
+ throw new Error ( "Authentication verification failed" ) ;
342
+ }
343
+ } catch ( _ ) {
344
+ if ( showError ) {
345
+ showToast ( {
346
+ title : t `Passkey authentication failed` ,
347
+ variant : "destructive" ,
348
+ } ) ;
349
+ }
350
+ } finally {
351
+ setPasskeyAuthenticating ( false ) ;
352
+ }
353
+ }
354
+
241
355
return (
242
356
< div
243
357
lang = { i18n . locale }
@@ -283,6 +397,29 @@ export default function SignPage() {
283
397
</ Button >
284
398
</ Grid >
285
399
</ form >
400
+ < div class = "relative my-6" >
401
+ < div class = "absolute inset-0 flex items-center" >
402
+ < span class = "w-full border-t" />
403
+ </ div >
404
+ < div class = "relative flex justify-center text-xs uppercase" >
405
+ < span class = "bg-background px-2 text-muted-foreground" >
406
+ { t `Or` }
407
+ </ span >
408
+ </ div >
409
+ </ div >
410
+ < div class = "mb-6" >
411
+ < Button
412
+ type = "button"
413
+ variant = "outline"
414
+ disabled = { passkeyAuthenticating ( ) }
415
+ onClick = { ( ) => onPasskeyLogin ( true ) }
416
+ class = "w-full cursor-pointer"
417
+ >
418
+ { passkeyAuthenticating ( )
419
+ ? t `Authenticating...`
420
+ : t `Sign in with passkey` }
421
+ </ Button >
422
+ </ div >
286
423
< div class = "text-center" >
287
424
< p class = "text-sm text-muted-foreground" >
288
425
{ t `Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you.` }
0 commit comments