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.