objective categorical abstract machine language personal data server

Add passkey support

futur.blue 7742b350 f0f3cfa4

verified
Changed files
+924 -13
bin
frontend
pegasus
lib
api
migrations
data_store
+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
··· 77 77 (ppx_deriving_yojson (>= 3.9.1)) 78 78 ppx_rapper 79 79 ppx_rapper_lwt 80 + (webauthn (>= 0.2.0)) 80 81 (alcotest :with-test) 81 82 crunch 82 83 (ocamlformat-mlx :with-dev-setup)
+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
··· 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
··· 7 7 server-reason-react.react 8 8 server-reason-react.reactDom 9 9 dream 10 + melange-fetch 10 11 melange-json-native 11 12 melange-webapi) 12 13 (preprocess
+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
··· 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
··· 10 10 "author": "", 11 11 "license": "ISC", 12 12 "dependencies": { 13 + "@simplewebauthn/browser": "^11.0.0", 13 14 "@pedrobslisboa/react-client": "^19.1.0", 14 15 "react": "^19.2.0", 15 16 "react-aria-components": "^1.13.0",
+1
pegasus.opam
··· 33 33 "ppx_deriving_yojson" {>= "3.9.1"} 34 34 "ppx_rapper" 35 35 "ppx_rapper_lwt" 36 + "webauthn" {>= "0.2.0"} 36 37 "alcotest" {with-test} 37 38 "crunch" 38 39 "ocamlformat-mlx" {with-dev-setup}
+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
··· 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
··· 27 27 timedesc 28 28 uri 29 29 uuidm 30 + webauthn 30 31 yojson 31 32 hermes_ppx 32 33 lwt_ppx
+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
··· 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
··· 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: