DEV Community

Cover image for Creating Passkey Authentication in a Rails 7 Application
Royce Threadgill for The Gnar Company

Posted on • Originally published at thegnar.com

Creating Passkey Authentication in a Rails 7 Application

If you're looking to allow your users to log into your Rails 7 app with passkeys, well searched.

This article is specifically about passkeys, which are a form of passwordless login that replaces passwords with secure, per-device keys (the eponymous passkeys) which are handled by the device's operating system.

As of this writing, up-to-date Windows, macOS, iOS, and Android all support passkeys. Linux users with the correct browser or browser extension can also use passkeys.

This widespread support is due to the standard called FIDO2, which covers two components: WebAuthn and CTAP2. WebAuthn handles communication between a relying party (your app) and a credential provider (we'll use Bitwarden's Passwordless.dev), while CTAP2 handles key management and communication between the browser and the operating system.

We won't need to worry about either of these standards, since using Bitwarden as a passkey provider means we can use their open-source library to handle the details.

Rails 7 Passkey Authentication Example

Now that we have a concept for what passkey auth is and how it works, let's implement it in a Rails app. You can find the repo for our example at https://github.com/JackVCurtis/devise-passkeys-example.

We are going to implement a passwordless strategy using Devise. For the uninitiated: Devise is an authentication gem commonly used in Rails applications. It provides several off-the-shelf strategies which abstract the core concepts typically encountered in conventional web authentication.

The core functionality of Devise focuses on the username/password authentication paradigm. Here we will add a custom strategy to Devise using passkeys rather than username/password.

As we mentioned earlier, Bitwarden (specifically Passwordless.dev) is a passkey provider which manages both sides of the passkey management flow (WebAuthn and CTAP2). The first step of our process is to create an account and an application with Passwordless.dev.

Passwordless.dev setup screen

Passwordless.dev dashboard

You will be given three values: An API URL, an API private key, and an API public key. Create a .env file and provide these values:

BITWARDEN_PASSWORDLESS_API_URL=
BITWARDEN_PASSWORDLESS_API_PRIVATE_KEY=
BITWARDEN_PASSWORDLESS_API_PUBLIC_KEY=
Enter fullscreen mode Exit fullscreen mode

Set up your database and run the app locally:

rails db:create
rails db:migrate
rails s
Enter fullscreen mode Exit fullscreen mode

Install Devise and associate it with a User model. I recommend removing the password field before running the migration; otherwise Devise will create an automatic presence validation on the password, which we will not be using.

The migration file that Devise generates, after we have removed the password field, should look something like this:

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users, id: :uuid do |t|
   ## Passkey authenticatable
      t.string :email,              null: false, default: ""

   ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      t.integer  :sign_in_count, default: 0, null: false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.string   :current_sign_in_ip
      t.string   :last_sign_in_ip

      ## Confirmable
      t.string :unconfirmed_email
      t.string   :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at

      t.timestamps null: false
    end

    add_index :users, :email,                unique: true
    add_index :users, :confirmation_token,   unique: true
  end
end
Enter fullscreen mode Exit fullscreen mode

Install the Bitwarden Passwordless.dev JS client using your choice of package manager. The example uses import-maps.

Add our custom passkey Devise module and strategy under the lib/devise directory.

lib/devise/models/passkey_authenticatable.rb:

require Rails.root.join('lib/devise/strategies/passkey_authenticatable')

module Devise  
    module Models  
        module PasskeyAuthenticatable  
            extend ActiveSupport::Concern  
        end  
    end
end
Enter fullscreen mode Exit fullscreen mode

lib/devise/strategies/passkey_authenticatable.rb:

module Devise
  module Strategies
    class PasskeyAuthenticatable &lt; Authenticatable
      def valid?
        params[:token]
      end

      def authenticate!
        token = params[:token]
        res = Excon.post(ENV['BITWARDEN_PASSWORDLESS_API_URL'] + '/signin/verify', 
          body: JSON.generate({
            token: token
          }),
          headers: {
            "ApiSecret" =&gt; ENV["BITWARDEN_PASSWORDLESS_API_PRIVATE_KEY"],
            "Content-Type" =&gt; "application/json"
          }
        )

      json = JSON.parse(res.body)
      if json["success"]
        success!(User.find(json["userId"]))
      else
        fail!(:invalid_login)
      end
    end
  end
end

Warden::Strategies.add(:passkey_authenticatable, Devise::Strategies::PasskeyAuthenticatable)
Enter fullscreen mode Exit fullscreen mode

Generate the Devise controller for sessions and configure your routes.

config/routes.rb:

devise_for :users, controllers: {
  sessions: "users/sessions"
}
Enter fullscreen mode Exit fullscreen mode

Replace app/controllers/users/sessions.rb with the following:

# frozen_string_literal: true

class Users::SessionsController < Devise::SessionsController
  before_action :configure_sign_in_params, only: [:create]

  protected

  def configure_sign_in_params
    devise_parameter_sanitizer.permit(:sign_in, keys: [:token])
  end
end
Enter fullscreen mode Exit fullscreen mode

Create a Stimulus controller at app/javascript/controllers/passwordless_controller.js:

import { Controller } from "@hotwired/stimulus"
import { Client } from '@passwordlessdev/passwordless-client';


export default class extends Controller {
  static targets = [ "email" ]

  connect() {
    this.client = new Client({ apiKey: window.VAULT_ENV.BITWARDEN_PASSWORDLESS_API_PUBLIC_KEY });
    this.csrf_token = document.querySelector('meta[name="csrf-token"]').content
  }

  async register() {
    const email = this.emailTarget.value
    const { token: registerToken } = await fetch('/api/registrations', {
      method: 'post',
      headers: {
        'content-type': 'application/json'
      },
      body: JSON.stringify({
        email: email,
      })
    }).then(r => r.json())

    const { token, error } = await this.client.register(registerToken)

    if (token) {
      await this.verifyUser(token)
    }

    if (error) {
      console.log(error)
    }
  }

  async login() {
    // Generate a verification token for the user.
    const { token, error } = await this.client.signinWithAlias(this.emailTarget.value);

    if (token) {
      await this.verifyUser(token)
    }
  }

  async verifyUser(token) {
    const verifiedUser = await fetch('/users/sign_in', {
      method: 'post',
      headers: {
        'content-type': 'application/json'
      },
      body: JSON.stringify({
        token,
        authenticity_token: this.csrf_token
      })
    }).then((r) => r.json());

    if (verifiedUser.id) {
      window.location.reload()
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

And insert the controller in the HTML at app/views/users/new.html.erb:

<h1>Sign Up</h1>

<% if current_user %>
  <h2>You're logged in!</h2>

  <%= button_to(
          "Log Out",
          destroy_user_session_path,
          method: :delete
        ) %>
<% else %>
  <div data-controller="passwordless">
    <input data-passwordless-target="email" type="email">

    <button data-action="click->passwordless#register">
      Create Account
    </button>

    <button data-action="click->passwordless#login">
      Log In
    </button>
  </div>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Navigate to your new combined signup/login page and test it out!

Contributors:

Learn more about how The Gnar builds Ruby on Rails applications.

Top comments (0)