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.
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 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 < 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" => ENV["BITWARDEN_PASSWORDLESS_API_PRIVATE_KEY"], "Content-Type" => "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!
Contributors:
Learn more about how The Gnar builds Ruby on Rails applications.
Top comments (0)