+17
bin/main.ml
+17
bin/main.ml
···
46
46
; (post, "/account/migrate", Api.Account_.Migrate.post_handler)
47
47
; (post, "/account/switch", Api.Account_.Login.switch_account_handler)
48
48
; (get, "/account/logout", Api.Account_.Logout.handler)
49
+
; (* passkey management (authed) *)
50
+
(get, "/account/passkeys", Api.Account_.Passkeys.list_handler)
51
+
; ( get
52
+
, "/account/passkeys/register/options"
53
+
, Api.Account_.Passkeys.register_options_handler )
54
+
; ( post
55
+
, "/account/passkeys/register/verify"
56
+
, Api.Account_.Passkeys.register_verify_handler )
57
+
; (delete, "/account/passkeys/:id", Api.Account_.Passkeys.delete_handler)
58
+
; (post, "/account/passkeys/:id/rename", Api.Account_.Passkeys.rename_handler)
59
+
; (* passkey login (unauthed) *)
60
+
( get
61
+
, "/account/passkeys/login/options"
62
+
, Api.Account_.Passkeys.login_options_handler )
63
+
; ( post
64
+
, "/account/passkeys/login/verify"
65
+
, Api.Account_.Passkeys.login_verify_handler )
49
66
; (* admin ui *)
50
67
(get, "/admin", Api.Admin_.Index.handler)
51
68
; (get, "/admin/login", Api.Admin_.Login.get_handler)
+1
dune-project
+1
dune-project
+22
frontend/client/WebAuthn.mlx
+22
frontend/client/WebAuthn.mlx
···
1
+
external browserSupportsWebAuthn : unit -> bool = "browserSupportsWebAuthn"
2
+
[@@mel.module "@simplewebauthn/browser"]
3
+
4
+
external browserSupportsWebAuthnAutofill : unit -> bool Js.Promise.t
5
+
= "browserSupportsWebAuthnAutofill"
6
+
[@@mel.module "@simplewebauthn/browser"]
7
+
8
+
type registration_options = {optionsJSON: Js.Json.t}
9
+
10
+
external startRegistration : registration_options -> Js.Json.t Js.Promise.t
11
+
= "startRegistration"
12
+
[@@mel.module "@simplewebauthn/browser"]
13
+
14
+
type auth_options = {optionsJSON: Js.Json.t; useBrowserAutofill: bool}
15
+
16
+
external startAuthentication : auth_options -> Js.Json.t Js.Promise.t
17
+
= "startAuthentication"
18
+
[@@mel.module "@simplewebauthn/browser"]
19
+
20
+
external startAuthenticationSimple : Js.Json.t -> Js.Json.t Js.Promise.t
21
+
= "startAuthentication"
22
+
[@@mel.module "@simplewebauthn/browser"]
+1
-1
frontend/src/components/AccountSidebar.mlx
+1
-1
frontend/src/components/AccountSidebar.mlx
···
19
19
footer=(<a
20
20
href=( "/account/logout?did="
21
21
^ Js.Global.encodeURIComponent current_user.did )>
22
-
<Button kind=`Secondary className="mt-2 justify-start pl-0">
22
+
<Button kind=`Secondary className="mt-2 justify-start pl-0 active:shadow-none focus-visible:shadow-none">
23
23
(string "log out")
24
24
</Button>
25
25
</a>)
+1
frontend/src/dune
+1
frontend/src/dune
+212
-6
frontend/src/templates/AccountPage.mlx
+212
-6
frontend/src/templates/AccountPage.mlx
···
7
7
{did: string; handle: string; avatar_data_uri: string option [@default None]}
8
8
[@@deriving json]
9
9
10
+
type passkey_display =
11
+
{ id: int
12
+
; name: string
13
+
; created_at: int
14
+
; last_used_at: int option [@default None] }
15
+
[@@deriving json]
16
+
10
17
type props =
11
18
{ current_user: actor
12
19
; logged_in_users: actor list
···
23
30
; delete_pending: bool [@default false]
24
31
; error: string option [@default None]
25
32
; success: string option [@default None]
26
-
; delete_error: string option [@default None] }
33
+
; delete_error: string option [@default None]
34
+
; passkeys: passkey_display list [@default []] }
27
35
[@@deriving json]
28
36
29
37
let[@react.component] make
···
43
51
; delete_pending
44
52
; error
45
53
; success
46
-
; delete_error } :
54
+
; delete_error
55
+
; passkeys } :
47
56
props ) () =
48
57
let emailModalOpen, setEmailModalOpen =
49
58
useState (fun () -> email_change_pending)
···
85
94
let importLoading, setImportLoading = useState (fun () -> false) in
86
95
let importError, setImportError = useState (fun () -> None) in
87
96
let importSuccess, setImportSuccess = useState (fun () -> false) in
97
+
let passkeysState, setPasskeysState = useState (fun () -> passkeys) in
98
+
let addingPasskey, setAddingPasskey = useState (fun () -> false) in
99
+
let passkeyName, setPasskeyName = useState (fun () -> "") in
100
+
let passkeyError, setPasskeyError = useState (fun () -> None) in
101
+
let webauthnSupported, setWebauthnSupported = useState (fun () -> false) in
102
+
let currentWebAuthnOptions = useRef None in
88
103
let fileInputRef : Dom.element Js.nullable React.ref =
89
104
useRef Js.Nullable.null
105
+
in
106
+
let _ =
107
+
React.useEffect0 (fun () ->
108
+
setWebauthnSupported (fun _ -> WebAuthn.browserSupportsWebAuthn ()) ;
109
+
None )
90
110
in
91
111
<div
92
112
className="w-full h-full max-w-[816px] px-8 pt-16 mx-auto flex flex-col \
···
202
222
setEmailErrorState (fun _ ->
203
223
Some "An error occurred. Please try again." ) ;
204
224
Js.Promise.resolve () ) )
205
-
|> Js.Promise.catch (fun _ ->
225
+
|> Js.Promise.catch (fun e ->
226
+
Js.Console.error (Obj.magic e) ;
206
227
setEmailLoading (fun _ -> false) ;
207
228
setEmailErrorState (fun _ ->
208
229
Some "An error occurred. Please try again." ) ;
···
421
442
Some
422
443
"An error occurred. Please try again." ) ;
423
444
Js.Promise.resolve () ) )
424
-
|> Js.Promise.catch (fun _ ->
445
+
|> Js.Promise.catch (fun e ->
446
+
Js.Console.error (Obj.magic e) ;
425
447
setConfirmEmailLoading (fun _ -> false) ;
426
448
setConfirmEmailError (fun _ ->
427
449
Some "An error occurred. Please try again." ) ;
···
600
622
Some "Import failed. Please try again." ) ;
601
623
Js.Promise.resolve ()
602
624
end )
603
-
|> Js.Promise.catch (fun _ ->
625
+
|> Js.Promise.catch (fun e ->
626
+
Js.Console.error (Obj.magic e) ;
604
627
setImportLoading (fun _ -> false) ;
605
628
setImportError (fun _ ->
606
629
Some "An error occurred. Please try again." ) ;
···
662
685
</section>
663
686
<section className="mt-8">
664
687
<h2 className="text-xl font-serif text-mana-200 mb-1">
688
+
(string "passkeys")
689
+
</h2>
690
+
<p className="text-mist-100 mb-4">
691
+
(string
692
+
"Passkeys are stored on your device, providing a more \
693
+
convenient alternative to logging in with a password." )
694
+
</p>
695
+
<ClientOnly
696
+
fallback=(<p className="text-mist-80 text-sm">
697
+
(string "Loading passkeys...")
698
+
</p>)>
699
+
[%browser_only
700
+
fun () ->
701
+
let formatDate ts =
702
+
let d = Js.Date.fromFloat (Float.of_int ts) in
703
+
Js.Date.toLocaleDateString d
704
+
in
705
+
let addPasskey () =
706
+
setAddingPasskey (fun _ -> true) ;
707
+
setPasskeyError (fun _ -> None) ;
708
+
let _ =
709
+
Fetch.fetch "/account/passkeys/register/options"
710
+
|> Js.Promise.then_ (fun response ->
711
+
if Fetch.Response.ok response then
712
+
Fetch.Response.json response
713
+
else Js.Exn.raiseError "Failed to get options" )
714
+
|> Js.Promise.then_ (fun options ->
715
+
currentWebAuthnOptions.current <- Some options ;
716
+
WebAuthn.startRegistration {optionsJSON= options} )
717
+
|> Js.Promise.then_ (fun credential ->
718
+
let challenge =
719
+
Js.Dict.unsafeGet
720
+
(Obj.magic currentWebAuthnOptions.current)
721
+
"challenge"
722
+
in
723
+
let body =
724
+
Js.Json.object_
725
+
(Js.Dict.fromArray
726
+
[| ( "response"
727
+
, Js.Json.string
728
+
(Js.Json.stringify credential) )
729
+
; ("challenge", challenge)
730
+
; ( "name"
731
+
, Js.Json.string
732
+
( if passkeyName = "" then "Passkey"
733
+
else passkeyName ) ) |] )
734
+
in
735
+
Fetch.fetchWithInit
736
+
"/account/passkeys/register/verify"
737
+
(Fetch.RequestInit.make ~method_:Post
738
+
~body:
739
+
(Fetch.BodyInit.make (Js.Json.stringify body))
740
+
~headers:
741
+
(Fetch.HeadersInit.makeWithArray
742
+
[|("Content-Type", "application/json")|] )
743
+
() ) )
744
+
|> Js.Promise.then_ (fun response ->
745
+
setAddingPasskey (fun _ -> false) ;
746
+
if Fetch.Response.ok response then begin
747
+
setPasskeyName (fun _ -> "") ;
748
+
Fetch.fetch "/account/passkeys"
749
+
|> Js.Promise.then_ (fun r ->
750
+
Fetch.Response.json r )
751
+
|> Js.Promise.then_ (fun json ->
752
+
let pks : passkey_display list =
753
+
Js.Dict.unsafeGet (Obj.magic json)
754
+
"passkeys"
755
+
|> Obj.magic |> Array.to_list
756
+
in
757
+
setPasskeysState (fun _ -> pks) ;
758
+
Js.Promise.resolve () )
759
+
end
760
+
else begin
761
+
setPasskeyError (fun _ ->
762
+
Some "Failed to register passkey" ) ;
763
+
Fetch.Response.text response
764
+
|> Js.Promise.then_ (fun e ->
765
+
Js.Console.error e ; Js.Promise.resolve () )
766
+
end )
767
+
|> Js.Promise.catch (fun e ->
768
+
Js.Console.error (Obj.magic e) ;
769
+
setAddingPasskey (fun _ -> false) ;
770
+
setPasskeyError (fun _ ->
771
+
Some "Passkey registration cancelled or failed" ) ;
772
+
Js.Promise.resolve () )
773
+
in
774
+
()
775
+
in
776
+
let deletePasskey id =
777
+
let _ =
778
+
Fetch.fetchWithInit
779
+
("/account/passkeys/" ^ string_of_int id)
780
+
(Fetch.RequestInit.make ~method_:Delete ())
781
+
|> Js.Promise.then_ (fun response ->
782
+
if Fetch.Response.ok response then
783
+
setPasskeysState (fun ps ->
784
+
List.filter (fun p -> p.id <> id) ps ) ;
785
+
Js.Promise.resolve () )
786
+
in
787
+
()
788
+
in
789
+
if not webauthnSupported then
790
+
<p className="text-mist-80 text-sm">
791
+
(string "Your browser doesn't support passkeys.")
792
+
</p>
793
+
else
794
+
<div>
795
+
( if List.length passkeysState = 0 then
796
+
<p className="text-mist-80 text-sm mb-4">
797
+
(string "You haven't added any passkeys yet.")
798
+
</p>
799
+
else
800
+
<ul className="mb-4 space-y-2">
801
+
( List.map
802
+
(fun (pk : passkey_display) ->
803
+
<li
804
+
key=(string_of_int pk.id)
805
+
className="flex items-center \
806
+
justify-between p-3 border \
807
+
border-mist-60 rounded-lg">
808
+
<div>
809
+
<span
810
+
className="font-medium text-mist-100">
811
+
(string pk.name)
812
+
</span>
813
+
<span
814
+
className="text-sm text-mist-80 ml-2">
815
+
(string
816
+
( {js|⸱ |js}
817
+
^ formatDate pk.created_at ) )
818
+
</span>
819
+
</div>
820
+
<button
821
+
type_="button"
822
+
className="p-1 text-phoenix-100 \
823
+
hover:text-phoenix-200 \
824
+
cursor-pointer"
825
+
onClick=(fun _ -> deletePasskey pk.id)>
826
+
<TrashIcon className="w-4 h-4" />
827
+
</button>
828
+
</li> )
829
+
passkeysState
830
+
|> Array.of_list |> React.array )
831
+
</ul> )
832
+
<div className="flex flex-row gap-x-3">
833
+
<div className="flex-1">
834
+
<Input
835
+
name="passkey_name"
836
+
label="Passkey name"
837
+
placeholder="My Little Passkey"
838
+
showIndicator=false
839
+
value=passkeyName
840
+
onChange=(fun e ->
841
+
setPasskeyName (fun _ ->
842
+
(Event.Form.target e)##value ) )
843
+
/>
844
+
</div>
845
+
<div className="self-end">
846
+
<Button
847
+
type_="button"
848
+
disabled=addingPasskey
849
+
onClick=(fun _ -> addPasskey ())>
850
+
(string
851
+
(if addingPasskey then "adding..." else "add") )
852
+
</Button>
853
+
</div>
854
+
</div>
855
+
( match passkeyError with
856
+
| Some err ->
857
+
<span
858
+
className="inline-flex items-center \
859
+
text-phoenix-100 text-sm mt-2">
860
+
<CircleAlertIcon className="w-4 h-4 mr-2" />
861
+
(string err)
862
+
</span>
863
+
| None ->
864
+
null )
865
+
</div>]
866
+
</ClientOnly>
867
+
</section>
868
+
<section className="mt-8">
869
+
<h2 className="text-xl font-serif text-mana-200 mb-1">
665
870
(string "danger zone")
666
871
</h2>
667
872
<p className="text-mist-100 mb-1">
···
735
940
setDeleteErrorState (fun _ ->
736
941
Some "An error occurred. Please try again." ) ;
737
942
Js.Promise.resolve () ) )
738
-
|> Js.Promise.catch (fun _ ->
943
+
|> Js.Promise.catch (fun e ->
944
+
Js.Console.error (Obj.magic e) ;
739
945
setDeleteLoading (fun _ -> false) ;
740
946
setDeleteErrorState (fun _ ->
741
947
Some "An error occurred. Please try again." ) ;
+87
-4
frontend/src/templates/LoginPage.mlx
+87
-4
frontend/src/templates/LoginPage.mlx
···
1
+
[@@@ocaml.warning "-26-27"]
2
+
1
3
open Melange_json.Primitives
2
4
open React
3
5
···
9
11
10
12
let[@react.component] make ~props:({redirect_url; csrf_token; error} : props) ()
11
13
=
14
+
let passkeyError, setPasskeyError = useState (fun () -> None) in
15
+
let passkeyLoading, setPasskeyLoading = useState (fun () -> false) in
16
+
let currentOptions = useRef (None : Js.Json.t option) in
17
+
let _ =
18
+
React.useEffect0 (fun () ->
19
+
let _ =
20
+
WebAuthn.browserSupportsWebAuthnAutofill ()
21
+
|> Js.Promise.then_ (fun supported ->
22
+
if supported then begin
23
+
Fetch.fetch "/account/passkeys/login/options"
24
+
|> Js.Promise.then_ (fun response ->
25
+
if Fetch.Response.ok response then
26
+
Fetch.Response.json response
27
+
else Js.Exn.raiseError "Failed to get options" )
28
+
|> Js.Promise.then_ (fun options ->
29
+
currentOptions.current <- Some options ;
30
+
WebAuthn.startAuthentication
31
+
{optionsJSON= options; useBrowserAutofill= true} )
32
+
|> Js.Promise.then_ (fun credential ->
33
+
setPasskeyLoading (fun _ -> true) ;
34
+
let challenge =
35
+
match currentOptions.current with
36
+
| Some opts ->
37
+
Js.Dict.unsafeGet (Obj.magic opts) "challenge"
38
+
| None ->
39
+
Js.Json.string ""
40
+
in
41
+
let body =
42
+
Js.Json.object_
43
+
(Js.Dict.fromArray
44
+
[| ( "response"
45
+
, Js.Json.string (Js.Json.stringify credential) )
46
+
; ("challenge", challenge) |] )
47
+
in
48
+
Fetch.fetchWithInit
49
+
( "/account/passkeys/login/verify?redirect_url="
50
+
^ Js.Global.encodeURIComponent redirect_url )
51
+
(Fetch.RequestInit.make ~method_:Post
52
+
~body:(Fetch.BodyInit.make (Js.Json.stringify body))
53
+
~headers:
54
+
(Fetch.HeadersInit.makeWithArray
55
+
[|("Content-Type", "application/json")|] )
56
+
() ) )
57
+
|> Js.Promise.then_ (fun response ->
58
+
setPasskeyLoading (fun _ -> false) ;
59
+
if Fetch.Response.ok response then
60
+
Fetch.Response.json response
61
+
|> Js.Promise.then_ (fun json ->
62
+
let redirect =
63
+
Js.Dict.unsafeGet (Obj.magic json) "redirect"
64
+
|> Js.Json.decodeString
65
+
|> Option.value ~default:"/account"
66
+
in
67
+
Webapi.Dom.(Window.setLocation window redirect) ;
68
+
Js.Promise.resolve () )
69
+
else begin
70
+
setPasskeyError (fun _ ->
71
+
Some "Passkey authentication failed" ) ;
72
+
Js.Promise.resolve ()
73
+
end )
74
+
|> Js.Promise.catch (fun _ ->
75
+
(* user cancelled or error *)
76
+
Js.Promise.resolve () )
77
+
end
78
+
else Js.Promise.resolve () )
79
+
in
80
+
None )
81
+
in
12
82
<main className="w-full h-auto max-w-xs px-4 sm:px-0 my-auto">
13
83
<h1 className="text-2xl font-serif text-mana-200 mb-2">
14
84
(string "sign in")
···
18
88
</span>
19
89
<form className="w-full flex flex-col mt-4 mb-2 gap-y-2">
20
90
<input type_="hidden" name="dream.csrf" value=csrf_token />
21
-
<Input sr_only=true name="identifier" type_="text" label="identifier" />
91
+
<Input
92
+
sr_only=true
93
+
name="identifier"
94
+
type_="text"
95
+
label="identifier"
96
+
autoComplete="username webauthn"
97
+
/>
22
98
<Input sr_only=true name="password" type_="password" label="password" />
23
99
<input type_="hidden" name="redirect_url" value=redirect_url />
24
100
( match error with
25
-
| Some error ->
101
+
| Some err ->
26
102
<span className="inline-flex items-center text-phoenix-100 text-sm">
27
-
<CircleAlertIcon className="w-4 h-4 mr-2" /> (string error)
103
+
<CircleAlertIcon className="w-4 h-4 mr-2" /> (string err)
104
+
</span>
105
+
| None ->
106
+
null )
107
+
( match passkeyError with
108
+
| Some err ->
109
+
<span className="inline-flex items-center text-phoenix-100 text-sm">
110
+
<CircleAlertIcon className="w-4 h-4 mr-2" /> (string err)
28
111
</span>
29
112
| None ->
30
113
null )
31
114
<Button type_="submit" formMethod="post" className="mt-2">
32
-
(string "sign in")
115
+
(string (if passkeyLoading then "signing in..." else "sign in"))
33
116
</Button>
34
117
</form>
35
118
<span className="text-sm text-mist-100">
+1
package.json
+1
package.json
+1
pegasus.opam
+1
pegasus.opam
+30
-2
pegasus/lib/api/account_/index.ml
+30
-2
pegasus/lib/api/account_/index.ml
···
47
47
let email_change_pending = has_valid_email_change_code actor in
48
48
let pending_email = actor.pending_email in
49
49
let delete_pending = has_valid_delete_code actor in
50
+
let%lwt passkeys_raw =
51
+
Passkey.get_credentials_for_user ~did ctx.db
52
+
in
53
+
let passkeys =
54
+
List.map
55
+
(fun (pk : Passkey.Types.passkey) ->
56
+
Frontend.AccountPage.
57
+
{ id= pk.id
58
+
; name= pk.name
59
+
; created_at= pk.created_at
60
+
; last_used_at= pk.last_used_at } )
61
+
passkeys_raw
62
+
in
50
63
Util.render_html ~title:"Account"
51
64
(module Frontend.AccountPage)
52
65
~props:
···
65
78
; delete_pending
66
79
; error= None
67
80
; success= None
68
-
; delete_error= None } ) )
81
+
; delete_error= None
82
+
; passkeys } ) )
69
83
70
84
let post_handler =
71
85
Xrpc.handler (fun ctx ->
···
101
115
let email_change_pending = has_valid_email_change_code actor in
102
116
let pending_email = actor.pending_email in
103
117
let delete_pending = has_valid_delete_code actor in
118
+
let%lwt passkeys_raw =
119
+
Passkey.get_credentials_for_user ~did ctx.db
120
+
in
121
+
let passkeys =
122
+
List.map
123
+
(fun (pk : Passkey.Types.passkey) ->
124
+
Frontend.AccountPage.
125
+
{ id= pk.id
126
+
; name= pk.name
127
+
; created_at= pk.created_at
128
+
; last_used_at= pk.last_used_at } )
129
+
passkeys_raw
130
+
in
104
131
Util.render_html ~title:"Account"
105
132
(module Frontend.AccountPage)
106
133
~props:
···
119
146
; delete_pending
120
147
; error
121
148
; success
122
-
; delete_error }
149
+
; delete_error
150
+
; passkeys }
123
151
in
124
152
match%lwt Dream.form ctx.req with
125
153
| `Ok fields -> (
+136
pegasus/lib/api/account_/passkeys.ml
+136
pegasus/lib/api/account_/passkeys.ml
···
1
+
type passkey_info =
2
+
{ id: int
3
+
; name: string
4
+
; created_at: int
5
+
; last_used_at: int option [@default None] }
6
+
[@@deriving yojson {strict= false}]
7
+
8
+
type list_response = {passkeys: passkey_info list}
9
+
[@@deriving yojson {strict= false}]
10
+
11
+
type register_request =
12
+
{name: string [@default "Passkey"]; response: string; challenge: string}
13
+
[@@deriving yojson {strict= false}]
14
+
15
+
type auth_request = {response: string; challenge: string}
16
+
[@@deriving yojson {strict= false}]
17
+
18
+
type success_response = {success: bool; message: string option [@default None]}
19
+
[@@deriving yojson {strict= false}]
20
+
21
+
type login_success_response = {success: bool; redirect: string}
22
+
[@@deriving yojson {strict= false}]
23
+
24
+
type error_response = {error: string} [@@deriving yojson {strict= false}]
25
+
26
+
(* helper to get the current DID or return unauthorized *)
27
+
let with_current_did ctx f =
28
+
match%lwt Session.Raw.get_current_did ctx.Xrpc.req with
29
+
| None ->
30
+
Errors.auth_required "not authorized"
31
+
| Some did ->
32
+
f did
33
+
34
+
let list_handler =
35
+
Xrpc.handler (fun ctx ->
36
+
with_current_did ctx (fun did ->
37
+
let%lwt passkeys = Passkey.get_credentials_for_user ~did ctx.db in
38
+
let passkey_infos =
39
+
List.map
40
+
(fun (pk : Passkey.Types.passkey) ->
41
+
{ id= pk.id
42
+
; name= pk.name
43
+
; created_at= pk.created_at
44
+
; last_used_at= pk.last_used_at } )
45
+
passkeys
46
+
in
47
+
Dream.json @@ Yojson.Safe.to_string
48
+
@@ list_response_to_yojson {passkeys= passkey_infos} ) )
49
+
50
+
let register_options_handler =
51
+
Xrpc.handler (fun ctx ->
52
+
with_current_did ctx (fun did ->
53
+
match%lwt Data_store.get_actor_by_identifier did ctx.db with
54
+
| None ->
55
+
Errors.auth_required "user not found"
56
+
| Some actor ->
57
+
let%lwt existing = Passkey.get_credentials_for_user ~did ctx.db in
58
+
let%lwt options =
59
+
Passkey.generate_registration_options ~did ~email:actor.email
60
+
~existing_credentials:existing ctx.db
61
+
in
62
+
Dream.json @@ Yojson.Safe.to_string options ) )
63
+
64
+
let register_verify_handler =
65
+
Xrpc.handler (fun ctx ->
66
+
with_current_did ctx (fun did ->
67
+
let%lwt {challenge; response; name; _} =
68
+
Xrpc.parse_body ctx.req register_request_of_yojson
69
+
in
70
+
match%lwt Passkey.verify_registration ~challenge ~response ctx.db with
71
+
| Error msg ->
72
+
Errors.invalid_request msg
73
+
| Ok (credential_id, public_key) ->
74
+
let%lwt () =
75
+
Passkey.store_credential ~did ~credential_id ~public_key ~name
76
+
ctx.db
77
+
in
78
+
Dream.json @@ Yojson.Safe.to_string
79
+
@@ success_response_to_yojson
80
+
{success= true; message= Some "Passkey registered"} ) )
81
+
82
+
let login_options_handler =
83
+
Xrpc.handler (fun ctx ->
84
+
let%lwt options = Passkey.generate_authentication_options ctx.db in
85
+
Dream.json @@ Yojson.Safe.to_string options )
86
+
87
+
let login_verify_handler =
88
+
Xrpc.handler (fun ctx ->
89
+
let%lwt {challenge; response; _} =
90
+
Xrpc.parse_body ctx.req auth_request_of_yojson
91
+
in
92
+
match%lwt Passkey.verify_authentication ~challenge ~response ctx.db with
93
+
| Error msg ->
94
+
Errors.auth_required msg
95
+
| Ok did ->
96
+
let%lwt () = Session.log_in_did ctx.req did in
97
+
let redirect_url =
98
+
Dream.query ctx.req "redirect_url"
99
+
|> Option.value ~default:"/account"
100
+
in
101
+
Dream.json @@ Yojson.Safe.to_string
102
+
@@ login_success_response_to_yojson
103
+
{success= true; redirect= redirect_url} )
104
+
105
+
let delete_handler =
106
+
Xrpc.handler (fun ctx ->
107
+
with_current_did ctx (fun did ->
108
+
let id_str = Dream.param ctx.req "id" in
109
+
match int_of_string_opt id_str with
110
+
| None ->
111
+
Errors.invalid_request "invalid passkey id"
112
+
| Some id ->
113
+
let%lwt _ = Passkey.delete_credential ~id ~did ctx.db in
114
+
Dream.json @@ Yojson.Safe.to_string
115
+
@@ success_response_to_yojson
116
+
{success= true; message= Some "Passkey deleted"} ) )
117
+
118
+
let rename_handler =
119
+
Xrpc.handler (fun ctx ->
120
+
with_current_did ctx (fun did ->
121
+
let id_str = Dream.param ctx.req "id" in
122
+
match int_of_string_opt id_str with
123
+
| None ->
124
+
Errors.invalid_request "invalid passkey id"
125
+
| Some id -> (
126
+
let%lwt body = Dream.body ctx.req in
127
+
let json = Yojson.Safe.from_string body in
128
+
match Yojson.Safe.Util.member "name" json with
129
+
| `String name ->
130
+
let%lwt _success =
131
+
Passkey.rename_credential ~id ~did ~name ctx.db
132
+
in
133
+
Dream.json @@ Yojson.Safe.to_string
134
+
@@ success_response_to_yojson {success= true; message= None}
135
+
| _ ->
136
+
Errors.invalid_request "missing name field" ) ) )
+1
pegasus/lib/dune
+1
pegasus/lib/dune
+30
pegasus/lib/migrations/data_store/005_passkeys.sql
+30
pegasus/lib/migrations/data_store/005_passkeys.sql
···
1
+
CREATE TABLE IF NOT EXISTS passkeys (
2
+
id INTEGER PRIMARY KEY,
3
+
did TEXT NOT NULL,
4
+
credential_id TEXT NOT NULL UNIQUE,
5
+
public_key BLOB NOT NULL,
6
+
sign_count INTEGER NOT NULL DEFAULT 0,
7
+
name TEXT NOT NULL DEFAULT 'Passkey',
8
+
created_at INTEGER NOT NULL,
9
+
last_used_at INTEGER,
10
+
FOREIGN KEY (did) REFERENCES actors(did) ON DELETE CASCADE
11
+
);
12
+
13
+
CREATE INDEX IF NOT EXISTS passkeys_did_idx ON passkeys(did);
14
+
CREATE INDEX IF NOT EXISTS passkeys_credential_id_idx ON passkeys(credential_id);
15
+
16
+
CREATE TABLE IF NOT EXISTS passkey_challenges (
17
+
challenge TEXT PRIMARY KEY,
18
+
did TEXT,
19
+
challenge_type TEXT NOT NULL,
20
+
expires_at INTEGER NOT NULL,
21
+
created_at INTEGER NOT NULL
22
+
);
23
+
24
+
CREATE INDEX IF NOT EXISTS passkey_challenges_expires_idx ON passkey_challenges(expires_at);
25
+
26
+
CREATE TRIGGER IF NOT EXISTS cleanup_expired_passkey_challenges
27
+
AFTER INSERT ON passkey_challenges
28
+
BEGIN
29
+
DELETE FROM passkey_challenges WHERE expires_at < unixepoch() * 1000;
30
+
END;
+368
pegasus/lib/passkey.ml
+368
pegasus/lib/passkey.ml
···
1
+
open Util.Rapper
2
+
3
+
let challenge_expiry_ms = 5 * 60 * 1000
4
+
5
+
module Types = struct
6
+
type passkey =
7
+
{ id: int
8
+
; did: string
9
+
; credential_id: string
10
+
; public_key: bytes
11
+
; sign_count: int
12
+
; name: string
13
+
; created_at: int
14
+
; last_used_at: int option }
15
+
16
+
type challenge =
17
+
{ challenge: string
18
+
; did: string option
19
+
; challenge_type: string
20
+
; expires_at: int
21
+
; created_at: int }
22
+
23
+
type passkey_display =
24
+
{ id: int
25
+
; name: string
26
+
; created_at: int
27
+
; last_used_at: int option [@default None] }
28
+
[@@deriving yojson {strict= false}]
29
+
end
30
+
31
+
open Types
32
+
33
+
module Queries = struct
34
+
let insert_passkey =
35
+
[%rapper
36
+
execute
37
+
{sql| INSERT INTO passkeys (did, credential_id, public_key, sign_count, name, created_at)
38
+
VALUES (%string{did}, %string{credential_id}, %Blob{public_key}, %int{sign_count}, %string{name}, %int{created_at})
39
+
|sql}]
40
+
41
+
let get_passkeys_by_did did =
42
+
[%rapper
43
+
get_many
44
+
{sql| SELECT @int{id}, @string{did}, @string{credential_id}, @Blob{public_key},
45
+
@int{sign_count}, @string{name}, @int{created_at}, @int?{last_used_at}
46
+
FROM passkeys WHERE did = %string{did}
47
+
ORDER BY created_at DESC
48
+
|sql}
49
+
record_out]
50
+
did
51
+
52
+
let get_passkey_by_credential_id credential_id =
53
+
[%rapper
54
+
get_opt
55
+
{sql| SELECT @int{id}, @string{did}, @string{credential_id}, @Blob{public_key},
56
+
@int{sign_count}, @string{name}, @int{created_at}, @int?{last_used_at}
57
+
FROM passkeys WHERE credential_id = %string{credential_id}
58
+
|sql}
59
+
record_out]
60
+
credential_id
61
+
62
+
let update_passkey_sign_count =
63
+
[%rapper
64
+
execute
65
+
{sql| UPDATE passkeys SET sign_count = %int{sign_count}, last_used_at = %int{last_used_at}
66
+
WHERE credential_id = %string{credential_id}
67
+
|sql}]
68
+
69
+
let delete_passkey =
70
+
[%rapper
71
+
execute
72
+
{sql| DELETE FROM passkeys WHERE id = %int{id} AND did = %string{did}
73
+
|sql}]
74
+
75
+
let rename_passkey =
76
+
[%rapper
77
+
execute
78
+
{sql| UPDATE passkeys SET name = %string{name} WHERE id = %int{id} AND did = %string{did}
79
+
|sql}]
80
+
81
+
let insert_challenge =
82
+
[%rapper
83
+
execute
84
+
{sql| INSERT INTO passkey_challenges (challenge, did, challenge_type, expires_at, created_at)
85
+
VALUES (%string{challenge}, %string?{did}, %string{challenge_type}, %int{expires_at}, %int{created_at})
86
+
|sql}]
87
+
88
+
let get_challenge challenge now =
89
+
[%rapper
90
+
get_opt
91
+
{sql| SELECT @string{challenge}, @string?{did}, @string{challenge_type},
92
+
@int{expires_at}, @int{created_at}
93
+
FROM passkey_challenges
94
+
WHERE challenge = %string{challenge} AND expires_at > %int{now}
95
+
|sql}
96
+
record_out]
97
+
~challenge ~now
98
+
99
+
let delete_challenge =
100
+
[%rapper
101
+
execute
102
+
{sql| DELETE FROM passkey_challenges WHERE challenge = %string{challenge}
103
+
|sql}]
104
+
end
105
+
106
+
let webauthn_instance : Webauthn.t option ref = ref None
107
+
108
+
let webauthn () =
109
+
match !webauthn_instance with
110
+
| Some t ->
111
+
t
112
+
| None -> (
113
+
match Webauthn.create Env.host_endpoint with
114
+
| Ok t ->
115
+
webauthn_instance := Some t ;
116
+
t
117
+
| Error msg ->
118
+
failwith ("Failed to initialize WebAuthn: " ^ msg) )
119
+
120
+
let serialize_pubkey (pk : Mirage_crypto_ec.P256.Dsa.pub) : bytes =
121
+
Bytes.of_string (Mirage_crypto_ec.P256.Dsa.pub_to_octets pk)
122
+
123
+
let deserialize_pubkey (b : bytes) : Mirage_crypto_ec.P256.Dsa.pub option =
124
+
Mirage_crypto_ec.P256.Dsa.pub_of_octets (Bytes.to_string b)
125
+
|> Result.to_option
126
+
127
+
let create_challenge ?did ~challenge_type db =
128
+
let _challenge_obj, challenge_b64 = Webauthn.generate_challenge () in
129
+
let now = Util.now_ms () in
130
+
let expires_at = now + challenge_expiry_ms in
131
+
let challenge_type_str =
132
+
match challenge_type with
133
+
| `Register ->
134
+
"register"
135
+
| `Authenticate ->
136
+
"authenticate"
137
+
in
138
+
let%lwt () =
139
+
Util.use_pool db
140
+
@@ Queries.insert_challenge ~challenge:challenge_b64 ~did
141
+
~challenge_type:challenge_type_str ~expires_at ~created_at:now
142
+
in
143
+
Lwt.return challenge_b64
144
+
145
+
let verify_challenge ~challenge ~challenge_type db =
146
+
let now = Util.now_ms () in
147
+
let expected_type =
148
+
match challenge_type with
149
+
| `Register ->
150
+
"register"
151
+
| `Authenticate ->
152
+
"authenticate"
153
+
in
154
+
match%lwt Util.use_pool db @@ Queries.get_challenge challenge now with
155
+
| Some c when c.challenge_type = expected_type ->
156
+
Lwt.return_some c
157
+
| _ ->
158
+
Lwt.return_none
159
+
160
+
let delete_challenge ~challenge db =
161
+
Util.use_pool db @@ Queries.delete_challenge ~challenge
162
+
163
+
let store_credential ~did ~credential_id ~public_key ~name db =
164
+
let now = Util.now_ms () in
165
+
Util.use_pool db
166
+
@@ Queries.insert_passkey ~did ~credential_id ~public_key ~sign_count:0 ~name
167
+
~created_at:now
168
+
169
+
let get_credentials_for_user ~did db =
170
+
Util.use_pool db @@ Queries.get_passkeys_by_did ~did
171
+
172
+
let get_credential_by_id ~credential_id db =
173
+
Util.use_pool db @@ Queries.get_passkey_by_credential_id ~credential_id
174
+
175
+
let update_sign_count ~credential_id ~sign_count db =
176
+
let now = Util.now_ms () in
177
+
Util.use_pool db
178
+
@@ Queries.update_passkey_sign_count ~credential_id ~sign_count
179
+
~last_used_at:now
180
+
181
+
let delete_credential ~id ~did db =
182
+
let%lwt () = Util.use_pool db @@ Queries.delete_passkey ~id ~did in
183
+
Lwt.return_true
184
+
185
+
let rename_credential ~id ~did ~name db =
186
+
let%lwt () = Util.use_pool db @@ Queries.rename_passkey ~id ~did ~name in
187
+
Lwt.return_true
188
+
189
+
let generate_registration_options ~did ~email ~existing_credentials db =
190
+
let%lwt challenge = create_challenge ~did ~challenge_type:`Register db in
191
+
let exclude_credentials =
192
+
List.map
193
+
(fun (pk : passkey) ->
194
+
`Assoc
195
+
[ ("id", `String pk.credential_id)
196
+
; ("type", `String "public-key")
197
+
; ("transports", `List [`String "internal"; `String "hybrid"]) ] )
198
+
existing_credentials
199
+
in
200
+
let user_id =
201
+
Base64.(encode_string ~alphabet:uri_safe_alphabet ~pad:false did)
202
+
in
203
+
Lwt.return
204
+
@@ `Assoc
205
+
[ ("challenge", `String challenge)
206
+
; ( "rp"
207
+
, `Assoc [("name", `String "Pegasus PDS"); ("id", `String Env.hostname)]
208
+
)
209
+
; ( "user"
210
+
, `Assoc
211
+
[ ("id", `String user_id)
212
+
; ("name", `String email)
213
+
; ("displayName", `String email) ] )
214
+
; ( "pubKeyCredParams"
215
+
, `List [`Assoc [("alg", `Int (-7)); ("type", `String "public-key")]]
216
+
)
217
+
; ("timeout", `Int 300000)
218
+
; ("attestation", `String "none")
219
+
; ("excludeCredentials", `List exclude_credentials)
220
+
; ( "authenticatorSelection"
221
+
, `Assoc
222
+
[ ("residentKey", `String "preferred")
223
+
; ("userVerification", `String "preferred") ] ) ]
224
+
225
+
let verify_registration ~challenge ~response db =
226
+
match%lwt verify_challenge ~challenge ~challenge_type:`Register db with
227
+
| None ->
228
+
Lwt.return_error "Invalid or expired challenge"
229
+
| Some _ -> (
230
+
let%lwt () = delete_challenge ~challenge db in
231
+
let credential_json = Yojson.Safe.from_string response in
232
+
let credential_id =
233
+
match credential_json with
234
+
| `Assoc fields -> (
235
+
match List.assoc_opt "id" fields with
236
+
| Some (`String id) ->
237
+
id
238
+
| _ ->
239
+
"" )
240
+
| _ ->
241
+
""
242
+
in
243
+
let inner_response =
244
+
match credential_json with
245
+
| `Assoc fields -> (
246
+
match List.assoc_opt "response" fields with
247
+
| Some (`Assoc r) -> (
248
+
match
249
+
(* need to extract these fields only, extra fields will cause
250
+
register_response_of_string to error *)
251
+
( List.assoc_opt "attestationObject" r
252
+
, List.assoc_opt "clientDataJSON" r )
253
+
with
254
+
| Some ao, Some cd ->
255
+
Yojson.Safe.to_string
256
+
(`Assoc [("attestationObject", ao); ("clientDataJSON", cd)])
257
+
| _ ->
258
+
"" )
259
+
| _ ->
260
+
"" )
261
+
| _ ->
262
+
""
263
+
in
264
+
if String.length credential_id = 0 || String.length inner_response = 0
265
+
then Lwt.return_error "invalid credential format"
266
+
else
267
+
match Webauthn.register_response_of_string inner_response with
268
+
| Error e ->
269
+
let err = Format.asprintf "%a" Webauthn.pp_error e in
270
+
Lwt.return_error ("invalid registration response: " ^ err)
271
+
| Ok reg_response -> (
272
+
match Webauthn.register (webauthn ()) reg_response with
273
+
| Error e ->
274
+
let err = Format.asprintf "%a" Webauthn.pp_error e in
275
+
Lwt.return_error ("registration verification failed: " ^ err)
276
+
| Ok (_returned_challenge, registration) ->
277
+
let public_key =
278
+
serialize_pubkey
279
+
registration.attested_credential_data.public_key
280
+
in
281
+
Lwt.return_ok (credential_id, public_key) ) )
282
+
283
+
let generate_authentication_options ?did:_did db =
284
+
let%lwt challenge = create_challenge ~challenge_type:`Authenticate db in
285
+
(* for conditional UI, we use empty allowCredentials *)
286
+
Lwt.return
287
+
@@ `Assoc
288
+
[ ("challenge", `String challenge)
289
+
; ("timeout", `Int 300000)
290
+
; ("rpId", `String Env.hostname)
291
+
; ("userVerification", `String "preferred")
292
+
; ("allowCredentials", `List []) ]
293
+
294
+
let verify_authentication ~challenge ~response db =
295
+
match%lwt verify_challenge ~challenge ~challenge_type:`Authenticate db with
296
+
| None ->
297
+
Lwt.return_error "invalid or expired challenge"
298
+
| Some _ -> (
299
+
let%lwt () = delete_challenge ~challenge db in
300
+
let credential_json = Yojson.Safe.from_string response in
301
+
let credential_id =
302
+
match credential_json with
303
+
| `Assoc fields -> (
304
+
match List.assoc_opt "id" fields with
305
+
| Some (`String id) ->
306
+
id
307
+
| _ ->
308
+
"" )
309
+
| _ ->
310
+
""
311
+
in
312
+
let inner_response =
313
+
match credential_json with
314
+
| `Assoc fields -> (
315
+
match List.assoc_opt "response" fields with
316
+
| Some (`Assoc r) -> (
317
+
match
318
+
(* need to extract these fields only, extra fields will cause
319
+
register_response_of_string to error *)
320
+
( List.assoc_opt "authenticatorData" r
321
+
, List.assoc_opt "clientDataJSON" r
322
+
, List.assoc_opt "signature" r
323
+
, List.assoc_opt "userHandle" r )
324
+
with
325
+
| Some ad, Some cd, Some sgn, uh ->
326
+
Yojson.Safe.to_string
327
+
(`Assoc
328
+
( ( match uh with
329
+
| Some uh ->
330
+
[("userHandle", uh)]
331
+
| None ->
332
+
[] )
333
+
@ [ ("authenticatorData", ad)
334
+
; ("clientDataJSON", cd)
335
+
; ("signature", sgn) ] ) )
336
+
| _ ->
337
+
"" )
338
+
| _ ->
339
+
"" )
340
+
| _ ->
341
+
""
342
+
in
343
+
if String.length credential_id = 0 || String.length inner_response = 0
344
+
then Lwt.return_error "invalid credential format"
345
+
else
346
+
match Webauthn.authenticate_response_of_string inner_response with
347
+
| Error _ ->
348
+
Lwt.return_error "invalid authentication response"
349
+
| Ok auth_response -> (
350
+
match%lwt get_credential_by_id ~credential_id db with
351
+
| None ->
352
+
Lwt.return_error "unknown credential"
353
+
| Some passkey -> (
354
+
match deserialize_pubkey passkey.public_key with
355
+
| None ->
356
+
Lwt.return_error "invalid stored public key"
357
+
| Some pubkey -> (
358
+
match
359
+
Webauthn.authenticate (webauthn ()) pubkey auth_response
360
+
with
361
+
| Error _ ->
362
+
Lwt.return_error "authentication verification failed"
363
+
| Ok (_returned_challenge, auth) ->
364
+
let sign_count = Int32.to_int auth.sign_count in
365
+
let%lwt () =
366
+
update_sign_count ~credential_id ~sign_count db
367
+
in
368
+
Lwt.return_ok passkey.did ) ) ) )
+16
pnpm-lock.yaml
+16
pnpm-lock.yaml
···
11
11
'@pedrobslisboa/react-client':
12
12
specifier: ^19.1.0
13
13
version: 19.1.0(react@19.2.0)
14
+
'@simplewebauthn/browser':
15
+
specifier: ^11.0.0
16
+
version: 11.0.0
14
17
react:
15
18
specifier: ^19.2.0
16
19
version: 19.2.0
···
932
935
resolution: {integrity: sha512-k+/Rkcyx//P6fetPoLMb8pBeqJBNGx81uuf7iljX9++yNBVRDQgD04L+SVXmXmh5ZP4/WOp4mWF0kmi06PW2tA==}
933
936
cpu: [x64]
934
937
os: [win32]
938
+
939
+
'@simplewebauthn/browser@11.0.0':
940
+
resolution: {integrity: sha512-KEGCStrl08QC2I561BzxqGiwoknblP6O1YW7jApdXLPtIqZ+vgJYAv8ssLCdm1wD8HGAHd49CJLkUF8X70x/pg==}
941
+
942
+
'@simplewebauthn/types@11.0.0':
943
+
resolution: {integrity: sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==}
944
+
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
935
945
936
946
'@swc/helpers@0.5.17':
937
947
resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==}
···
2364
2374
2365
2375
'@rollup/rollup-win32-x64-msvc@4.53.2':
2366
2376
optional: true
2377
+
2378
+
'@simplewebauthn/browser@11.0.0':
2379
+
dependencies:
2380
+
'@simplewebauthn/types': 11.0.0
2381
+
2382
+
'@simplewebauthn/types@11.0.0': {}
2367
2383
2368
2384
'@swc/helpers@0.5.17':
2369
2385
dependencies: