Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ PATH
bcrypt (>= 3.1.1)
email_validator (~> 2.0)
railties (>= 5.0)
webauthn (~> 3.0)

GEM
remote: https://rubygems.org/
Expand Down Expand Up @@ -64,6 +65,7 @@ GEM
activesupport (>= 3.0)
railties (>= 3.0)
rspec-rails (>= 2.2)
android_key_attestation (0.3.0)
appraisal (2.5.0)
bundler
rake
Expand All @@ -83,6 +85,7 @@ GEM
parser (>= 2.4)
smart_properties
bigdecimal (4.1.1)
bindata (2.5.1)
builder (3.3.0)
capybara (3.40.0)
addressable
Expand All @@ -93,9 +96,13 @@ GEM
rack-test (>= 0.6.3)
regexp_parser (>= 1.5, < 3.0)
xpath (~> 3.2)
cbor (0.5.10.2)
coderay (1.1.3)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
cose (1.3.1)
cbor (~> 0.5.9)
openssl-signature_algorithm (~> 1.0)
crass (1.0.6)
database_cleaner (2.1.0)
database_cleaner-active_record (>= 2, < 3)
Expand Down Expand Up @@ -138,6 +145,8 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.19.3)
jwt (3.1.2)
base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
Expand Down Expand Up @@ -168,6 +177,9 @@ GEM
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-gnu)
racc (~> 1.4)
openssl (4.0.1)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
parallel (1.28.0)
parser (3.3.11.1)
ast (~> 2.4.1)
Expand Down Expand Up @@ -258,6 +270,8 @@ GEM
rubocop (>= 1.75.0, < 2.0)
rubocop-ast (>= 1.47.1, < 2.0)
ruby-progressbar (1.13.0)
safety_net_attestation (0.5.0)
jwt (>= 2.0, < 4.0)
securerandom (0.4.1)
shoulda-matchers (7.0.1)
activesupport (>= 7.1)
Expand All @@ -280,6 +294,10 @@ GEM
thor (1.5.0)
timecop (0.9.11)
timeout (0.4.3)
tpm-key_attestation (0.14.1)
bindata (~> 2.4)
openssl (> 2.0)
openssl-signature_algorithm (~> 1.0)
tsort (0.2.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
Expand All @@ -288,6 +306,14 @@ GEM
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
webauthn (3.4.3)
android_key_attestation (~> 0.3.0)
bindata (~> 2.4)
cbor (~> 0.5.9)
cose (~> 1.1)
openssl (>= 2.2)
safety_net_attestation (~> 0.5.0)
tpm-key_attestation (~> 0.14.0)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.7.5)
Expand Down
34 changes: 34 additions & 0 deletions app/controllers/clearance/passkey_authentications_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class Clearance::PasskeyAuthenticationsController < Clearance::BaseController
skip_before_action :require_login, raise: false

def new
options = WebAuthn::Credential.options_for_get
session[:passkey_authentication_challenge] = options.challenge

render json: options
end

def create
credential = WebAuthn::Credential.from_get(params)
passkey = Clearance::Passkey.find_by!(external_id: credential.id)

credential.verify(
session.delete(:passkey_authentication_challenge),
public_key: passkey.public_key,
sign_count: passkey.sign_count
)
passkey.update!(sign_count: credential.sign_count)

sign_in(passkey.user) do |status|
if status.success?
render json: {redirect_to: Clearance.configuration.redirect_url}
else
render json: {error: status.failure_message}, status: :unauthorized
end
end
rescue ActiveRecord::RecordNotFound
render json: {error: "Passkey not found"}, status: :unauthorized
rescue WebAuthn::Error => e
render json: {error: e.message}, status: :unprocessable_content
end
end
30 changes: 30 additions & 0 deletions app/controllers/clearance/passkeys_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class Clearance::PasskeysController < Clearance::BaseController
before_action :require_login

def new
current_user.update_column(:webauthn_id, WebAuthn.generate_user_id) unless current_user.webauthn_id?

options = WebAuthn::Credential.options_for_create(
user: {id: current_user.webauthn_id, name: current_user.email}
)
session[:passkey_creation_challenge] = options.challenge

render json: options
end

def create
credential = WebAuthn::Credential.from_create(params)
credential.verify(session.delete(:passkey_creation_challenge))

current_user.passkeys.create!(
label: params[:label],
external_id: credential.id,
public_key: credential.public_key,
sign_count: credential.sign_count
)

head :created
rescue WebAuthn::Error => e
render json: {error: e.message}, status: :unprocessable_content
end
end
1 change: 1 addition & 0 deletions clearance.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ require "clearance/version"

Gem::Specification.new do |s|
s.add_dependency "bcrypt", ">= 3.1.1"
s.add_dependency "webauthn", "~> 3.0"
s.add_dependency "argon2", "~> 2.0", ">= 2.0.2"
s.add_dependency "email_validator", "~> 2.0"
s.add_dependency "railties", ">= 5.0"
Expand Down
8 changes: 8 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,13 @@
if Clearance.configuration.allow_sign_up?
get "/sign_up" => "clearance/users#new", :as => "sign_up"
end

