/reflex-webauthn

Reflex based implementation of webauthn

Primary LanguageHaskell

reflex-webauthn

Setup

This repo contains three packages: reflex-webauthn-common, reflex-webauthn-frontend and reflex-webauthn-backend.

An example usage of these packages is available in the example directory.

To add these packages to your obelisk project, follow the steps below from your obelisk project root (i.e., the folder you ran ob init in).

Switch Obelisk to 8.10 version

reflex-webauthn uses GHC 8.10 due to its dependencies, so you will need to use the same.

Inside your obelisk project root, ensure that .obelisk/impl/github.json contains the following:

{
  "owner": "obsidiansystems",
  "repo": "obelisk",
  "branch": "ll/ghc8_10-wip",
  "private": false,
  "rev": "f0e87e0905ff7800e0e0dc4921a70f5aaf097235",
  "sha256": "11i6d7ps3slxrkzs1q4qm5jzhvhacmfbrcwb0xph27swir6l97n4"
}

Add dependency thunk

$ mkdir dep
$ cd dep
$ git clone git@github.com:obsidiansystems/reflex-webauthn.git
$ ob thunk pack reflex-webauthn

The last step here (ob thunk pack) replaces the cloned repository with a "thunk" that contains all the information obelisk needs to fetch/use the repository when needed.

Check out ob thunk --help to learn more about working with thunks.

Add packages to default.nix

Your skeleton project's default.nix uses the reflex-platform project infrastructure. We can use the packages field of the project configuration to add our custom packages, and overrides field to override packages required by reflex-webauthn, as follows:

project ./. ({ hackGet, ... }: {
  overrides = import ((hackGet ./dep/reflex-webauthn) + "/backend") { obelisk = obelisk; pkgs = pkgs; };
  packages = {
    reflex-webauthn-common = (hackGet ./dep/reflex-webauthn) + "/common";
    reflex-webauthn-frontend = (hackGet ./dep/reflex-webauthn) + "/frontend";
    reflex-webauthn-backend = (hackGet ./dep/reflex-webauthn) + "/backend";
    ... # other configuration goes here
  };
})

Be sure to add hackGet to the list of items to bring into scope. hackGet is a nix function defined in reflex-platform that takes a path that points to either a source directory or a packed thunk (in other words, it takes a path to a thunk but doesn't care whether it's packed or unpacked). It produces a path to the source (unpacked if necessary). Once we've got that path, we just need to append the subdirectory paths to the individual repos contained in this repository.

Add packages to cabal files

common/common.cabal

Add reflex-webauthn-common to the build-depends field of common/common.cabal in library stanza.

library
  ... 
  build-depends:
    ...
    , reflex-webauthn-common
    ...
  ...

frontend/frontend.cabal

Add reflex-webauthn-common and reflex-webauthn-frontend to the build-depends field of frontend/frontend.cabal in library stanza.

library
  ...
  build-depends: 
    ...
    , reflex-webauthn-common
    , reflex-webauthn-frontend
    ...
  ...

backend/backend.cabal

library
  ...
  build-depends:
    ...
    , reflex-webauthn-backend
    , webauthn
    ...
  ...

Finally, add reflex-webauthn-backend and webauthn to the build-depends field of the library stanza in backend/backend.cabal.

Adding WebAuthnRoute to your own routes (Common.Route)

Add a route for webauthn in your backend routes

data BackendRoute :: * -> * where
  ...
  BackendRoute_WebAuthn :: BackendRoute (R WebAuthnRoute)
  ...

Add handling for this new route in your backend encoder

...
yourOwnEncoder $ \case
  ...
  BackendRoute_WebAuthn -> PathSegment webAuthnBaseUrl webauthnRouteEncoder)
  ...
...

Frontend

On the frontend, use setupRegisterWorkflow and setupLoginWorkflow like the example.

do
  -- Create an input element for receiving username
  textDyn <- _inputElement_value <$> inputElement (def
    & inputElementConfig_elementConfig . elementConfig_initialAttributes
      .~ "placeholder" =: "Enter username"
    )

  -- Create two buttons, one for login, one for registration
  (eRegister, eLogin) <- el "div" $ do
    (e1, _) <- el' "button" $ text "Register"
    (e2, _) <- el' "button" $ text "Login"
    pure (e1, e2)
  let
    textRegisterEv = tag (current textDyn) $ domEvent Click eRegister
    textLoginEv = tag (current textDyn) $ domEvent Click eLogin

  registerEv <- setupRegisterWorkflow webAuthnBaseUrl textRegisterEv
  loginEv <- setupLoginWorkflow webAuthnBaseUrl textLoginEv

  let
    greenText = divClass "correct" . text
    redText = divClass "error" . text . pack . show
    finalEv = either redText greenText <$> leftmost [registerEv, loginEv]

  void $ el "h1" $ widgetHold blank finalEv

Backend

On the backend, use withWebAuthnBackend to match webauthn routes.

import qualified Crypto.WebAuthn as WA
...

...
backend = Backend
  { _backend_run = withWebAuthnBackend $ \webAuthnRouteHandler -> \case
      ...
      BackendRoute_WebAuthn :/ webAuthnRoute -> webAuthnRouteHandler modifyRegCredOpts modifyLoginCredOpts webAuthnRoute
      ...
  , _backend_routeEncoder = ...
  }
  where
    modifyRegCredOpts :: ModifyCredentialOptionsRegistration
    modifyRegCredOpts regCredOpts = regCredOpts
      { WA.corRp =
        (WA.corRp regCredOpts)
          { WA.creName = "Example"
          }
      }

    modifyLoginCredOpts :: ModifyCredentialOptionsAuthentication
    modifyLoginCredOpts loginCredOpts = loginCredOpts { WA.coaTimeout = Just $ WA.Timeout 10000 }