Creating Passkey Authentication in a Rails 7 Application
Tags: ruby, rails, security, learning
Published: 2024-03-27 17:39:10

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](https://github.com/JackVCurtis/devise-passkeys-example). 

We are going to implement a passwordless strategy using [Devise](https://github.com/heartcombo/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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/0xcvlw3masd0sn4mse0q.png)


![Passwordless.dev dashboard](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sytrmg5luij06qgtcxoj.png)

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=
```

Set up your database and run the app locally:
```
rails db:create
rails db:migrate
rails s
```

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
```

Install the Bitwarden [Passwordless.dev JS client](https://docs.passwordless.dev/guide/frontend/javascript.html) 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
```

`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)
```

Generate the Devise controller for sessions and configure your routes.

`config/routes.rb`:

```
devise_for :users, controllers: {
  sessions: "users/sessions"
}
```

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
```

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()
    }
  }
}
```

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 %>
```

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

<h2>Contributors:</h2>
<ul>
<li><a href="https://github.com/JackVCurtis">Jack Curtis</a></li>
<li><a href="https://github.com/taylorkearns">Taylor Kearns</a></li>
<li><a href="https://github.com/jrthreadgill">Royce Threadgill</a></li>
</ul>
<p><i><span style="font-weight: 400;">Learn more about how The Gnar</span></i> <a href="https://www.thegnar.com/software-development/ruby-on-rails-development"><i><span style="font-weight: 400;">builds Ruby on Rails applications</span></i></a><i><span style="font-weight: 400;">.</span></i></p>