resources :passkeys,
controller: "clearance/passkeys",
only: [:new, :create]

resource :passkey_authentication,
controller: "clearance/passkey_authentications",
only: [:new, :create]
end
end
1 change: 1 addition & 0 deletions lib/clearance.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
require "clearance/back_door"
require "clearance/controller"
require "clearance/user"
require "clearance/passkey"
require "clearance/password_strategies"
require "clearance/constraints"
require "clearance/engine"
Expand Down
10 changes: 10 additions & 0 deletions lib/clearance/passkey.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
require "webauthn"

module Clearance
class Passkey < ActiveRecord::Base
belongs_to :user, class_name: "::User", optional: false

validates :label, :external_id, :public_key, presence: true
validates :external_id, uniqueness: true
end
end
79 changes: 79 additions & 0 deletions lib/generators/clearance/passkeys/passkeys_generator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
require "rails/generators/base"
require "rails/generators/active_record"

module Clearance
module Generators
class PasskeysGenerator < Rails::Generators::Base
include Rails::Generators::Migration

source_root File.expand_path("../templates", __FILE__)

# Required by Rails::Generators::Migration to produce timestamped filenames.
def self.next_migration_number(dir)
ActiveRecord::Generators::Base.next_migration_number(dir)
end

def create_migrations
copy_migration("add_webauthn_id_to_users") unless webauthn_id_column_exists?
copy_migration("create_passkeys") unless passkeys_table_exists?
end

def inject_passkeys_into_user_model
return unless File.exist?("app/models/user.rb")

inject_into_file(
"app/models/user.rb",
" has_many :passkeys, class_name: \"Clearance::Passkey\", dependent: :destroy\n",
after: "include Clearance::User\n"
)
end

def display_readme_in_terminal
readme "README"
end

private

def copy_migration(migration_name)
unless migration_exists?(migration_name)
migration_template(
"db/migrate/#{migration_name}.rb.erb",
"db/migrate/#{migration_name}.rb",
migration_version: migration_version
)
end
end

def webauthn_id_column_exists?
users_table_exists? &&
connection.columns(:users).map(&:name).include?("webauthn_id")
end

def users_table_exists?
connection.data_source_exists?(:users)
end

def passkeys_table_exists?
connection.data_source_exists?(:passkeys)
end

def migration_exists?(name)
existing_migrations.include?(name)
end

def existing_migrations
@existing_migrations ||= Dir.glob("db/migrate/*.rb").map do |file|
file.sub(%r{^.*(db/migrate/)(?:\d+_)?}, "").chomp(".rb")
end
end

def migration_version
"[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
end

def connection
ActiveRecord::Base.connection
end
end
end
end
62 changes: 62 additions & 0 deletions lib/generators/clearance/passkeys/templates/README
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
*******************************************************************************

Next steps:

1. Configure WebAuthn in an initializer:

# config/initializers/webauthn.rb
WebAuthn.configure do |config|
config.allowed_origins = ["https://yourapp.example.com"]
config.rp_name = "Your Application Name"
end

In development, use your local server URL (e.g. "http://localhost:3000").
In production, use your app's full HTTPS URL.
allowed_origins takes an array, so you can list multiple origins if needed.

2. Migrate:

Run `rails db:migrate` to add passkey database changes.

3. Handle credential encoding on the frontend:

The WebAuthn browser API returns ArrayBuffers, not plain strings. Use
@github/webauthn-json to handle Base64url encoding between the
browser's native credential objects and the JSON your server expects.

Install it using your app's JavaScript setup. For example, with
importmaps (Rails default):

./bin/importmap pin @github/webauthn-json

Then pin your own passkeys JS file in config/importmap.rb:

pin "passkeys", to: "passkeys.js"

Then in your JavaScript (app/javascript/passkeys.js):

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

// Registration (user must be signed in)
const options = await fetch("/passkeys/new").then(r => r.json())
const credential = await create({ publicKey: options })
await fetch("/passkeys", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify({ ...credential, label: "My device" })
})

// Authentication
const options = await fetch("/passkey_authentication/new").then(r => r.json())
const credential = await get({ publicKey: options })
const result = await fetch("/passkey_authentication", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
body: JSON.stringify(credential)
}).then(r => r.json())
window.location = result.redirect_to

See https://github.com/github/webauthn-json for other installation
options.

*******************************************************************************
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class AddWebauthnIdToUsers < ActiveRecord::Migration<%= migration_version %>
def change
add_column :users, :webauthn_id, :string
add_index :users, :webauthn_id, unique: true
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
class CreatePasskeys < ActiveRecord::Migration<%= migration_version %>
def change
create_table :passkeys do |t|
t.references :user, null: false, foreign_key: true
t.string :label, null: false
t.string :external_id, null: false
t.string :public_key, null: false
t.integer :sign_count, null: false, default: 0
t.timestamps
end

add_index :passkeys, :external_id, unique: true
end
end
Loading
Loading