Skip to content

tfiedlerdejanze/webauthn-rails-example

Repository files navigation

webauthn-rails-example

This is a minimal example demoing the WebAuthn API with ruby-on-rails. While the client-side part takes minimal advantage of rails default webpack configuration, it is by intention not bound to any framework and tries to be easily understandable for everyone. We will not find clean nor DRY code in this repository :)

See the demo

prerequisites

  • rails 5 or 6 installed
  • up to date chrome or firefox browser
  • basic knowledge of javascript
  • basic knowledge of ruby
  • authenticator (fido2 key or fingerprint scanner)

Run this app

  • bundle install
  • bundle exec rails db:create
  • bundle exec rails db:migrate
  • bundle exec rails webpacker:install
  • bundle exec rails s
  • visit http://localhost:3000

Workshop Chapters

1) Create a rails app & install dependencies. view commit
$ rails new webauthn-rails-example
$ cd webauthn-rails-example
$ bundle add webauthn
$ yarn add @github/webauthn-json

Create config/initializers/webauthn.rb with following required configuration:

WebAuthn.configure do |config|
  config.origin = ENV["WEBAUTHN_ORIGIN"] || "http://localhost:3000"
  config.rp_name = "WebAuthn Example"
end

2) Add a User model and add simple validation. view commit
$ rails g model User username user_id:binary public_key:binary sign_count:integer

Edit app/models/user.rb so it looks similar to:

class User < ApplicationRecord
  validates :username, uniqueness: true, presence: true
end

3) Create a controller for the user registration. view commit
$ rails g controller RegistrationsController new

Edit app/controllers/registrations_controller.rb and add the two endpoints we'll need for the registration. One prepares the WebAuthn credential challenge and options, the other one validates the authenticity of the request with the public key that is stored on the authenticator and creates the user record. Here is a minimal version of the controller, for a more detailed one, see the commit above or check the demo file.

class RegistrationsController < ApplicationController
  def prepare
    options = WebAuthn::Credential.options_for_create(
      user: {
        id: WebAuthn.generate_user_id,
        name: params[:username]
      }
    )

    session[:registration_challenge] = options.challenge

    render json: options
  end

  def create
    c = WebAuthn::Credential.from_create(params[:registration])
    c.verify(session[:registration_challenge])

    User.create({
      username: params[:username],
      user_id: c.id,
      public_key: c.public_key,
      sign_count: c.sign_count,
    })
    
    render json: {message: "Registration success"}
  end
end

4) Create a form for the user registration. view commit

Edit app/views/registrations/new.html.erb. As we dont ask for much typed input from the user, this one is short. Feel free to throw in some divs or css to make it look bearable or use the stylesheets from the demo.

<h1>Register</h1>

<%= form_with scope: :registration, id: "registration-form" do |form| %>
  <%= form.label :username, "Enter a username:" %>
  <%= form.text_field :username, required: true, id: "username-field", autocomplete: 'off' %>
  <%= form.submit "Register" %>
<% end %>

5) Handle the form submit. view commit

It's time to consume our previously created endpoints, so we'll create a file app/javascripts/packs/registration.js in which we'll handle the form submission. We first need to request the credential options and challenge providing only a username and sign those with an authenticatior connected via the navigator browser api. The result of this process is sent back to the registration endpoint, verified by the initial challenge and a user record gets created. In JavaScript words this is quite a lot, so we try to keep it minimal again and you can check out the commit above or view the demo file. Now is also time to note that the npm package @github/webauthn-json we use here, is a WebAuthn API wrapper that translates to/from pure JSON using base64url which essentially wraps the navigator.credentials.{get, create} api for us and saves us from manually encoding the credential parameters.

We added some comments in places where you might want to check the result of a request or give the user some feedback on what is happening.

import { create } from "@github/webauthn-json";

const getHeaders = () => {
  "accept": "application/json",
  "content-type": "application/json",
  "x-csrf-token": document.querySelector('meta[name="csrf-token"]')
                          .getAttribute('content'),
}

const RegistrationForm = form => {
  const register = async () => {
    const headers = getJsonHeaders();
    const username = document.getElementById("username-field");

    const preparePayload = { registration: { username: username.value } };

    const prepareRequest = await fetch("/register/prepare", {
      method: "POST",
      credentials: "same-origin",
      body: JSON.stringify(preparePayload),
      headers
    });
	
    // here
	
    const webauthnPayload = await create({
      publicKey: await prepareRequest.json()
    });
    
    const payload = Object.assign({}, webauthnPayload, {username: username.value})

    const registerRequest = await fetch("/register", {
      method: "POST",
      credentials: "same-origin",
      body: JSON.stringify(payload),
      headers
    });

    // here
  };

  form.addEventListener("submit", function(e) {
    e.preventDefault();
    register();
  });
};

window.onload = function() {
  const form = document.getElementById("registration-form");
  if (form) {
    RegistrationForm(form);
  }
};

6) Configure routes and load assets. view commit

To make this hopefully understandable mess from the previous step work, we must do a couple of things, like mapping our controller actions to actual routes, load the javascript file we added and remove turbolinks which are a bit in the way of our straight forward client side implementation.

Edit config/routes.rb so it looks like this:

Rails.application.routes.draw do
  get '/register/new', to: 'registrations#new'

  post '/register/prepare', to: 'registrations#prepare'
  post '/register', to: 'registrations#create'
  
  root 'registrations#new'
end

Edit app/javascript/packs/application.js, remove the imports that are there and load our javascript pack:

require('packs/registration');

Now is definitely the time to start the rails server and test out the form.

$ rails s

Feel free to make some improvements where you feel it's necessary or copy some details from the demo. For example validate the incoming user input, return eventual errors and handle them on the client side.

7) Adding the login form. view commit

The functionality and requirements of the login form are quite similar to the one we just added for the registration. The big difference now, is that instead of doing a create and verifying WebAuthn credentials in the browser and server side of our app, we now want to get and verify our previously created credentials from information that the user provides: first only the username, then the information that is stored on the authenticator.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages