├── .ruby-version ├── spec ├── dummy │ ├── log │ │ └── .keep │ ├── lib │ │ └── assets │ │ │ └── .keep │ ├── storage │ │ └── .keep │ ├── public │ │ ├── favicon.ico │ │ ├── apple-touch-icon.png │ │ ├── apple-touch-icon-precomposed.png │ │ ├── 500.html │ │ ├── 422.html │ │ └── 404.html │ ├── app │ │ ├── assets │ │ │ ├── images │ │ │ │ └── .keep │ │ │ ├── config │ │ │ │ └── manifest.js │ │ │ └── stylesheets │ │ │ │ └── application.css │ │ ├── models │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ ├── user.rb │ │ │ ├── application_record.rb │ │ │ └── contact.rb │ │ ├── controllers │ │ │ ├── concerns │ │ │ │ └── .keep │ │ │ └── application_controller.rb │ │ ├── helpers │ │ │ └── application_helper.rb │ │ ├── channels │ │ │ └── application_cable │ │ │ │ ├── channel.rb │ │ │ │ └── connection.rb │ │ ├── mailers │ │ │ └── application_mailer.rb │ │ ├── jobs │ │ │ └── application_job.rb │ │ └── views │ │ │ └── layouts │ │ │ └── application.html.erb │ ├── bin │ │ ├── rake │ │ ├── rails │ │ └── setup │ ├── config │ │ ├── environment.rb │ │ ├── routes.rb │ │ ├── cable.yml │ │ ├── boot.rb │ │ ├── initializers │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── permissions_policy.rb │ │ │ ├── assets.rb │ │ │ ├── inflections.rb │ │ │ └── content_security_policy.rb │ │ ├── database.yml │ │ ├── application.rb │ │ ├── locales │ │ │ └── en.yml │ │ ├── storage.yml │ │ ├── puma.rb │ │ └── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ ├── config.ru │ ├── Rakefile │ └── db │ │ ├── migrate │ │ ├── 20230620194014_create_users.rb │ │ └── 20230624050358_create_contacts.rb │ │ └── schema.rb ├── support │ ├── json_helpers.rb │ ├── webauthn_helper.rb │ └── api_helpers.rb ├── factories │ ├── passkeys.rb │ └── agents.rb ├── interactors │ └── passkeys │ │ └── rails │ │ ├── begin_authentication_spec.rb │ │ ├── generate_auth_token_spec.rb │ │ ├── refresh_token_spec.rb │ │ ├── validate_auth_token_spec.rb │ │ ├── begin_challenge_spec.rb │ │ ├── debug_login_spec.rb │ │ ├── begin_registration_spec.rb │ │ ├── finish_authentication_spec.rb │ │ ├── debug_register_spec.rb │ │ └── finish_registration_spec.rb ├── generator │ └── passkeys_rails │ │ └── install_generator_spec.rb ├── requests │ ├── passkeys │ │ └── rails │ │ │ ├── passkeys_controller_refresh_spec.rb │ │ │ ├── passkeys_controller_debug_login_spec.rb │ │ │ ├── passkeys_controller_debug_register_spec.rb │ │ │ ├── passkeys_controller_challenge_spec.rb │ │ │ ├── passkeys_controller_authenticate_spec.rb │ │ │ └── passkeys_controller_register_spec.rb │ └── application_controller_spec.rb ├── configuration_spec.rb ├── features │ └── register_new_user_spec.rb ├── spec_helper.rb ├── rails_helper.rb └── passkeys_rails_spec.rb ├── .rspec ├── lib ├── passkeys_rails │ ├── version.rb │ ├── railtie.rb │ ├── engine.rb │ ├── test │ │ └── integration_helpers.rb │ └── configuration.rb ├── tasks │ └── passkeys_rails_tasks.rake ├── generators │ └── passkeys_rails │ │ ├── USAGE │ │ ├── templates │ │ ├── README │ │ └── passkeys_rails_config.rb │ │ └── install_generator.rb └── passkeys-rails.rb ├── app ├── models │ ├── passkeys_rails │ │ ├── application_record.rb │ │ ├── passkey.rb │ │ ├── error.rb │ │ └── agent.rb │ └── concerns │ │ └── passkeys_rails │ │ ├── authenticatable.rb │ │ ├── debuggable.rb │ │ └── authenticatable_creator.rb ├── interactors │ └── passkeys_rails │ │ ├── begin_authentication.rb │ │ ├── refresh_token.rb │ │ ├── generate_auth_token.rb │ │ ├── begin_challenge.rb │ │ ├── begin_registration.rb │ │ ├── validate_auth_token.rb │ │ ├── debug_login.rb │ │ ├── debug_register.rb │ │ ├── finish_authentication.rb │ │ └── finish_registration.rb └── controllers │ ├── concerns │ └── passkeys_rails │ │ └── authentication.rb │ └── passkeys_rails │ ├── application_controller.rb │ └── passkeys_controller.rb ├── Gemfile ├── .travis.yml ├── db └── migrate │ ├── 20230620012600_create_passkeys_rails_passkeys.rb │ └── 20230620012530_create_passkeys_rails_agents.rb ├── Dangerfile ├── config ├── initializers │ └── application_controller.rb └── routes.rb ├── .gitignore ├── .reek.yml ├── bin ├── rails └── rake ├── .github └── workflows │ └── danger.yml ├── Rakefile ├── MIT-LICENSE ├── RELEASING.md ├── CHANGELOG.md ├── passkeys_rails.gemspec ├── CODE_OF_CONDUCT.md ├── CONTRIBUTION_GUIDELINES.md ├── .rubocop.yml ├── Gemfile.lock └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/storage/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/dummy/public/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require rails_helper 3 | --order rand 4 | -------------------------------------------------------------------------------- /spec/dummy/app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /lib/passkeys_rails/version.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | VERSION = "0.3.4".freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ApplicationRecord 2 | include PasskeysRails::Authenticatable 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative "../config/boot" 3 | require "rake" 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | primary_abstract_class 3 | end 4 | -------------------------------------------------------------------------------- /lib/tasks/passkeys_rails_tasks.rake: -------------------------------------------------------------------------------- 1 | # desc "Explaining what the task does" 2 | # task :passkeys_rails do 3 | # # Task goes here 4 | # end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Channel < ActionCable::Channel::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | //= link passkeys_rails_manifest.js 4 | -------------------------------------------------------------------------------- /spec/dummy/app/channels/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | module ApplicationCable 2 | class Connection < ActionCable::Connection::Base 3 | end 4 | end 5 | -------------------------------------------------------------------------------- /spec/dummy/app/mailers/application_mailer.rb: -------------------------------------------------------------------------------- 1 | class ApplicationMailer < ActionMailer::Base 2 | default from: "from@example.com" 3 | layout "mailer" 4 | end 5 | -------------------------------------------------------------------------------- /app/models/passkeys_rails/application_record.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class ApplicationRecord < ActiveRecord::Base 3 | self.abstract_class = true 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path("../config/application", __dir__) 3 | require_relative "../config/boot" 4 | require "rails/commands" 5 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative "application" 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | mount PasskeysRails::Engine => "/passkeys_rails" 3 | root to: 'application#index' 4 | get '/home' => 'application#home' 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative "config/environment" 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /spec/support/json_helpers.rb: -------------------------------------------------------------------------------- 1 | module Requests 2 | module JsonHelpers 3 | def json 4 | json = JSON.parse(response.body) 5 | json.is_a?(Hash) ? json.with_indifferent_access : json 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/models/contact.rb: -------------------------------------------------------------------------------- 1 | class Contact < ApplicationRecord 2 | validates :first_name, presence: true 3 | validates :last_name, presence: true 4 | validates :phone, presence: true 5 | validates :email, presence: true 6 | end 7 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/begin_authentication.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class BeginAuthentication 3 | include Interactor 4 | 5 | def call 6 | context.options = WebAuthn::Credential.options_for_get 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: test 6 | 7 | production: 8 | adapter: redis 9 | url: <%= ENV.fetch("REDIS_URL") { "redis://localhost:6379/1" } %> 10 | channel_prefix: dummy_production 11 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative "config/application" 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | # Set up gems listed in the Gemfile. 2 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../../Gemfile", __dir__) 3 | 4 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 5 | $LOAD_PATH.unshift File.expand_path("../../../lib", __dir__) 6 | -------------------------------------------------------------------------------- /spec/factories/passkeys.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :passkey, class: "PasskeysRails::Passkey" do 3 | agent 4 | sequence(:identifier) { |i| "identifier #{i}" } 5 | sequence(:public_key) { |i| "public key #{i}" } 6 | sign_count { 0 } 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /app/models/passkeys_rails/passkey.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class Passkey < ApplicationRecord 3 | belongs_to :agent 4 | validates :identifier, presence: true, uniqueness: true 5 | validates :public_key, presence: true 6 | validates :sign_count, presence: true 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20230620194014_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.0] 2 | def change 3 | # rubocop:disable Style/SymbolProc 4 | create_table :users do |t| 5 | t.timestamps 6 | end 7 | # rubocop:enable Style/SymbolProc 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/app/jobs/application_job.rb: -------------------------------------------------------------------------------- 1 | class ApplicationJob < ActiveJob::Base 2 | # Automatically retry jobs that encountered a deadlock 3 | # retry_on ActiveRecord::Deadlocked 4 | 5 | # Most jobs are safe to ignore if the underlying records are no longer available 6 | # discard_on ActiveJob::DeserializationError 7 | end 8 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in passkeys_rails.gemspec. 5 | gemspec 6 | 7 | # other development dependencies 8 | group :test do 9 | gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby] 10 | end 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 3.2.1 4 | 5 | env: 6 | - DB=sqlite 7 | - DB=mysql 8 | - DB=postgresql 9 | 10 | before_script: 11 | - rake app:db:create 12 | - rake app:db:migrate 13 | - rake app:db:test:prepare 14 | 15 | after_script: 16 | - rake app:de:rollback 17 | 18 | branches: 19 | only: 20 | - main 21 | -------------------------------------------------------------------------------- /app/models/passkeys_rails/error.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class Error < StandardError 3 | attr_reader :hash 4 | 5 | def initialize(message, hash = {}) 6 | @hash = hash 7 | super(message) 8 | end 9 | 10 | def to_h 11 | { error: hash.merge(context: message) } 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20230624050358_create_contacts.rb: -------------------------------------------------------------------------------- 1 | class CreateContacts < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :contacts do |t| 4 | t.string :first_name 5 | t.string :last_name 6 | t.string :phone 7 | t.string :email 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | before_action :authenticate_passkey!, only: :index 3 | 4 | def index 5 | render json: { username: current_agent&.username } 6 | end 7 | 8 | def home 9 | render json: { username: current_agent&.username } 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/generators/passkeys_rails/USAGE: -------------------------------------------------------------------------------- 1 | Description: 2 | Creates a PasskeysRails config file, updates the routes and adds migrations. 3 | 4 | Example: 5 | bin/rails generate passkeys_rails:install 6 | 7 | This will: 8 | create config/passkeys_rails.rb 9 | add database migrations 10 | update routes to mount the passkeys_rails engine 11 | -------------------------------------------------------------------------------- /spec/dummy/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Dummy 5 | 6 | <%= csrf_meta_tags %> 7 | <%= csp_meta_tag %> 8 | 9 | <%= stylesheet_link_tag "application" %> 10 | 11 | 12 | 13 | <%= yield %> 14 | 15 | 16 | -------------------------------------------------------------------------------- /db/migrate/20230620012600_create_passkeys_rails_passkeys.rb: -------------------------------------------------------------------------------- 1 | class CreatePasskeysRailsPasskeys < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :passkeys_rails_passkeys do |t| 4 | t.string :identifier 5 | t.string :public_key 6 | t.integer :sign_count 7 | t.references :agent, null: false, foreign_key: { to_table: :passkeys_rails_agents } 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/begin_authentication_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::BeginAuthentication do 2 | let(:call) { described_class.call } 3 | 4 | it "returns options from WebAuthn" do 5 | options = "SOME OPTIONS" 6 | allow(WebAuthn::Credential).to receive(:options_for_get).and_return(options) 7 | 8 | result = call 9 | expect(result).to be_success 10 | expect(result.options).to eq options 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/passkeys_rails/railtie.rb: -------------------------------------------------------------------------------- 1 | require 'passkeys-rails' 2 | require 'rails' 3 | 4 | module PasskeysRails 5 | class Railtie < ::Rails::Railtie 6 | railtie_name :passkeys_rails 7 | 8 | rake_tasks do 9 | path = File.expand_path(__dir__) 10 | Dir.glob("#{path}/tasks/**/*.rake").each { |f| load f } 11 | end 12 | 13 | generators do 14 | require "generators/passkeys_rails/install_generator" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure parameters to be filtered from the log file. Use this to limit dissemination of 4 | # sensitive information. See the ActiveSupport::ParameterFilter documentation for supported 5 | # notations and behaviors. 6 | Rails.application.config.filter_parameters += %i[ 7 | passw secret token _key crypt salt certificate otp ssn 8 | ] 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/permissions_policy.rb: -------------------------------------------------------------------------------- 1 | # Define an application-wide HTTP permissions policy. For further 2 | # information see https://developers.google.com/web/updates/2018/06/feature-policy 3 | # 4 | # Rails.application.config.permissions_policy do |f| 5 | # f.camera :none 6 | # f.gyroscope :none 7 | # f.microphone :none 8 | # f.usb :none 9 | # f.fullscreen :self 10 | # f.payment :self, "https://secure.example.com" 11 | # end 12 | -------------------------------------------------------------------------------- /app/models/passkeys_rails/agent.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class Agent < ApplicationRecord 3 | belongs_to :authenticatable, polymorphic: true, optional: true 4 | has_many :passkeys 5 | 6 | scope :registered, -> { where.not registered_at: nil } 7 | scope :unregistered, -> { where registered_at: nil } 8 | 9 | validates :username, presence: true, uniqueness: true 10 | 11 | def registered? 12 | registered_at.present? 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /Dangerfile: -------------------------------------------------------------------------------- 1 | # Make it more obvious that a PR is a work in progress and shouldn't be merged yet 2 | warn("PR is classed as Work in Progress") if github.pr_title.include? "[WIP]" 3 | 4 | # Warn when there is a big PR 5 | warn("Big PR") if git.lines_of_code > 500 6 | 7 | # Don't let testing shortcuts get into master by accident 8 | raise("fdescribe left in tests") if `grep -r fdescribe specs/ `.length > 1 9 | raise("fit left in tests") if `grep -r fit specs/ `.length > 1 10 | 11 | toc.check 12 | changelog.check 13 | -------------------------------------------------------------------------------- /spec/factories/agents.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :agent, class: "PasskeysRails::Agent" do 3 | sequence(:username) { |n| "username-#{n}" } 4 | registered_at { nil } 5 | last_authenticated_at { nil } 6 | 7 | trait :registered do 8 | registered_at { 1.day.ago } 9 | end 10 | 11 | trait :unregistered do 12 | registered_at { nil } 13 | end 14 | 15 | trait :recentyl_authenticated do 16 | last_authenticated_at { 2.hours.ago } 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /config/initializers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # These should be autoloaded, but if these aren't required here, apps using this 2 | # gem will throw an exception that PasskeysRails::Authentication can't be found 3 | require_relative '../../app/controllers/concerns/passkeys_rails/authentication' 4 | require_relative '../../app/models/passkeys_rails/error' 5 | 6 | class ActionController::Base 7 | include PasskeysRails::Authentication 8 | end 9 | 10 | class ActionController::API 11 | include PasskeysRails::Authentication 12 | end 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /doc/ 3 | /log/*.log 4 | /pkg/ 5 | /tmp/ 6 | /coverage/ 7 | /spec/reports/ 8 | 9 | # rspec failure tracking 10 | .rspec_status 11 | 12 | *.code-workspace 13 | .env 14 | 15 | /spec/dummy/db/*.sqlite3 16 | /spec/dummy/db/*.sqlite3-* 17 | /spec/dummy/log/*.log 18 | /spec/dummy/storage/ 19 | /spec/dummy/tmp/ 20 | *.gem 21 | .yardoc 22 | .vscode 23 | spec/dummy/db/migrate/*_create_passkeys_rails_agents.passkeys_rails.rb 24 | spec/dummy/db/migrate/*_create_passkeys_rails_passkeys.passkeys_rails.rb 25 | *.breakpoints 26 | -------------------------------------------------------------------------------- /.reek.yml: -------------------------------------------------------------------------------- 1 | # Auto generated by Reeks --todo flag 2 | --- 3 | detectors: 4 | MissingSafeMethod: 5 | enabled: false 6 | UncommunicativeParameterName: 7 | enabled: false 8 | UncommunicativeVariableName: 9 | enabled: false 10 | IrresponsibleModule: 11 | enabled: false 12 | 13 | Attribute: 14 | exclude: 15 | - PasskeysRails#auth_token_algorithm 16 | - PasskeysRails#auth_token_expires_in 17 | - PasskeysRails#auth_token_secret 18 | - PasskeysRails#default_class 19 | - PasskeysRails#class_whitelist 20 | -------------------------------------------------------------------------------- /app/models/concerns/passkeys_rails/authenticatable.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | 3 | module PasskeysRails 4 | module Authenticatable 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | has_one :agent, as: :authenticatable, class_name: "PasskeysRails::Agent" 9 | 10 | delegate :username, to: :agent, allow_nil: true 11 | delegate :registered?, to: :agent, allow_nil: true 12 | 13 | def registering_with(_params) 14 | # initialize required attributes 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/passkeys_rails/engine.rb: -------------------------------------------------------------------------------- 1 | require "rails" 2 | require "active_support/core_ext/numeric/time" 3 | require "active_support/dependencies" 4 | 5 | require "interactor" 6 | require "jwt" 7 | require "webauthn" 8 | 9 | module PasskeysRails 10 | class Engine < ::Rails::Engine 11 | isolate_namespace PasskeysRails 12 | 13 | config.generators do |g| 14 | g.test_framework :rspec 15 | g.fixture_replacement :factory_bot 16 | g.factory_bot dir: 'spec/factories' 17 | g.assets false 18 | g.helper false 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /app/models/concerns/passkeys_rails/debuggable.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | module Debuggable 3 | extend ActiveSupport::Concern 4 | 5 | protected 6 | 7 | def ensure_debug_mode 8 | context.fail!(code: :not_allowed, message: 'Action not allowed') if username_regex.blank? 9 | end 10 | 11 | def ensure_regex_match 12 | context.fail!(code: :not_allowed, message: 'Invalid username') unless username&.match?(username_regex) 13 | end 14 | 15 | def username_regex 16 | PasskeysRails.debug_login_regex 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | # Rails.application.config.assets.version = "1.0" 5 | 6 | # Add additional assets to the asset load path. 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in the app/assets 11 | # folder are already added. 12 | # Rails.application.config.assets.precompile += %w( admin.js admin.css ) 13 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/refresh_token.rb: -------------------------------------------------------------------------------- 1 | # Finish authentication ceremony 2 | module PasskeysRails 3 | class RefreshToken 4 | include Interactor 5 | 6 | delegate :token, to: :context 7 | 8 | def call 9 | agent = ValidateAuthToken.call!(auth_token: token).agent 10 | 11 | context.agent = agent 12 | context.username = agent.username 13 | context.auth_token = GenerateAuthToken.call!(agent:).auth_token 14 | rescue Interactor::Failure => e 15 | context.fail! code: e.context.code, message: e.context.message 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # This command will automatically be run when you run "rails" with Rails gems 3 | # installed from the root of your application. 4 | 5 | ENGINE_ROOT = File.expand_path("..", __dir__) 6 | ENGINE_PATH = File.expand_path("../lib/passkeys/rails/engine", __dir__) 7 | APP_PATH = File.expand_path("../spec/dummy/config/application", __dir__) 8 | 9 | # Set up gems listed in the Gemfile. 10 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 11 | require "bundler/setup" if File.exist?(ENV["BUNDLE_GEMFILE"]) 12 | 13 | require "rails/all" 14 | require "rails/engine/commands" 15 | -------------------------------------------------------------------------------- /.github/workflows/danger.yml: -------------------------------------------------------------------------------- 1 | name: PR Linter 2 | on: [pull_request] 3 | jobs: 4 | danger: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | with: 9 | fetch-depth: 0 10 | - name: Set up Ruby 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | bundler-cache: true 14 | ruby-version: 3.1 15 | - run: | 16 | # the personal token is public, this is ok, base64 encode to avoid tripping Github 17 | TOKEN=$(echo -n Z2hwX2JPUVEwOEhqekRoZWhzNGplUkRvMzNENjRmOUR1UzNSSU80dQo= | base64 --decode) 18 | DANGER_GITHUB_API_TOKEN=$TOKEN bundle exec danger --verbose 19 | 20 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | PasskeysRails::Engine.routes.draw do 2 | post 'challenge', to: 'passkeys#challenge' 3 | post 'register', to: 'passkeys#register' 4 | post 'authenticate', to: 'passkeys#authenticate' 5 | post 'refresh', to: 'passkeys#refresh' 6 | 7 | # These routes exist to allow easier mobile app debugging as it may not 8 | # be possible to acess Passkey functionality in mobile simulators. 9 | # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment. 10 | constraints(->(_request) { PasskeysRails.debug_login_regex.present? }) do 11 | post 'debug_login', to: 'passkeys#debug_login' 12 | post 'debug_register', to: 'passkeys#debug_register' 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/generate_auth_token.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class GenerateAuthToken 3 | include Interactor 4 | 5 | delegate :agent, to: :context 6 | 7 | def call 8 | context.auth_token = generate_auth_token 9 | end 10 | 11 | private 12 | 13 | def generate_auth_token 14 | JWT.encode(jwt_payload, 15 | PasskeysRails.auth_token_secret, 16 | PasskeysRails.auth_token_algorithm) 17 | end 18 | 19 | def jwt_payload 20 | expiration = (Time.current + PasskeysRails.auth_token_expires_in).to_i 21 | 22 | payload = { agent_id: agent.id } 23 | payload[:exp] = expiration unless expiration.zero? 24 | payload 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | # SQLite. Versions 3.8.0 and up are supported. 2 | # gem install sqlite3 3 | # 4 | # Ensure the SQLite 3 gem is defined in your Gemfile 5 | # gem "sqlite3" 6 | # 7 | default: &default 8 | adapter: sqlite3 9 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 10 | timeout: 5000 11 | 12 | development: 13 | <<: *default 14 | database: db/development.sqlite3 15 | 16 | # Warning: The database defined as "test" will be erased and 17 | # re-generated from your development database when you run "rake". 18 | # Do not set this db to the same as development or production. 19 | test: 20 | <<: *default 21 | database: db/test.sqlite3 22 | 23 | production: 24 | <<: *default 25 | database: db/production.sqlite3 26 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, "\\1en" 8 | # inflect.singular /^(ox)en/i, "\\1" 9 | # inflect.irregular "person", "people" 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym "RESTful" 16 | # end 17 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/generate_auth_token_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::GenerateAuthToken do 2 | let(:call) { described_class.call agent: } 3 | 4 | context "with an agent" do 5 | let(:agent) { create(:agent) } 6 | 7 | it "returns a properly encoded JWT" do 8 | result = call 9 | expect(result).to be_success 10 | 11 | token = result.auth_token 12 | expect(token).to be_a String 13 | 14 | payload, = JWT.decode(token, PasskeysRails.auth_token_secret, true, { algorithm: PasskeysRails.auth_token_algorithm }) 15 | 16 | expect(payload['agent_id']).to eq agent.id 17 | expect(payload['exp']).to be_present 18 | expect(Time.zone.at(payload['exp'])).to be_future 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/generators/passkeys_rails/templates/README: -------------------------------------------------------------------------------- 1 | =============================================================================== 2 | 3 | Depending on your application's configuration some manual setup may be required: 4 | 5 | 1. Add a before_action to all controllers that require authentication to use. 6 | 7 | For example: 8 | 9 | before_action :authenticate_passkey!, except: [:index] 10 | 11 | 2. Optionally include PasskeysRails::Authenticatable to the model(s) you are using as 12 | your user model(s). For example, the User model. 13 | 14 | 3. See the reference mobile applications for how to use passkeys-rails for passkey 15 | authentication. 16 | 17 | =============================================================================== 18 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env rake 2 | 3 | begin 4 | require 'bundler/setup' 5 | rescue LoadError 6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 7 | end 8 | 9 | Bundler::GemHelper.install_tasks 10 | 11 | APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__) 12 | load 'rails/tasks/engine.rake' 13 | require 'rspec/core/rake_task' 14 | 15 | RSpec::Core::RakeTask.new(:spec) do |spec| 16 | spec.pattern = 'spec/**/*_spec.rb' 17 | end 18 | 19 | require "rubocop/rake_task" 20 | RuboCop::RakeTask.new do |task| 21 | task.requires << 'rubocop-rails' 22 | task.requires << 'rubocop-performance' 23 | task.requires << 'rubocop-rspec' 24 | task.requires << 'rubocop-rake' 25 | task.requires << 'rubocop-factory_bot' 26 | end 27 | 28 | task default: %i[spec rubocop] 29 | -------------------------------------------------------------------------------- /spec/dummy/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS 10 | * files in this directory. Styles in this file should be added after the last require_* statement. 11 | * It is generally better to create a new file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | */ 16 | -------------------------------------------------------------------------------- /app/controllers/concerns/passkeys_rails/authentication.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | module Authentication 3 | extend ActiveSupport::Concern 4 | 5 | included do 6 | rescue_from PasskeysRails::Error do |e| 7 | render json: e.to_h, status: :unauthorized 8 | end 9 | end 10 | 11 | def current_agent 12 | @current_agent ||= (passkey_authentication_result.success? && 13 | passkey_authentication_result.agent.registered? && 14 | passkey_authentication_result.agent) || nil 15 | end 16 | 17 | def authenticate_passkey! 18 | @authenticate_passkey ||= PasskeysRails.authenticate!(request) 19 | end 20 | 21 | def passkey_authentication_result 22 | @passkey_authentication_result ||= PasskeysRails.authenticate(request) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) 12 | 13 | bundle_binstub = File.expand_path("bundle", __dir__) 14 | 15 | if File.file?(bundle_binstub) 16 | if File.read(bundle_binstub, 300).include?("This file was generated by Bundler") 17 | load(bundle_binstub) 18 | else 19 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 20 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 21 | end 22 | end 23 | 24 | require "rubygems" 25 | require "bundler/setup" 26 | 27 | load Gem.bin_path("rake", "rake") 28 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/begin_challenge.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class BeginChallenge 3 | include Interactor 4 | 5 | delegate :username, to: :context 6 | 7 | def call 8 | result = generate_challenge! 9 | 10 | options = result.options 11 | 12 | context.response = options 13 | context.cookie_data = cookie_data(options) 14 | rescue Interactor::Failure => e 15 | context.fail! code: e.context.code, message: e.context.message 16 | end 17 | 18 | private 19 | 20 | def generate_challenge! 21 | if username.present? 22 | BeginRegistration.call!(username:) 23 | else 24 | BeginAuthentication.call! 25 | end 26 | end 27 | 28 | def cookie_data(options) 29 | { 30 | username:, 31 | challenge: options.challenge 32 | } 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /db/migrate/20230620012530_create_passkeys_rails_agents.rb: -------------------------------------------------------------------------------- 1 | class CreatePasskeysRailsAgents < ActiveRecord::Migration[7.0] 2 | def change 3 | create_table :passkeys_rails_agents do |t| 4 | t.string :username, null: false 5 | t.references :authenticatable, polymorphic: true 6 | t.string :webauthn_identifier 7 | t.datetime :registered_at 8 | t.datetime :last_authenticated_at 9 | 10 | t.timestamps 11 | end 12 | 13 | # Make the authenticatable index enforce uniqueness 14 | remove_index :passkeys_rails_agents, %i[authenticatable_type authenticatable_id], name: 'index_passkeys_rails_agents_on_authenticatable' 15 | add_index :passkeys_rails_agents, %i[authenticatable_type authenticatable_id], unique: true, name: 'index_passkeys_rails_agents_on_authenticatable' 16 | add_index :passkeys_rails_agents, :username, unique: true 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative "boot" 2 | 3 | require "rails/all" 4 | 5 | # Require the gems listed in Gemfile, including any gems 6 | # you've limited to :test, :development, or :production. 7 | Bundler.require(*Rails.groups) 8 | 9 | module Dummy 10 | class Application < Rails::Application 11 | config.load_defaults Rails::VERSION::STRING.to_f 12 | 13 | # For compatibility with applications that use this config 14 | config.action_controller.include_all_helpers = false 15 | 16 | # Configuration for the application, engines, and railties goes here. 17 | # 18 | # These settings can be overridden in specific environments using the files 19 | # in config/environments, which are processed later. 20 | # 21 | # config.time_zone = "Central Time (US & Canada)" 22 | # config.eager_load_paths << Rails.root.join("extras") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/begin_registration.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class BeginRegistration 3 | include Interactor 4 | 5 | delegate :username, to: :context 6 | 7 | def call 8 | agent = create_or_replace_unregistered_agent 9 | 10 | context.options = WebAuthn::Credential.options_for_create(user: { id: agent.webauthn_identifier, name: agent.username }) 11 | end 12 | 13 | private 14 | 15 | def create_or_replace_unregistered_agent 16 | context.fail! code: :origin_error, message: "config.wa_origin must be set" if WebAuthn.configuration.origin.blank? 17 | 18 | Agent.unregistered.where(username:).destroy_all 19 | 20 | agent = Agent.create(username:, webauthn_identifier: WebAuthn.generate_user_id) 21 | 22 | context.fail!(code: :validation_errors, message: agent.errors.full_messages.to_sentence) unless agent.valid? 23 | 24 | agent 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/generators/passkeys_rails/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails/generators' 2 | 3 | module PasskeysRails 4 | module Generators 5 | class InstallGenerator < ::Rails::Generators::Base 6 | source_root File.expand_path("templates", __dir__) 7 | 8 | desc "Adds passkeys config file to your application." 9 | def copy_config 10 | template 'passkeys_rails_config.rb', "config/initializers/passkeys_rails.rb" 11 | end 12 | 13 | desc "Adds passkeys routes to your application." 14 | def add_routes 15 | route 'mount PasskeysRails::Engine => "/passkeys"' 16 | end 17 | 18 | desc "Copies migrations to your application." 19 | def copy_migrations 20 | rake("passkeys_rails:install:migrations") 21 | end 22 | 23 | desc "Displays readme during installation." 24 | def show_readme 25 | readme "README" if behavior == :invoke 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t "hello" 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t("hello") %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # "true": "foo" 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /app/controllers/passkeys_rails/application_controller.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class ApplicationController < ActionController::Base 3 | rescue_from StandardError, with: :handle_standard_error 4 | rescue_from ::Interactor::Failure, with: :handle_interactor_failure 5 | rescue_from ActionController::ParameterMissing, with: :handle_missing_parameter 6 | 7 | protected 8 | 9 | def handle_standard_error(error) 10 | render_error(:authentication, 'error', error.message.truncate(512), status: 500) 11 | end 12 | 13 | def handle_missing_parameter(error) 14 | render_error(:authentication, 'missing_parameter', error.message) 15 | end 16 | 17 | def handle_interactor_failure(failure) 18 | render_error(:authentication, failure.context.code, failure.context.message) 19 | end 20 | 21 | private 22 | 23 | def render_error(context, code, message, status: :unprocessable_entity) 24 | render json: { error: { context:, code:, message: } }, status: 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/validate_auth_token.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class ValidateAuthToken 3 | include Interactor 4 | 5 | delegate :auth_token, to: :context 6 | 7 | def call 8 | context.fail!(code: :missing_token, message: "X-Auth header is required") if auth_token.blank? 9 | 10 | context.agent = fetch_agent 11 | end 12 | 13 | private 14 | 15 | def fetch_agent 16 | agent = Agent.find_by(id: payload['agent_id']) 17 | context.fail!(code: :invalid_token, message: "Invalid token - no agent exists with agent_id") if agent.blank? 18 | 19 | agent 20 | end 21 | 22 | def payload 23 | JWT.decode(auth_token, 24 | PasskeysRails.auth_token_secret, 25 | true, 26 | { required_claims: %w[exp agent_id], algorithm: PasskeysRails.auth_token_algorithm }).first 27 | rescue JWT::ExpiredSignature 28 | context.fail!(code: :expired_token, message: "The token has expired") 29 | rescue StandardError => e 30 | context.fail!(code: :token_error, message: e.message) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Troy Anderson 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require "fileutils" 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path("..", __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts "== Installing dependencies ==" 17 | system! "gem install bundler --conservative" 18 | system("bundle check") || system!("bundle install") 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?("config/database.yml") 22 | # FileUtils.cp "config/database.yml.sample", "config/database.yml" 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! "bin/rails db:prepare" 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! "bin/rails log:clear tmp:clear" 30 | 31 | puts "\n== Restarting application server ==" 32 | system! "bin/rails restart" 33 | end 34 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/debug_login.rb: -------------------------------------------------------------------------------- 1 | # This functionality exists to allow easier mobile app debugging as it may not 2 | # be possible to acess Passkey functionality in mobile simulators. 3 | # It is only operational if DEBUG_LOGIN_REGEX is set in the server environment. 4 | # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment. 5 | module PasskeysRails 6 | class DebugLogin 7 | include Interactor 8 | include Debuggable 9 | 10 | delegate :username, to: :context 11 | 12 | def call 13 | ensure_debug_mode 14 | ensure_regex_match 15 | 16 | context.agent = agent 17 | context.username = agent.username 18 | context.auth_token = GenerateAuthToken.call!(agent:).auth_token 19 | rescue Interactor::Failure => e 20 | context.fail! code: e.context.code, message: e.context.message 21 | end 22 | 23 | private 24 | 25 | def agent 26 | @agent ||= begin 27 | agent = Agent.find_by(username:) 28 | context.fail!(code: :agent_not_found, message: "No agent found with that username") if agent.blank? 29 | agent 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/generator/passkeys_rails/install_generator_spec.rb: -------------------------------------------------------------------------------- 1 | require 'generators/passkeys_rails/install_generator' 2 | 3 | RSpec.describe PasskeysRails::Generators::InstallGenerator do 4 | destination Rails.root.join('tmp') 5 | 6 | def create_temporary_routes_file 7 | FileUtils.mkdir_p(File.join(destination_root, "config")) 8 | 9 | routes_content = <<~CONTENT 10 | Rails.application.routes.draw do 11 | root to: 'application#index' 12 | end 13 | CONTENT 14 | 15 | File.write(File.join(destination_root, "config/routes.rb"), routes_content) 16 | end 17 | 18 | before do 19 | prepare_destination 20 | create_temporary_routes_file 21 | run_generator 22 | end 23 | 24 | it "has the correct structure and contents" do 25 | expect(destination_root).to have_structure { 26 | directory "config" do 27 | directory "initializers" do 28 | file "passkeys_rails.rb" do 29 | contains 'PasskeysRails.config do |c|' 30 | end 31 | end 32 | file 'routes.rb' do 33 | contains 'mount PasskeysRails::Engine => "/passkeys"' 34 | end 35 | end 36 | } 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/content_security_policy.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Define an application-wide content security policy. 4 | # See the Securing Rails Applications Guide for more information: 5 | # https://guides.rubyonrails.org/security.html#content-security-policy-header 6 | 7 | # Rails.application.configure do 8 | # config.content_security_policy do |policy| 9 | # policy.default_src :self, :https 10 | # policy.font_src :self, :https, :data 11 | # policy.img_src :self, :https, :data 12 | # policy.object_src :none 13 | # policy.script_src :self, :https 14 | # policy.style_src :self, :https 15 | # # Specify URI for violation reports 16 | # # policy.report_uri "/csp-violation-report-endpoint" 17 | # end 18 | # 19 | # # Generate session nonces for permitted importmap and inline scripts 20 | # config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } 21 | # config.content_security_policy_nonce_directives = %w(script-src) 22 | # 23 | # # Report violations without enforcing the policy. 24 | # # config.content_security_policy_report_only = true 25 | # end 26 | -------------------------------------------------------------------------------- /spec/requests/passkeys/rails/passkeys_controller_refresh_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::PasskeysController do 2 | let(:call_api) { post '/passkeys_rails/refresh', params:, headers: } 3 | 4 | include_context 'with api params' 5 | 6 | it_behaves_like 'an api that requires some params' 7 | 8 | context 'with required params' do 9 | let(:required_params) { { auth_token: "123" } } 10 | 11 | context 'with valid parameters and a successfull call to RefreshToken' do 12 | before { 13 | allow(PasskeysRails::RefreshToken) 14 | .to receive(:call!) 15 | .and_return(Interactor::Context.build(username: "name", auth_token: "123", agent:)) 16 | } 17 | 18 | let(:agent) { create(:agent) } 19 | 20 | it 'Succeeds and returns instance credentails' do 21 | call_api 22 | 23 | expect_success 24 | expect(PasskeysRails::RefreshToken).to have_received(:call!).exactly(1).time 25 | expect(json.keys).to match_array %w[username auth_token] 26 | expect(json).to include(username: "name", auth_token: "123") 27 | end 28 | 29 | it_behaves_like "a notifier", :did_refresh 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/dummy/config/storage.yml: -------------------------------------------------------------------------------- 1 | test: 2 | service: Disk 3 | root: <%= Rails.root.join("tmp/storage") %> 4 | 5 | local: 6 | service: Disk 7 | root: <%= Rails.root.join("storage") %> 8 | 9 | # Use bin/rails credentials:edit to set the AWS secrets (as aws:access_key_id|secret_access_key) 10 | # amazon: 11 | # service: S3 12 | # access_key_id: <%= Rails.application.credentials.dig(:aws, :access_key_id) %> 13 | # secret_access_key: <%= Rails.application.credentials.dig(:aws, :secret_access_key) %> 14 | # region: us-east-1 15 | # bucket: your_own_bucket-<%= Rails.env %> 16 | 17 | # Remember not to checkin your GCS keyfile to a repository 18 | # google: 19 | # service: GCS 20 | # project: your_project 21 | # credentials: <%= Rails.root.join("path/to/gcs.keyfile") %> 22 | # bucket: your_own_bucket-<%= Rails.env %> 23 | 24 | # Use bin/rails credentials:edit to set the Azure Storage secret (as azure_storage:storage_access_key) 25 | # microsoft: 26 | # service: AzureStorage 27 | # storage_account_name: your_account_name 28 | # storage_access_key: <%= Rails.application.credentials.dig(:azure_storage, :storage_access_key) %> 29 | # container: your_container_name-<%= Rails.env %> 30 | 31 | # mirror: 32 | # service: Mirror 33 | # primary: local 34 | # mirrors: [ amazon, google, microsoft ] 35 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/refresh_token_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::RefreshToken do 2 | let(:call) { described_class.call token: } 3 | let(:agent) { create(:agent) } 4 | let(:token) { nil } 5 | 6 | context "with a valid, non-expired token" do 7 | let(:token) { PasskeysRails::GenerateAuthToken.call(agent:).auth_token } 8 | 9 | it "returns a new auth token that expires in the future" do 10 | result = call 11 | expect(result).to be_success 12 | 13 | token = result.auth_token 14 | expect(token).to be_a String 15 | expect(result.agent).to be_a PasskeysRails::Agent 16 | 17 | payload, = JWT.decode(token, PasskeysRails.auth_token_secret, true, { algorithm: PasskeysRails.auth_token_algorithm }) 18 | 19 | expect(payload['agent_id']).to eq agent.id 20 | expect(payload['exp']).to be_present 21 | expect(Time.zone.at(payload['exp'])).to be_future 22 | end 23 | end 24 | 25 | context "with an bogus token" do 26 | let(:token) { "BOGUS" } 27 | 28 | it_behaves_like "a failing call", :token_error, "Not enough or too many segments" 29 | end 30 | 31 | context "with a valid, but expired token" do 32 | let(:token) { Timecop.freeze(1.year.ago) { PasskeysRails::GenerateAuthToken.call(agent:).auth_token } } 33 | 34 | it_behaves_like "a failing call", :expired_token, "The token has expired" 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::Configuration do 2 | subject(:configuration) { described_class.new } 3 | 4 | it "allows customization auth_token_expires_in" do 5 | new_value = 1.week 6 | 7 | expect { configuration.auth_token_expires_in = new_value } 8 | .to change { configuration.auth_token_expires_in } 9 | .to(new_value) 10 | end 11 | 12 | it "allows customization auth_token_algorithm" do 13 | expect { configuration.auth_token_algorithm = "MY ALGORITHM" } 14 | .to change { configuration.auth_token_algorithm } 15 | .from("HS256") 16 | .to("MY ALGORITHM") 17 | end 18 | 19 | it "allows customization auth_token_secret" do 20 | expect { configuration.auth_token_secret = "MY SECRET" } 21 | .to change { configuration.auth_token_secret } 22 | .from(Rails.application.secret_key_base) 23 | .to("MY SECRET") 24 | end 25 | 26 | it "allows customization default_class" do 27 | expect { configuration.default_class = "AdminUser" } 28 | .to change { configuration.default_class } 29 | .from("User") 30 | .to("AdminUser") 31 | end 32 | 33 | it "allows customization class_whitelist" do 34 | expect { configuration.class_whitelist = %w[User AdminUser] } 35 | .to change { configuration.class_whitelist } 36 | .from(nil) 37 | .to match_array(%w[User AdminUser]) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /RELEASING.md: -------------------------------------------------------------------------------- 1 | # Releasing passkeys-rails 2 | 3 | There're no hard rules about when to release passkeys-rails. Release bug fixes frequently, features not so frequently and breaking API changes rarely. 4 | 5 | ### Release 6 | 7 | Run tests, check that all tests succeed locally. 8 | 9 | ``` 10 | bundle install 11 | bundle exec rake 12 | ``` 13 | 14 | Change "Next" in [CHANGELOG.md](CHANGELOG.md) to the current date. 15 | 16 | ``` 17 | ### 0.2.2 (2024/12/25) 18 | ``` 19 | 20 | Remove the line with "Your contribution here.", since there will be no more contributions to this release. 21 | 22 | Commit your changes. 23 | 24 | ``` 25 | git add CHANGELOG.md 26 | git commit -m "Preparing for release 0.2.2" 27 | git push origin main 28 | ``` 29 | 30 | Release. 31 | 32 | ``` 33 | $ bundle exec rake release 34 | 35 | passkeys-rails 0.2.2 built to pkg/passkeys-rails-0.2.2.gem. 36 | Tagged v0.2.2. 37 | Pushed git commits and tags. 38 | Pushed passkeys-rails 0.2.2 to rubygems.org. 39 | ``` 40 | 41 | ### Prepare for the Next Version 42 | 43 | Add the next release to [CHANGELOG.md](CHANGELOG.md). 44 | 45 | ``` 46 | ### 0.2.3 (Next) 47 | 48 | - Your contribution here. 49 | ``` 50 | 51 | Increment the third version number in [lib/passkeys_rails/version.rb](lib/passkeys_rails/version.rb). 52 | 53 | Commit your changes. 54 | 55 | ``` 56 | git add CHANGELOG.md lib/passkeys_rails/version.rb 57 | git commit -m "Preparing for next development iteration - 0.2.3" 58 | git push origin main 59 | ``` 60 | -------------------------------------------------------------------------------- /lib/passkeys_rails/test/integration_helpers.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | # PasskeysRails::Test::IntegrationHelpers is a helper module for facilitating 3 | # authentication on Rails integration tests to bypass the required steps for 4 | # signin in or signin out a record. 5 | # 6 | # Examples 7 | # 8 | # class PostsTest < ActionDispatch::IntegrationTest 9 | # include PasskeysRails::Test::IntegrationHelpers 10 | # 11 | # test 'authenticated users can see posts' do 12 | # get '/posts', headers: logged_in_headers('username-1') 13 | # assert_response :success 14 | # end 15 | # end 16 | module Test 17 | module IntegrationHelpers 18 | def self.included(base) 19 | base.class_eval do 20 | setup :setup_integration_for_passkeys_rails 21 | teardown :teardown_integration_for_passkeys_rails 22 | end 23 | end 24 | 25 | def logged_in_headers(username, authenticatable = nil, headers: {}) 26 | @agent = Agent.create(username:, registered_at: Time.current, authenticatable:) 27 | result = PasskeysRails::GenerateAuthToken.call(agent:) 28 | raise result.message if result.failure? 29 | 30 | headers.merge("X-Auth" => result.auth_token) 31 | end 32 | 33 | protected 34 | 35 | attr_reader :agent 36 | 37 | def setup_integration_for_passkeys_rails 38 | # Nothing to do here 39 | end 40 | 41 | def teardown_integration_for_passkeys_rails 42 | @agent&.destroy 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/debug_register.rb: -------------------------------------------------------------------------------- 1 | # This functionality exists to allow easier mobile app debugging as it may not 2 | # be possible to acess Passkey functionality in mobile simulators. 3 | # It is only operational if DEBUG_LOGIN_REGEX is set in the server environment. 4 | # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment. 5 | module PasskeysRails 6 | class DebugRegister 7 | include Interactor 8 | include Debuggable 9 | include AuthenticatableCreator 10 | 11 | delegate :username, :authenticatable_info, to: :context 12 | 13 | def call 14 | ensure_debug_mode 15 | ensure_regex_match 16 | 17 | ActiveRecord::Base.transaction do 18 | create_authenticatable! if aux_class_name.present? 19 | end 20 | 21 | context.agent = agent 22 | context.username = agent.username 23 | context.auth_token = GenerateAuthToken.call!(agent:).auth_token 24 | rescue Interactor::Failure => e 25 | context.fail! code: e.context.code, message: e.context.message 26 | end 27 | 28 | private 29 | 30 | def agent 31 | @agent ||= begin 32 | result = BeginRegistration.call(username:) 33 | context.fail!(code: result.code, message: result.message) if result.failure? 34 | 35 | agent = Agent.find_by(username:) 36 | context.fail!(code: :agent_not_found, message: "No agent found with that username") if agent.blank? 37 | 38 | agent.update! registered_at: Time.current 39 | 40 | agent 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/validate_auth_token_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::ValidateAuthToken do 2 | let(:call) { described_class.call auth_token: } 3 | 4 | context "with a valid auth_token" do 5 | let(:agent) { create(:agent) } 6 | let(:auth_token) { PasskeysRails::GenerateAuthToken.call(agent:).auth_token } 7 | 8 | it "returns the agent" do 9 | result = call 10 | expect(result).to be_success 11 | expect(result.agent).to eq agent 12 | end 13 | end 14 | 15 | context "with an bogus auth_token" do 16 | let(:auth_token) { "BOGUS" } 17 | 18 | it_behaves_like "a failing call", :token_error, "Not enough or too many segments" 19 | end 20 | 21 | context "with a JWT with an agent_id, but without an expiration" do 22 | let(:auth_token) { JWT.encode({ agent_id: 123 }, PasskeysRails.auth_token_secret, PasskeysRails.auth_token_algorithm) } 23 | 24 | it_behaves_like "a failing call", :token_error, "Missing required claim exp" 25 | end 26 | 27 | context "with a JWT with an expiration, but without an agent_id" do 28 | let(:auth_token) { JWT.encode({ exp: 1.day.from_now.to_i }, PasskeysRails.auth_token_secret, PasskeysRails.auth_token_algorithm) } 29 | 30 | it_behaves_like "a failing call", :token_error, "Missing required claim agent_id" 31 | end 32 | 33 | context "with a JWT with an expiration, but an agent_id that doesn't exist" do 34 | let(:auth_token) { JWT.encode({ exp: 1.day.from_now.to_i, agent_id: 0 }, PasskeysRails.auth_token_secret, PasskeysRails.auth_token_algorithm) } 35 | 36 | it_behaves_like "a failing call", :invalid_token, "Invalid token - no agent exists with agent_id" 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/requests/passkeys/rails/passkeys_controller_debug_login_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::PasskeysController do 2 | let(:call_api) { post '/passkeys_rails/debug_login', params:, headers: } 3 | 4 | context "when debug_login_regex is empty" do 5 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(nil) } 6 | 7 | let(:params) { {} } 8 | let(:headers) { {} } 9 | 10 | it 'responds that there are no matching routes' do 11 | expect(call_api).to eq 404 12 | end 13 | end 14 | 15 | context "when debug_login_regex has a regex that matches adam-123" do 16 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^adam-\d+$/) } 17 | 18 | include_context 'with api params' 19 | 20 | it_behaves_like 'an api that requires some params' 21 | 22 | context 'with required params' do 23 | let(:required_params) { { username: 'adam-123' } } 24 | 25 | context 'with valid parameters and a successfull call to DebugLogin' do 26 | before { 27 | allow(PasskeysRails::DebugLogin) 28 | .to receive(:call!) 29 | .and_return(Interactor::Context.build(username: "adam-123", auth_token: "456", agent: create(:agent))) 30 | } 31 | 32 | it 'Succeeds and returns instance credentails' do 33 | call_api 34 | 35 | expect_success 36 | expect(PasskeysRails::DebugLogin).to have_received(:call!).exactly(1).time 37 | expect(json.keys).to match_array %w[username auth_token] 38 | expect(json).to include(username: "adam-123", auth_token: "456") 39 | end 40 | 41 | it_behaves_like "a notifier", :did_authenticate 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/requests/passkeys/rails/passkeys_controller_debug_register_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::PasskeysController do 2 | let(:call_api) { post '/passkeys_rails/debug_register', params:, headers: } 3 | 4 | context "when debug_login_regex is empty" do 5 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(nil) } 6 | 7 | let(:params) { {} } 8 | let(:headers) { {} } 9 | 10 | it 'responds that there are no matching routes' do 11 | expect(call_api).to eq 404 12 | end 13 | end 14 | 15 | context "when debug_login_regex has a regex that matches adam-123" do 16 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^adam-\d+$/) } 17 | 18 | include_context 'with api params' 19 | 20 | it_behaves_like 'an api that requires some params' 21 | 22 | context 'with required params' do 23 | let(:required_params) { { username: 'adam-123' } } 24 | 25 | context 'with valid parameters and a successfull call to DebugRegister' do 26 | before { 27 | allow(PasskeysRails::DebugRegister) 28 | .to receive(:call!) 29 | .and_return(Interactor::Context.build(username: "adam-123", auth_token: "456", agent: create(:agent))) 30 | } 31 | 32 | it 'Succeeds and returns instance credentails' do 33 | call_api 34 | 35 | expect_success 36 | expect(PasskeysRails::DebugRegister).to have_received(:call!).exactly(1).time 37 | expect(json.keys).to match_array %w[username auth_token] 38 | expect(json).to include(username: "adam-123", auth_token: "456") 39 | end 40 | 41 | it_behaves_like "a notifier", :did_register 42 | end 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/begin_challenge_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::BeginChallenge do 2 | let(:call) { described_class.call username: } 3 | 4 | context "with all parameters" do 5 | let(:username) { nil } 6 | 7 | context "without a username" do 8 | it "calls BeginAuthentication and returns the challenge, and options" do 9 | options = instance_double(WebAuthn::PublicKeyCredential::RequestOptions, challenge: "CHALLENGE") 10 | context = Interactor::Context.build(options:) 11 | 12 | allow(PasskeysRails::BeginAuthentication) 13 | .to receive(:call!) 14 | .and_return(context) 15 | .exactly(1).time 16 | 17 | result = call 18 | expect(result).to be_success 19 | expect(result.cookie_data[:username]).to be_nil 20 | expect(result.cookie_data[:challenge]).to eq "CHALLENGE" 21 | expect(result.response).to eq options 22 | end 23 | end 24 | 25 | context "with a username" do 26 | let(:username) { "alice" } 27 | 28 | it "calls BeginRegistration and returns the username, challenge, and options" do 29 | options = instance_double(WebAuthn::PublicKeyCredential::CreationOptions, challenge: "CHALLENGE") 30 | context = Interactor::Context.build(options:) 31 | 32 | allow(PasskeysRails::BeginRegistration) 33 | .to receive(:call!) 34 | .with(username:) 35 | .and_return(context) 36 | .exactly(1).time 37 | 38 | result = call 39 | expect(result).to be_success 40 | expect(result.cookie_data[:username]).to eq username 41 | expect(result.cookie_data[:challenge]).to eq "CHALLENGE" 42 | expect(result.response).to eq options 43 | end 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/requests/passkeys/rails/passkeys_controller_challenge_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::PasskeysController do 2 | let(:call_api) { post '/passkeys_rails/challenge', params:, headers: } 3 | 4 | shared_examples "a successful registration initiation process" do 5 | it 'begins the registration process and returns the passkey challenge fields' do 6 | call_api 7 | expect_success 8 | expect(json.keys).to match(%w[challenge timeout extensions rp user pubKeyCredParams]) 9 | end 10 | end 11 | 12 | include_context 'with api params' 13 | 14 | context 'with no params' do 15 | it 'begins the login process and returns the passkey challenge fields' do 16 | call_api 17 | expect_success 18 | expect(json.keys).to match(%w[challenge timeout extensions allowCredentials]) 19 | end 20 | end 21 | 22 | context 'with the username param' do 23 | let(:optional_params) { { username: } } 24 | let(:username) { '' } 25 | 26 | context 'when the username is not yet taken by any agent' do 27 | let(:username) { 'Testing 1 2 3' } 28 | 29 | it_behaves_like "a successful registration initiation process" 30 | end 31 | 32 | context 'when the username is taken by an unregistered agent' do 33 | let(:username) { create(:agent, :unregistered).username } 34 | 35 | it_behaves_like "a successful registration initiation process" 36 | end 37 | 38 | context 'when the username is taken by a registered agent' do 39 | let(:username) { create(:agent, :registered).username } 40 | 41 | it 'fails with an appropriate error' do 42 | call_api 43 | expect(error).to match([:authentication, 'validation_errors', "Username has already been taken"]) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/support/webauthn_helper.rb: -------------------------------------------------------------------------------- 1 | require "webauthn" 2 | require "webauthn/fake_client" 3 | 4 | module Requests 5 | module WebauthnHelpers 6 | def fake_origin 7 | "http://localhost" 8 | end 9 | 10 | def fake_challenge 11 | SecureRandom.random_bytes(32) 12 | end 13 | 14 | def fake_cose_credential_key(algorithm: -7, x_coordinate: nil, y_coordinate: nil) 15 | crv_p256 = 1 16 | 17 | COSE::Key::EC2.new( 18 | alg: algorithm, 19 | crv: crv_p256, 20 | x: x_coordinate || SecureRandom.random_bytes(32), 21 | y: y_coordinate || SecureRandom.random_bytes(32) 22 | ).serialize 23 | end 24 | 25 | def create_credential( 26 | client: WebAuthn::FakeClient.new, 27 | rp_id: nil, 28 | relying_party: WebAuthn.configuration.relying_party 29 | ) 30 | rp_id ||= relying_party.id || URI.parse(client.origin).host 31 | 32 | create_result = client.create(rp_id:) 33 | 34 | attestation_object = 35 | if client.encoding 36 | relying_party.encoder.decode(create_result["response"]["attestationObject"]) 37 | else 38 | create_result["response"]["attestationObject"] 39 | end 40 | 41 | client_data_json = 42 | if client.encoding 43 | relying_party.encoder.decode(create_result["response"]["clientDataJSON"]) 44 | else 45 | create_result["response"]["clientDataJSON"] 46 | end 47 | 48 | response = 49 | WebAuthn::AuthenticatorAttestationResponse 50 | .new( 51 | attestation_object:, 52 | client_data_json:, 53 | relying_party: 54 | ) 55 | 56 | credential_public_key = response.credential.public_key 57 | 58 | [create_result["id"], credential_public_key, response.authenticator_data.sign_count] 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/features/register_new_user_spec.rb: -------------------------------------------------------------------------------- 1 | require "webauthn/attestation_statement/fido_u2f/public_key" 2 | require "webauthn/authenticator_assertion_response" 3 | require "webauthn/u2f_migrator" 4 | 5 | RSpec.describe 'Register new user', type: :request do 6 | let(:challenge_params) { { username: } } 7 | let(:json_content_type_headers) { { 'Content-Type': 'application/json' } } 8 | let(:username) { "Test User" } 9 | 10 | let(:client) { WebAuthn::FakeClient.new(actual_origin, encoding: false) } 11 | let(:origin) { fake_origin } 12 | let(:actual_origin) { origin } 13 | let(:credential) { create_credential(client:) } 14 | let(:credential_public_key) { credential[1] } 15 | let(:authenticatable) { {} } 16 | 17 | it "requests a challenge, follows up with a registration request, and receives a valid auth token" do 18 | post '/passkeys_rails/challenge', params: challenge_params.to_json, headers: json_content_type_headers 19 | expect(response).to be_successful 20 | 21 | account = client.create(challenge: json[:challenge]) 22 | 23 | pending "Figuring out how to create the params from the passkeys_rails/challenge" 24 | # TODO: Not sure how create the params from the passkeys_rails/challenge response that succeed" 25 | credential = { 26 | id: Base64.strict_encode64(account['id']), 27 | rawId: Base64.strict_encode64(account['rawId']), 28 | type: account['type'], 29 | response: { 30 | attestationObject: Base64.strict_encode64(account['response']['attestationObject']), 31 | clientDataJSON: Base64.strict_encode64(account['response']['clientDataJSON']) 32 | } 33 | } 34 | 35 | register_params = { credential:, authenticatable: } 36 | 37 | post '/passkeys_rails/register', params: register_params.to_json, headers: json_content_type_headers 38 | expect(json[:error]).to be_blank 39 | expect(response).to be_successful 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /spec/dummy/public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/finish_authentication.rb: -------------------------------------------------------------------------------- 1 | # Finish authentication ceremony 2 | module PasskeysRails 3 | class FinishAuthentication 4 | include Interactor 5 | 6 | delegate :credential, :challenge, to: :context 7 | 8 | def call 9 | verify_credential! 10 | 11 | context.agent = agent 12 | context.username = agent.username 13 | context.auth_token = GenerateAuthToken.call!(agent:).auth_token 14 | rescue Interactor::Failure => e 15 | context.fail! code: e.context.code, message: e.context.message 16 | end 17 | 18 | private 19 | 20 | def verify_credential! 21 | webauthn_credential.verify( 22 | challenge, 23 | public_key: passkey.public_key, 24 | sign_count: passkey.sign_count 25 | ) 26 | 27 | passkey.update!(sign_count: webauthn_credential.sign_count) 28 | agent.update!(last_authenticated_at: Time.current) 29 | rescue WebAuthn::SignCountVerificationError 30 | # Cryptographic verification of the authenticator data succeeded, but the signature counter was less than or equal 31 | # to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or 32 | # pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter 33 | rescue WebAuthn::Error => e 34 | context.fail!(code: :webauthn_error, message: e.message) 35 | end 36 | 37 | def webauthn_credential 38 | @webauthn_credential ||= WebAuthn::Credential.from_get(credential) 39 | end 40 | 41 | def passkey 42 | @passkey ||= begin 43 | passkey = Passkey.find_by(identifier: webauthn_credential.id) 44 | context.fail!(code: :passkey_not_found, message: "Unable to find the specified passkey") if passkey.blank? 45 | 46 | passkey 47 | end 48 | end 49 | 50 | def agent 51 | passkey.agent 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /app/models/concerns/passkeys_rails/authenticatable_creator.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | module AuthenticatableCreator 3 | extend ActiveSupport::Concern 4 | 5 | protected 6 | 7 | def create_authenticatable! 8 | authenticatable = aux_class.new 9 | authenticatable.agent = agent if authenticatable.respond_to?(:agent=) 10 | authenticatable.registering_with(authenticatable_params) if authenticatable.respond_to?(:registering_with) 11 | authenticatable.save! 12 | 13 | agent.update!(authenticatable:) 14 | rescue ActiveRecord::RecordInvalid => e 15 | context.fail!(code: :record_invalid, message: e.message) 16 | end 17 | 18 | def aux_class_name 19 | @aux_class_name ||= authenticatable_class || PasskeysRails.default_class 20 | end 21 | 22 | private 23 | 24 | def authenticatable_class 25 | authenticatable_info && authenticatable_info[:class] 26 | end 27 | 28 | def authenticatable_params 29 | authenticatable_info && authenticatable_info[:params] 30 | end 31 | 32 | def aux_class 33 | whitelist = PasskeysRails.class_whitelist 34 | 35 | @aux_class ||= begin 36 | if whitelist.is_a?(Array) 37 | unless whitelist.include?(aux_class_name) 38 | context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not in the whitelist") 39 | end 40 | elsif whitelist.present? 41 | context.fail!(code: :invalid_class_whitelist, 42 | message: "class_whitelist is invalid. It should be nil or an array of zero or more class names.") 43 | end 44 | 45 | begin 46 | aux_class_name.constantize 47 | rescue StandardError 48 | context.fail!(code: :invalid_authenticatable_class, message: "authenticatable_class (#{aux_class_name}) is not defined") 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 5) 8 | min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count } 9 | threads min_threads_count, max_threads_count 10 | 11 | # Specifies the `worker_timeout` threshold that Puma will use to wait before 12 | # terminating a worker in development environments. 13 | # 14 | worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" 15 | 16 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 17 | # 18 | port ENV.fetch("PORT", 3000) 19 | 20 | # Specifies the `environment` that Puma will run in. 21 | # 22 | environment ENV.fetch("RAILS_ENV") { "development" } 23 | 24 | # Specifies the `pidfile` that Puma will use. 25 | pidfile ENV.fetch("PIDFILE") { "tmp/pids/server.pid" } 26 | 27 | # Specifies the number of `workers` to boot in clustered mode. 28 | # Workers are forked web server processes. If using threads and workers together 29 | # the concurrency of the application would be max `threads` * `workers`. 30 | # Workers do not work on JRuby or Windows (both of which do not support 31 | # processes). 32 | # 33 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 34 | 35 | # Use the `preload_app!` method when specifying a `workers` number. 36 | # This directive tells Puma to first boot the application and load code 37 | # before forking the application. This takes advantage of Copy On Write 38 | # process behavior so workers use less memory. 39 | # 40 | # preload_app! 41 | 42 | # Allow puma to be restarted by `bin/rails restart` command. 43 | plugin :tmp_restart 44 | -------------------------------------------------------------------------------- /spec/dummy/public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/debug_login_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::DebugLogin do 2 | let(:call) { described_class.call username: } 3 | 4 | context "with all parameters" do 5 | let(:username) { nil } 6 | 7 | context "with an agent" do 8 | let(:agent) { create(:agent, username: 'adam-123') } 9 | 10 | context "when the username matches the agent" do 11 | let(:username) { agent.username } 12 | 13 | context "with an empty debug_login_regex" do 14 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(nil) } 15 | 16 | it_behaves_like "a failing call", :not_allowed, "Action not allowed" 17 | end 18 | 19 | context "with a debug_login_regex that does not match the username" do 20 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^eve-\d+$/) } 21 | 22 | it_behaves_like "a failing call", :not_allowed, "Invalid username" 23 | end 24 | 25 | context "with a debug_login_regex that matches the username" do 26 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^adam-\d+$/) } 27 | 28 | it "returns the username and auth token" do 29 | result = call 30 | expect(result).to be_success 31 | expect(result.username).to eq agent.username 32 | expect(result.auth_token).to be_present 33 | expect(result.agent).to be_a PasskeysRails::Agent 34 | end 35 | end 36 | 37 | context "when the username doesn't match the agent" do 38 | let(:username) { "kane-123" } 39 | 40 | context "with a debug_login_regex that matches the username" do 41 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^kane-\d+$/) } 42 | 43 | it_behaves_like "a failing call", :agent_not_found, "No agent found with that username" 44 | end 45 | end 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.3.4 (Next) 2 | 3 | - Your contribution here. 4 | 5 | ### 0.3.3 (2025/01/13) 6 | 7 | - Avoid double encoding the auth challenge 8 | - Relax rails version constraint 9 | 10 | ### 0.3.2 (2024/10/01) 11 | 12 | - Changed from unsigned cookies to short lived signed cookies 13 | - Added RELEASING.md 14 | 15 | ### 0.3.1 (2023/11/30) 16 | 17 | - Fixed a bug in reading session/cookie variables 18 | - Added webauthn configuration parameters to this gem's configuration 19 | - Moved configuration to its own class 20 | - Added more info to the README 21 | 22 | ### 0.3.0 (2023/08/01) 23 | 24 | - Added debug_register endpoint. 25 | - Fixed authenticatable_params for register enpoint. 26 | - Added notifications to certain controller actions. 27 | - Improved spec error helper. 28 | 29 | ### 0.2.1 (2023/07/29) 30 | 31 | Added ability to pass either the auth token string or a request with one in the header to authenticate methods. 32 | 33 | ### 0.2.0 (2023/07/28) 34 | 35 | - Added passkeys/debug_login functionality. 36 | 37 | ### 0.1.7 (2023/07/26) 38 | 39 | - Added IntegrationHelpers to support client testing. 40 | - Updated methods for interfacing with Rails client app. 41 | - Changed route path added by the generator. 42 | 43 | ### 0.1.6 (2023/07/26) 44 | 45 | - Added default_class and class_whitelist config parameters. 46 | 47 | ### 0.1.5 (2023/07/24) 48 | 49 | - Updated validation to ensure the agent has completed registration to be considered valid. 50 | 51 | ### 0.1.4 (2023/07/23) 52 | 53 | - Changed namespace from Passkeys::Rails to PasskeysRails 54 | 55 | ### 0.1.3 (2023/07/23) 56 | 57 | - More restructuring and fixed issue where autoloading failed 58 | during client app initialization. 59 | 60 | ### 0.1.2 (2023/07/23) 61 | 62 | - Restructured lib directory. 63 | 64 | - Fixed naming convention for gem/gemspec. 65 | 66 | - Fixed exception handling. 67 | 68 | ### 0.1.1 (2023/07/23) 69 | 70 | - Fixed dependency 71 | 72 | ### 0.1.0 (2023/07/23) 73 | 74 | - Initial release - looking for feedback 75 | -------------------------------------------------------------------------------- /spec/dummy/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /spec/requests/passkeys/rails/passkeys_controller_authenticate_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::PasskeysController do 2 | let(:call_api) { post '/passkeys_rails/authenticate', params:, headers: } 3 | 4 | include_context 'with api params' 5 | 6 | it_behaves_like 'an api that requires some params' 7 | 8 | context 'with required params' do 9 | let(:required_params) { { id: '123', rawId: '123', type: '123', response: auth_response } } 10 | let(:auth_response) { { authenticatorData: '123', clientDataJSON: '{}', signature: '123', userHandle: '123' } } 11 | 12 | context 'with valid parameters and a successfull call to FinishAuthentication' do 13 | let(:agent) { create(:agent) } 14 | 15 | before { 16 | allow(PasskeysRails::FinishAuthentication) 17 | .to receive(:call!) 18 | .and_return(Interactor::Context.build(username: "name", auth_token: "123", agent:)) 19 | } 20 | 21 | it 'Succeeds and returns instance credentails' do 22 | call_api 23 | 24 | expect_success 25 | expect(PasskeysRails::FinishAuthentication).to have_received(:call!).exactly(1).time 26 | expect(json.keys).to match_array %w[username auth_token] 27 | expect(json).to include(username: "name", auth_token: "123") 28 | end 29 | 30 | it_behaves_like "a notifier", :did_authenticate 31 | end 32 | 33 | describe "Unexpected exceptions" do 34 | context 'with a standard error exception' do 35 | it "renders the exception in JSON" do 36 | allow(PasskeysRails::FinishAuthentication).to receive(:call!).and_raise(StandardError.new("This is an exception")) 37 | 38 | call_api 39 | 40 | expect(response.code.to_i).to eq 500 41 | 42 | expect(json[:error]).to be_present 43 | expect(json[:error][:code]).to eq "error" 44 | expect(json[:error][:context]).to eq "authentication" 45 | expect(json[:error][:message]).to eq "This is an exception" 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'dotenv/load' 4 | require 'simplecov' 5 | 6 | SimpleCov.start 'rails' do 7 | add_filter 'spec/' 8 | add_filter '.github/' 9 | add_filter 'lib/generators/passkeys_rails/templates/' 10 | add_filter 'lib/passkeys_rails/version' 11 | end 12 | 13 | if ENV['CI'] == 'true' 14 | require 'codecov' 15 | SimpleCov.formatter = SimpleCov::Formatter::Codecov 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.expect_with :rspec do |expectations| 20 | # This option will default to `true` in RSpec 4. It makes the `description` 21 | # and `failure_message` of custom matchers include text for helper methods 22 | # defined using `chain`, e.g.: 23 | # be_bigger_than(2).and_smaller_than(4).description 24 | # # => "be bigger than 2 and smaller than 4" 25 | # ...rather than: 26 | # # => "be bigger than 2" 27 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 28 | end 29 | 30 | config.mock_with :rspec do |mocks| 31 | # Prevents you from mocking or stubbing a method that does not exist on 32 | # a real object. This is generally recommended, and will default to 33 | # `true` in RSpec 4. 34 | mocks.verify_partial_doubles = true 35 | end 36 | 37 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 38 | # have no way to turn it off -- the option exists only for backwards 39 | # compatibility in RSpec 3). It causes shared context metadata to be 40 | # inherited by the metadata hash of host groups and examples, rather than 41 | # triggering implicit auto-inclusion in groups with matching metadata. 42 | config.shared_context_metadata_behavior = :apply_to_host_groups 43 | 44 | # Enable flags like --only-failures and --next-failure 45 | config.example_status_persistence_file_path = ".rspec_status" 46 | 47 | # Disable RSpec exposing methods globally on `Module` and `main` 48 | config.disable_monkey_patching! 49 | 50 | config.expect_with :rspec do |c| 51 | c.syntax = :expect 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /spec/requests/application_controller_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe ApplicationController do 2 | let(:get_index) { get '/', params:, headers: } 3 | let(:get_home) { get '/home', params:, headers: } 4 | 5 | include_context 'with api params' 6 | 7 | context "without an auth token" do 8 | context 'when visiting the index page' do 9 | it "is unauthorized" do 10 | get_index 11 | expect(response.code.to_i).to eq 401 12 | expect(error).to match([:authentication, 'missing_token', "X-Auth header is required"]) 13 | end 14 | end 15 | 16 | context 'when visiting the home page' do 17 | it "renders, but there's no username" do 18 | get_home 19 | expect(response.code.to_i).to eq 200 20 | expect(json[:username]).to be_nil 21 | end 22 | end 23 | end 24 | 25 | context "with a valid auth token for a registered user" do 26 | let(:agent) { create(:agent, :registered) } 27 | let(:additional_headers) { { 'X-Auth' => PasskeysRails::GenerateAuthToken.call(agent:).auth_token } } 28 | 29 | context 'when visiting the index page' do 30 | it "renders the username" do 31 | get_index 32 | expect(response.code.to_i).to eq 200 33 | expect(json[:username]).to eq agent.username 34 | end 35 | end 36 | 37 | context 'when visiting the home page' do 38 | it "renders the username" do 39 | get_index 40 | expect(response.code.to_i).to eq 200 41 | expect(json[:username]).to eq agent.username 42 | end 43 | end 44 | end 45 | 46 | context "with an auth token for an unregistered user (registration in process, but not complete)" do 47 | context 'when visiting the index page' do 48 | it "is unauthorized" do 49 | get_index 50 | expect(response.code.to_i).to eq 401 51 | expect(error).to match([:authentication, 'missing_token', "X-Auth header is required"]) 52 | end 53 | end 54 | 55 | context 'when visiting the home page' do 56 | it "renders, but there's no username" do 57 | get_home 58 | expect(response.code.to_i).to eq 200 59 | expect(json[:username]).to be_nil 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/requests/passkeys/rails/passkeys_controller_register_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::PasskeysController do 2 | let(:call_api) { post '/passkeys_rails/register', params:, headers: } 3 | 4 | include_context 'with api params' 5 | 6 | it_behaves_like 'an api that requires some params' 7 | 8 | shared_examples "a successful registration" do 9 | it 'Succeeds and returns instance credentails' do 10 | call_api 11 | expect_success 12 | expect(PasskeysRails::FinishRegistration).to have_received(:call!).exactly(1).time 13 | expect(json.keys).to match_array %w[username auth_token] 14 | expect(json).to include(username: "name", auth_token: "123") 15 | end 16 | end 17 | 18 | context 'with required params' do 19 | let(:required_params) { { credential: } } 20 | let(:credential) { { id: '123', rawId: '123', type: 'hmm', response: auth_response } } 21 | let(:auth_response) { { attestationObject: '123', clientDataJSON: '{}' } } 22 | 23 | context 'with valid parameters and a successfull call to FinishLogin' do 24 | let(:agent) { create(:agent) } 25 | let(:interactor_context) { { credential:, authenticatable_info:, username: nil, challenge: nil } } 26 | let(:authenticatable_info) { nil } 27 | 28 | before { 29 | allow(PasskeysRails::FinishRegistration) 30 | .to receive(:call!) 31 | .with(interactor_context) 32 | .and_return(Interactor::Context.build(username: "name", auth_token: "123", agent:)) 33 | } 34 | 35 | it_behaves_like "a successful registration" 36 | it_behaves_like "a notifier", :did_register 37 | 38 | context 'with the optional authenticatable_params' do 39 | let(:optional_params) { { authenticatable: } } 40 | let(:authenticatable) { { class: 'User', params: { some: 'more data' } } } 41 | let(:authenticatable_info) { authenticatable } 42 | 43 | context "when PasskeysRails.default_class is nil" do 44 | before { PasskeysRails.config.default_class = nil } 45 | 46 | it_behaves_like "a successful registration" 47 | it_behaves_like "a notifier", :did_register 48 | end 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /app/interactors/passkeys_rails/finish_registration.rb: -------------------------------------------------------------------------------- 1 | # Finish registration ceremony 2 | module PasskeysRails 3 | class FinishRegistration 4 | include Interactor 5 | include AuthenticatableCreator 6 | 7 | delegate :credential, :username, :challenge, :authenticatable_info, to: :context 8 | 9 | def call 10 | verify_credential! 11 | 12 | agent.transaction do 13 | store_passkey_and_register_agent! 14 | create_authenticatable! if aux_class_name.present? 15 | end 16 | 17 | context.agent = agent 18 | context.username = agent.username 19 | context.auth_token = GenerateAuthToken.call!(agent:).auth_token 20 | rescue Interactor::Failure => e 21 | context.fail! code: e.context.code, message: e.context.message 22 | end 23 | 24 | private 25 | 26 | def verify_credential! 27 | webauthn_credential.verify(challenge) 28 | rescue WebAuthn::Error => e 29 | context.fail!(code: :webauthn_error, message: e.message) 30 | rescue StandardError => e 31 | if e.message == "undefined method `end_with?' for nil:NilClass" 32 | context.fail!(code: :webauthn_error, message: "origin is not set") 33 | else 34 | context.fail!(code: :error, message: e.message) 35 | end 36 | end 37 | 38 | def store_passkey_and_register_agent! 39 | # Store Credential ID, Credential Public Key and Sign Count for future authentications 40 | agent.passkeys.create!( 41 | identifier: webauthn_credential.id, 42 | public_key: webauthn_credential.public_key, 43 | sign_count: webauthn_credential.sign_count 44 | ) 45 | 46 | agent.update! registered_at: Time.current 47 | rescue StandardError => e 48 | context.fail! code: :passkey_error, message: e.message 49 | end 50 | 51 | def webauthn_credential 52 | @webauthn_credential ||= WebAuthn::Credential.from_create(credential) 53 | end 54 | 55 | def agent 56 | @agent ||= begin 57 | agent = Agent.find_by(username:) 58 | context.fail!(code: :agent_not_found, message: "Agent not found for cookie value: \"#{username}\"") if agent.blank? 59 | 60 | agent 61 | end 62 | end 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/begin_registration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::BeginRegistration do 2 | let(:call) { described_class.call username: } 3 | 4 | RSpec.shared_examples 'an agent creator' do 5 | it 'creates a new agent and returns webauthn credential options' do 6 | options = instance_double(WebAuthn::PublicKeyCredential::CreationOptions, challenge: "CHALLENGE") 7 | user_identifier = "SOME ID" 8 | 9 | allow(WebAuthn).to receive(:generate_user_id).and_return(user_identifier) 10 | 11 | allow(WebAuthn::Credential) 12 | .to receive(:options_for_create) 13 | .with(user: { id: user_identifier, name: username }) 14 | .and_return(options) 15 | .exactly(1).time 16 | 17 | expect { 18 | result = call 19 | expect(result).to be_success 20 | expect(result.options).to eq options 21 | expect(PasskeysRails::Agent.unregistered.find_by(username:)).to be_present 22 | } 23 | .not_to change { PasskeysRails::Agent.registered.count } 24 | end 25 | end 26 | 27 | context "with all parameters" do 28 | let(:username) { "alice" } 29 | 30 | context "without an origin set" do 31 | before { WebAuthn.configuration.origin = nil } 32 | 33 | it_behaves_like "a failing call", :origin_error, "config.wa_origin must be set" 34 | end 35 | 36 | context "with an origin set" do 37 | context "when there are no Agents with the username" do 38 | it_behaves_like "an agent creator" 39 | end 40 | 41 | context "when there is already an unregistered Agent with the username" do 42 | let(:username) { create(:agent, :unregistered).username } 43 | 44 | it_behaves_like "an agent creator" 45 | end 46 | 47 | context "when there is already a registered Agent with the username" do 48 | let(:username) { create(:agent, :registered).username } 49 | 50 | it_behaves_like "a failing call", :validation_errors, "Username has already been taken" 51 | end 52 | 53 | context "when the username is empty" do 54 | let(:username) { "" } 55 | 56 | it_behaves_like "a failing call", :validation_errors, "Username can't be blank" 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema[7.0].define(version: 2023_06_24_050358) do 14 | create_table "contacts", force: :cascade do |t| 15 | t.string "first_name" 16 | t.string "last_name" 17 | t.string "phone" 18 | t.string "email" 19 | t.datetime "created_at", null: false 20 | t.datetime "updated_at", null: false 21 | end 22 | 23 | create_table "passkeys_rails_agents", force: :cascade do |t| 24 | t.string "username", null: false 25 | t.string "authenticatable_type" 26 | t.integer "authenticatable_id" 27 | t.string "webauthn_identifier" 28 | t.datetime "registered_at" 29 | t.datetime "last_authenticated_at" 30 | t.datetime "created_at", null: false 31 | t.datetime "updated_at", null: false 32 | t.index ["authenticatable_type", "authenticatable_id"], name: "index_passkeys_rails_agents_on_authenticatable", unique: true 33 | t.index ["username"], name: "index_passkeys_rails_agents_on_username", unique: true 34 | end 35 | 36 | create_table "passkeys_rails_passkeys", force: :cascade do |t| 37 | t.string "identifier" 38 | t.string "public_key" 39 | t.integer "sign_count" 40 | t.integer "agent_id", null: false 41 | t.datetime "created_at", null: false 42 | t.datetime "updated_at", null: false 43 | t.index ["agent_id"], name: "index_passkeys_rails_passkeys_on_agent_id" 44 | end 45 | 46 | create_table "users", force: :cascade do |t| 47 | t.datetime "created_at", null: false 48 | t.datetime "updated_at", null: false 49 | end 50 | 51 | add_foreign_key "passkeys_rails_passkeys", "passkeys_rails_agents", column: "agent_id" 52 | end 53 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # This file is copied to spec/ when you run 'rails generate rspec:install' 2 | require 'spec_helper' 3 | ENV['RAILS_ENV'] ||= 'test' 4 | 5 | # Use the dummy app for testing this gem 6 | require_relative '../spec/dummy/config/environment' 7 | ENV['RAILS_ROOT'] ||= "#{File.dirname(__FILE__)}../../../spec/dummy" 8 | 9 | # Prevent database truncation if the environment is production 10 | abort("The Rails environment is running in production mode!") if Rails.env.production? 11 | require 'rspec/rails' 12 | 13 | # Add additional requires below this line. Rails is not loaded until this point! 14 | require 'bundler' 15 | Bundler.require :default, :development 16 | 17 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each { |f| require f } 18 | 19 | # Checks for pending migrations and applies them before tests are run. 20 | # If you are not using ActiveRecord, you can remove these lines. 21 | begin 22 | ActiveRecord::Migration.maintain_test_schema! 23 | rescue ActiveRecord::PendingMigrationError => e 24 | puts e.to_s.strip 25 | exit 1 26 | end 27 | 28 | RSpec::Matchers.define_negated_matcher :not_change, :change 29 | RSpec::Matchers.define_negated_matcher :not_have_enqueued_job, :have_enqueued_job 30 | 31 | ActiveJob::Base.queue_adapter = :test 32 | 33 | RSpec.configure do |config| 34 | config.use_transactional_fixtures = true 35 | 36 | config.mock_with :rspec 37 | config.order = "random" 38 | 39 | config.infer_spec_type_from_file_location! 40 | 41 | # Filter lines from Rails gems in backtraces. 42 | config.filter_rails_from_backtrace! 43 | # arbitrary gems may also be filtered via: 44 | # config.filter_gems_from_backtrace("gem name") 45 | 46 | # Include our helpers 47 | config.include Requests::JsonHelpers 48 | config.include Requests::APIHelpers 49 | config.include Requests::WebauthnHelpers 50 | 51 | # infer FactoryBot as the base of :create & :build calls 52 | config.include FactoryBot::Syntax::Methods 53 | 54 | # Restore defaults so specs can change them without affecting others 55 | config.after { 56 | PasskeysRails.config.auth_token_secret = Rails.application.secret_key_base 57 | PasskeysRails.config.auth_token_algorithm = "HS256" 58 | PasskeysRails.config.auth_token_expires_in = 30.days 59 | PasskeysRails.config.default_class = "User" 60 | PasskeysRails.config.class_whitelist = nil 61 | WebAuthn.configuration.origin = "http://localhost:3000" 62 | } 63 | end 64 | -------------------------------------------------------------------------------- /lib/passkeys_rails/configuration.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class Configuration 3 | # Secret used to encode the auth token. 4 | # Rails.application.secret_key_base is used if none is defined here. 5 | # Changing this value will invalidate all tokens that have been fetched 6 | # through the API. 7 | attr_accessor :auth_token_secret 8 | 9 | # Algorithm used to generate the auth token. 10 | # Changing this value will invalidate all tokens that have been fetched 11 | # through the API. 12 | attr_accessor :auth_token_algorithm 13 | 14 | # How long the auth token is valid before requiring a refresh or new login. 15 | # Set it to 0 for no expiration (not recommended in production). 16 | attr_accessor :auth_token_expires_in 17 | 18 | # Model to use when creating or authenticating a passkey. 19 | # This can be overridden when calling the API, but if no 20 | # value is supplied when calling the API, this value is used. 21 | # If nil, there is no default, and if none is supplied when 22 | # calling the API, no resource is created other than 23 | # a PaskeysRails::Agent that is used to track the passkey. 24 | # 25 | # This library doesn't assume that there will only be one 26 | # model, but it is a common use case, so setting the 27 | # default_class simplifies the use of the API in that case. 28 | attr_accessor :default_class 29 | 30 | # By providing a class_whitelist, the API will require that 31 | # any supplied class is in the whitelist. If it is not, the 32 | # auth API will return an error. This prevents a caller from 33 | # attempting to create an unintended record on registration. 34 | # If nil, any model will be allowed. 35 | # If [], no model will be allowed. 36 | # This should be an array of symbols or strings, 37 | # for example: %w[User AdminUser] 38 | attr_accessor :class_whitelist 39 | 40 | # webauthn settings 41 | attr_accessor :wa_origin, 42 | :wa_relying_party_name, 43 | :wa_credential_options_timeout, 44 | :wa_rp_id, 45 | :wa_encoding, 46 | :wa_algorithms, 47 | :wa_algorithm 48 | 49 | def initialize 50 | # defaults 51 | @auth_token_secret = Rails.application.secret_key_base 52 | @auth_token_algorithm = "HS256" 53 | @auth_token_expires_in = 30.days 54 | @default_class = "User" 55 | @wa_origin = "https://example.com" 56 | end 57 | 58 | def subscribe(event_name) 59 | PasskeysRails.subscribe(event_name) 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | # Turn false under Spring and add config.action_view.cache_template_loading = true. 12 | config.cache_classes = true 13 | 14 | # Eager loading loads your whole application. When running a single test locally, 15 | # this probably isn't necessary. It's a good idea to do in a continuous integration 16 | # system, or in some way before deploying your code. 17 | config.eager_load = ENV["CI"].present? 18 | 19 | # Configure public file server for tests with Cache-Control for performance. 20 | config.public_file_server.enabled = true 21 | config.public_file_server.headers = { 22 | "Cache-Control" => "public, max-age=#{1.hour.to_i}" 23 | } 24 | 25 | # Show full error reports and disable caching. 26 | config.consider_all_requests_local = true 27 | config.action_controller.perform_caching = false 28 | config.cache_store = :null_store 29 | 30 | # Raise exceptions instead of rendering exception templates. 31 | config.action_dispatch.show_exceptions = false 32 | 33 | # Disable request forgery protection in test environment. 34 | config.action_controller.allow_forgery_protection = false 35 | 36 | # Store uploaded files on the local file system in a temporary directory. 37 | config.active_storage.service = :test 38 | 39 | config.action_mailer.perform_caching = false 40 | 41 | # Tell Action Mailer not to deliver emails to the real world. 42 | # The :test delivery method accumulates sent emails in the 43 | # ActionMailer::Base.deliveries array. 44 | config.action_mailer.delivery_method = :test 45 | 46 | # Print deprecation notices to the stderr. 47 | config.active_support.deprecation = :stderr 48 | 49 | # Raise exceptions for disallowed deprecations. 50 | config.active_support.disallowed_deprecation = :raise 51 | 52 | # Tell Active Support which deprecation messages to disallow. 53 | config.active_support.disallowed_deprecation_warnings = [] 54 | 55 | # Raises error for missing translations. 56 | # config.i18n.raise_on_missing_translations = true 57 | 58 | # Annotate rendered view with file names. 59 | # config.action_view.annotate_rendered_view_with_filenames = true 60 | end 61 | -------------------------------------------------------------------------------- /spec/passkeys_rails_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails do 2 | it "has a version number" do 3 | expect(PasskeysRails::VERSION).not_to be_nil 4 | end 5 | 6 | it "doesn't require a configuration block to have the default configuration" do 7 | expect(described_class.auth_token_algorithm).to eq "HS256" 8 | end 9 | 10 | context "with some valid and invalid auth tokens and requests" do 11 | let(:agent) { create(:agent, :registered) } 12 | let(:valid_token) { PasskeysRails::GenerateAuthToken.call(agent:).auth_token } 13 | let(:invalid_token) { "INVALID" } 14 | let(:valid_headers) { { 'X-Auth' => valid_token } } 15 | let(:invalid_headers) { { 'X-Auth' => invalid_token } } 16 | let(:valid_request) { instance_double(ActionDispatch::Request, headers: valid_headers) } 17 | let(:invalid_request) { instance_double(ActionDispatch::Request, headers: invalid_headers) } 18 | 19 | describe "authenticate!" do 20 | it "authenticates a valid token" do 21 | expect(described_class.authenticate!(valid_token)).to be_nil 22 | end 23 | 24 | it "authenticates a valid request" do 25 | expect(described_class.authenticate!(valid_request)).to be_nil 26 | end 27 | 28 | it "throws an exception on an invalid token String" do 29 | expect { described_class.authenticate!(invalid_token) }.to raise_error(PasskeysRails::Error, "authentication") 30 | end 31 | 32 | it "throws an exception on an invalid token Object" do 33 | expect { described_class.authenticate!(123) }.to raise_error(PasskeysRails::Error, "authentication") 34 | end 35 | 36 | it "throws an exception on an invalid request" do 37 | expect { described_class.authenticate!(invalid_request) }.to raise_error(PasskeysRails::Error, "authentication") 38 | end 39 | end 40 | 41 | describe "authenticate" do 42 | it "authenticates a valid token" do 43 | expect(described_class.authenticate(valid_token)).to be_success 44 | end 45 | 46 | it "authenticates a valid request" do 47 | expect(described_class.authenticate(valid_request)).to be_success 48 | end 49 | 50 | it "returns a context with error information on an invalid token" do 51 | result = described_class.authenticate(invalid_token) 52 | expect(result).to be_failure 53 | expect(result.code).to eq :token_error 54 | end 55 | 56 | it "returns a context with error information on an invalid request" do 57 | result = described_class.authenticate(invalid_request) 58 | expect(result).to be_failure 59 | expect(result.code).to eq :token_error 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable server timing 18 | config.server_timing = true 19 | 20 | # Enable/disable caching. By default caching is disabled. 21 | # Run rails dev:cache to toggle caching. 22 | if Rails.root.join("tmp/caching-dev.txt").exist? 23 | config.action_controller.perform_caching = true 24 | config.action_controller.enable_fragment_cache_logging = true 25 | 26 | config.cache_store = :memory_store 27 | config.public_file_server.headers = { 28 | "Cache-Control" => "public, max-age=#{2.days.to_i}" 29 | } 30 | else 31 | config.action_controller.perform_caching = false 32 | 33 | config.cache_store = :null_store 34 | end 35 | 36 | # Store uploaded files on the local file system (see config/storage.yml for options). 37 | config.active_storage.service = :local 38 | 39 | # Don't care if the mailer can't send. 40 | config.action_mailer.raise_delivery_errors = false 41 | 42 | config.action_mailer.perform_caching = false 43 | 44 | # Print deprecation notices to the Rails logger. 45 | config.active_support.deprecation = :log 46 | 47 | # Raise exceptions for disallowed deprecations. 48 | config.active_support.disallowed_deprecation = :raise 49 | 50 | # Tell Active Support which deprecation messages to disallow. 51 | config.active_support.disallowed_deprecation_warnings = [] 52 | 53 | # Raise an error on page load if there are pending migrations. 54 | config.active_record.migration_error = :page_load 55 | 56 | # Highlight code that triggered database queries in logs. 57 | config.active_record.verbose_query_logs = true 58 | 59 | # Suppress logger output for asset requests. 60 | config.assets.quiet = true 61 | 62 | # Raises error for missing translations. 63 | # config.i18n.raise_on_missing_translations = true 64 | 65 | # Annotate rendered view with file names. 66 | # config.action_view.annotate_rendered_view_with_filenames = true 67 | 68 | # Uncomment if you wish to allow Action Cable access from any origin. 69 | # config.action_cable.disable_request_forgery_protection = true 70 | end 71 | -------------------------------------------------------------------------------- /spec/support/api_helpers.rb: -------------------------------------------------------------------------------- 1 | module Requests 2 | module APIHelpers 3 | def headers 4 | @headers ||= response.headers 5 | end 6 | alias response_headers headers 7 | 8 | def success 9 | expect(error.compact).to be_blank # get better spec output 10 | expect(error.compact).to be_empty 11 | expect(response).to be_successful 12 | end 13 | 14 | # alias of success 15 | def expect_success 16 | success 17 | end 18 | 19 | def error 20 | if error_fields.present? 21 | [error_context&.to_sym, error_code, error_message, error_fields] 22 | else 23 | [error_context&.to_sym, error_code, error_message] 24 | end 25 | end 26 | 27 | def error_context 28 | json.is_a?(Hash) ? json.dig(:error, :context) : nil 29 | end 30 | 31 | def error_code 32 | json.is_a?(Hash) ? json.dig(:error, :code) : nil 33 | end 34 | 35 | def error_message 36 | json.is_a?(Hash) ? json.dig(:error, :message) : nil 37 | end 38 | 39 | def error_fields 40 | return unless json.is_a?(Hash) 41 | 42 | fields = json.dig(:error, :fields) 43 | expect(fields).to be_a(Hash) if fields 44 | fields 45 | end 46 | 47 | RSpec.shared_context 'with api params' do 48 | let(:params) { required_params.merge(optional_params).to_json } 49 | let(:optional_params) { {} } 50 | let(:required_params) { {} } 51 | let(:additional_headers) { {} } 52 | 53 | let(:headers) { { 'Content-Type': 'application/json' }.merge(additional_headers) } 54 | end 55 | 56 | RSpec.shared_examples 'an api that requires some params' do 57 | it 'returns a validation error' do 58 | expect(call_api && error).to match [:authentication, 'missing_parameter', /.+ is missing/] 59 | end 60 | end 61 | 62 | RSpec.shared_examples "a failing call" do |code, message| 63 | it "with a resulting code and message" do 64 | result = call 65 | expect(result).to be_failure 66 | expect(result.code).to eq code 67 | expect(result.message).to match(message) 68 | end 69 | end 70 | 71 | RSpec.shared_examples 'a notifier' do |notification| 72 | it 'notifies subscribers' do 73 | result = {} 74 | 75 | sub = PasskeysRails.subscribe(notification) do |name, agent, request| 76 | result = { name:, agent:, request: } 77 | end 78 | 79 | call_api 80 | expect_success 81 | 82 | expect(result[:name].to_s).to eq notification.to_s 83 | expect(result[:agent]).to be_a PasskeysRails::Agent 84 | expect(result[:request]).to be_an ActionDispatch::Request 85 | 86 | ActiveSupport::Notifications.unsubscribe(sub) 87 | end 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /passkeys_rails.gemspec: -------------------------------------------------------------------------------- 1 | require_relative "lib/passkeys_rails/version" 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "passkeys-rails" 5 | spec.version = PasskeysRails::VERSION 6 | spec.authors = ["Troy Anderson"] 7 | spec.email = ["troy@alliedcode.com"] 8 | spec.homepage = "https://github.com/alliedcode/passkeys-rails" 9 | spec.summary = "PassKey authentication back end with simple API" 10 | spec.description = "Devise is awesome, but we don't need all that UI/UX for PassKeys. This gem is to make it easy to provide a back end that authenticates a mobile front end with PassKeys." 11 | spec.license = "MIT" 12 | spec.required_ruby_version = ">= 3.1" 13 | 14 | spec.metadata["homepage_uri"] = spec.homepage 15 | spec.metadata["source_code_uri"] = "https://github.com/alliedcode/passkeys-rails" 16 | spec.metadata["changelog_uri"] = "https://github.com/alliedcode/passkeys-rails/CHANGELOG.md" 17 | 18 | spec.metadata['rubygems_mfa_required'] = 'true' 19 | 20 | spec.files = Dir.chdir(File.expand_path(__dir__)) do 21 | Dir["{app,config,db,lib}/**/*", "CHANGELOG.md", "MIT-LICENSE", "Rakefile", "README.md"] 22 | end 23 | 24 | spec.add_runtime_dependency 'rails', '>= 7.2' 25 | 26 | spec.add_dependency "interactor", "~> 3.1.2" 27 | spec.add_dependency "jwt", "~> 2.9.1" 28 | spec.add_dependency "webauthn", "~> 3.1.0" 29 | # Temproarily add this to get the latest version of cbor (while we wait for webauthn to update) 30 | spec.add_dependency "cbor", "~> 0.5.9.8" 31 | 32 | spec.add_development_dependency "dotenv", "~> 3.1.4" 33 | spec.add_development_dependency "puma", "~> 6.4.3" 34 | spec.add_development_dependency "rake", "~> 13.2.1" 35 | spec.add_development_dependency "sprockets-rails", "~> 3.5.2" 36 | spec.add_development_dependency "sqlite3", "~> 2.1.0" 37 | 38 | spec.add_development_dependency "codecov", "~> 0.6.0" 39 | spec.add_development_dependency "debug", "~> 1.9.2" 40 | spec.add_development_dependency "simplecov", "~> 0.21.2" 41 | 42 | spec.add_development_dependency "reek", "~> 6.3.0" 43 | 44 | spec.add_development_dependency "factory_bot_rails", "~> 6.4.3" 45 | spec.add_development_dependency "generator_spec", "~> 0.10.0" 46 | spec.add_development_dependency "rspec", "~> 3.13.0" 47 | spec.add_development_dependency "rspec-rails", "~> 7.0.1" 48 | spec.add_development_dependency "timecop", "~> 0.9.10" 49 | 50 | spec.add_development_dependency "rubocop", "~> 1.66.1" 51 | spec.add_development_dependency 'rubocop-performance', "~> 1.22.1" 52 | spec.add_development_dependency "rubocop-rails", "~> 2.26.2" 53 | spec.add_development_dependency 'rubocop-rake', "~> 0.6.0" 54 | spec.add_development_dependency 'rubocop-rspec_rails', "~> 2.30.0" 55 | spec.add_development_dependency 'rubocop-factory_bot', "~> 2.26.1" 56 | 57 | spec.add_development_dependency 'danger-changelog', '~> 0.7.0' 58 | spec.add_development_dependency 'danger-toc', '~> 0.2.0' 59 | end 60 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | - Being respectful and considerate of differing viewpoints and experiences. 12 | - Gracefully accepting constructive criticism. 13 | - Focusing on what is best for the community. 14 | - Showing empathy towards other community members. 15 | 16 | Examples of unacceptable behavior by participants include: 17 | 18 | - The use of sexualized language or imagery and unwelcome sexual attention or advances. 19 | - Trolling, insulting or derogatory comments, and personal or political attacks. 20 | - Public or private harassment against anyone. 21 | - Publishing others' private information, such as physical or electronic addresses, without explicit permission. 22 | - Any other conduct which could reasonably be considered inappropriate in a professional setting. 23 | 24 | ## Our Responsibilities 25 | 26 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 27 | 28 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that do not align with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 29 | 30 | ## Scope 31 | 32 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 33 | 34 | ## Enforcement 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [info@alliedcode.com]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. 37 | 38 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 39 | 40 | ## Attribution 41 | 42 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 43 | 44 | For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq. 45 | -------------------------------------------------------------------------------- /CONTRIBUTION_GUIDELINES.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for considering contributing to PasskeysRails! We welcome your help to improve and enhance this project. Whether it's a bug fix, documentation update, or a new feature, your contributions are valuable to the community. 4 | 5 | To ensure a smooth collaboration, please follow these contribution guidelines when submitting your contributions. 6 | 7 | ## Code of Conduct 8 | 9 | Please note that this project follows the [Code of Conduct](https://github.com/alliedcode/passkeys-rails/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. If you encounter any behavior that violates the code, please report it to the project maintainers. 10 | 11 | ## How to Contribute 12 | 13 | 1. Fork the repository on GitHub. 14 | 15 | 2. Create a new branch for your contribution. Use a descriptive name that reflects the purpose of your changes. 16 | 17 | 3. Make your changes and commit them with clear and concise messages. Remember to follow the project's coding style and guidelines. 18 | 19 | 4. Before submitting a pull request, ensure that your changes pass all existing tests and add relevant tests if applicable. 20 | 21 | 5. Update the documentation if your changes introduce new features, modify existing behavior, or require user instructions. 22 | 23 | 6. Squash your commits into a single logical commit if needed. Keep your commit history clean and focused. 24 | 25 | 7. Submit a pull request against the `main` branch of the original repository. 26 | 27 | 8. Add a comment at the top of the CHANGELOG.md describing the change. 28 | 29 | ## Pull Request Guidelines 30 | 31 | When submitting a pull request, please include the following details: 32 | 33 | - A clear description of the changes you made and the problem it solves. 34 | 35 | - Any relevant issue numbers that your pull request addresses or fixes. 36 | 37 | - The steps to test your changes, so the project maintainers can verify them. 38 | 39 | - Ensure that your pull request title and description are descriptive and informative. 40 | 41 | ## Code Review Process 42 | 43 | All pull requests will undergo a code review process by the project maintainers. We appreciate your patience during this review process. Constructive feedback may be provided, and further changes might be requested. 44 | 45 | ## Contributor License Agreement 46 | 47 | By submitting a pull request, you acknowledge that your contributions will be licensed under the project's [MIT License](https://github.com/alliedcode/passkeys-rails/blob/main/MIT-LICENSE). 48 | 49 | ## Reporting Issues 50 | 51 | If you encounter any bugs, problems, or have suggestions for improvement, please create an issue on the GitHub repository. Provide clear and detailed information about the issue to help us address it efficiently. 52 | 53 | ## Thank You 54 | 55 | Your contributions are valuable, and we sincerely appreciate your efforts to improve PasskeysRails. Together, we can build a better software ecosystem for the community. Thank you for your support and happy contributing! 56 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/finish_authentication_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::FinishAuthentication do 2 | let(:call) { described_class.call credential:, challenge: original_challenge } 3 | 4 | context "with all parameters" do 5 | let(:credential) { nil } 6 | let(:challenge) { nil } 7 | 8 | context "with a passkey" do 9 | let!(:passkey) { create(:passkey) } 10 | 11 | context "when the credential and challenge are valid and match the passkey" do 12 | let(:credential) { { id: '123', rawId: '123', type: 'hmm', response: auth_response } } 13 | let(:auth_response) { { attestationObject: '123', clientDataJSON: '{}' } } 14 | let(:original_challenge) { "CHALLENGE" } 15 | 16 | before do 17 | assertion_credential = instance_double(WebAuthn::PublicKeyCredentialWithAssertion, id: passkey.identifier, sign_count: passkey.sign_count) 18 | allow(WebAuthn::Credential).to receive(:from_get).with(credential).and_return(assertion_credential) 19 | allow(assertion_credential).to receive(:verify).with(original_challenge, public_key: passkey.public_key, sign_count: passkey.sign_count) 20 | end 21 | 22 | it "returns the username, auth token, and agent" do 23 | expect { 24 | result = call 25 | expect(result).to be_success 26 | expect(result.username).to eq passkey.agent.username 27 | expect(result.auth_token).to be_present 28 | expect(result.agent).to be_a PasskeysRails::Agent 29 | } 30 | .to change { passkey.reload.agent.last_authenticated_at } 31 | end 32 | 33 | context "when the passkey has invalid data" do 34 | let(:credential) { { id: '123', rawId: '123', type: 'hmm', response: auth_response } } 35 | let(:auth_response) { { attestationObject: '123', clientDataJSON: '{}' } } 36 | let(:original_challenge) { "CHALLENGE" } 37 | 38 | before do 39 | assertion_credential = instance_double(WebAuthn::PublicKeyCredentialWithAssertion, id: passkey.identifier, sign_count: passkey.sign_count) 40 | allow(WebAuthn::Credential).to receive(:from_get).with(credential).and_return(assertion_credential) 41 | allow(assertion_credential).to receive(:verify) 42 | .with(original_challenge, public_key: passkey.public_key, sign_count: passkey.sign_count) 43 | .and_raise(WebAuthn::Error.new) 44 | end 45 | 46 | it_behaves_like "a failing call", :webauthn_error, "WebAuthn::Error" 47 | end 48 | end 49 | end 50 | 51 | context "without a passkey" do 52 | context "when the credential and challenge are not valid" do 53 | let(:credential) { { id: '123', rawId: '123', type: 'hmm', response: auth_response } } 54 | let(:auth_response) { { attestationObject: '123', clientDataJSON: '{}' } } 55 | let(:original_challenge) { "CHALLENGE" } 56 | 57 | before do 58 | assertion_credential = instance_double(WebAuthn::PublicKeyCredentialWithAssertion, id: 'hmm', sign_count: 0) 59 | allow(WebAuthn::Credential).to receive(:from_get).with(credential).and_return(assertion_credential) 60 | end 61 | 62 | it_behaves_like "a failing call", :passkey_not_found, "Unable to find the specified passkey" 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/passkeys-rails.rb: -------------------------------------------------------------------------------- 1 | # rubocop:disable Naming/FileName 2 | require 'passkeys_rails/engine' 3 | require 'passkeys_rails/configuration' 4 | require 'passkeys_rails/version' 5 | require_relative "generators/passkeys_rails/install_generator" 6 | require 'forwardable' 7 | 8 | module PasskeysRails 9 | module Test 10 | autoload :IntegrationHelpers, 'passkeys_rails/test/integration_helpers' 11 | end 12 | 13 | class << self 14 | extend Forwardable 15 | 16 | def_delegators :config, :auth_token_secret, :auth_token_algorithm, :auth_token_expires_in, :default_class, :class_whitelist 17 | 18 | def config 19 | @config ||= begin 20 | config = Configuration.new 21 | yield(config) if block_given? 22 | apply_webauthn_configuration(config) 23 | 24 | config 25 | end 26 | end 27 | end 28 | 29 | def self.apply_webauthn_configuration(config) 30 | WebAuthn.configure do |c| 31 | c.origin = config.wa_origin 32 | c.rp_name = config.wa_relying_party_name if config.wa_relying_party_name 33 | c.credential_options_timeout = config.wa_credential_options_timeout if config.wa_credential_options_timeout 34 | c.rp_id = config.wa_rp_id if config.wa_rp_id 35 | c.encoding = config.wa_encoding if config.wa_encoding 36 | c.algorithms = config.wa_algorithms if config.wa_algorithms 37 | c.algorithms << config.wa_algorithm if config.wa_algorithm 38 | end 39 | end 40 | 41 | # Convenience method to subscribe to various events in PasskeysRails. 42 | # 43 | # Valid events: 44 | # :did_register 45 | # :did_authenticate 46 | # :did_refresh 47 | # 48 | # Each event will include the event name, current agent and http request. 49 | # For example: 50 | # 51 | # subscribe(:did_register) do |event, agent, request| 52 | # # do something with the agent and/or request 53 | # end 54 | # 55 | def self.subscribe(event_name) 56 | ActiveSupport::Notifications.subscribe("passkeys_rails.#{event_name}") do |name, _start, _finish, _id, payload| 57 | yield(name.gsub(/^passkeys_rails\./, ''), payload[:agent], payload[:request]) if block_given? 58 | end 59 | end 60 | 61 | # This is only used by the debug_login endpoint. 62 | # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment. 63 | def self.debug_login_regex 64 | ENV['DEBUG_LOGIN_REGEX'].present? ? Regexp.new(ENV['DEBUG_LOGIN_REGEX']) : nil 65 | end 66 | 67 | # Returns an Interactor::Context that indicates if the request is authentic. 68 | # 69 | # `request` can be a String on an Http Request 70 | # 71 | # .success? is true if authentic 72 | # .agent is the Passkey::Agent on success 73 | # 74 | # .failure? is true if failed (just the opposite of .success?) 75 | # .code is the error code on failure 76 | # .message is the human readable error message on failure 77 | def self.authenticate(request) 78 | auth_token = if request.is_a?(String) 79 | request 80 | elsif request.respond_to?(:headers) 81 | request.headers['X-Auth'] 82 | else 83 | "" 84 | end 85 | 86 | PasskeysRails::ValidateAuthToken.call(auth_token:) 87 | end 88 | 89 | # Raises a PasskeysRails::Error exception if the request is not authentic. 90 | # `request` can be a String on an Http Request 91 | def self.authenticate!(request) 92 | auth = authenticate(request) 93 | return if auth.success? 94 | 95 | raise PasskeysRails::Error.new(:authentication, 96 | code: auth.code, 97 | message: auth.message) 98 | end 99 | 100 | require 'passkeys_rails/railtie' if defined?(Rails) 101 | end 102 | # rubocop:enable Naming/FileName 103 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require "active_support/core_ext/integer/time" 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | config.action_controller.perform_caching = true 18 | 19 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 20 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 21 | # config.require_master_key = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? 26 | 27 | # Compress CSS using a preprocessor. 28 | # config.assets.css_compressor = :sass 29 | 30 | # Do not fallback to assets pipeline if a precompiled asset is missed. 31 | config.assets.compile = false 32 | 33 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 34 | # config.asset_host = "http://assets.example.com" 35 | 36 | # Specifies the header that your server uses for sending files. 37 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for Apache 38 | # config.action_dispatch.x_sendfile_header = "X-Accel-Redirect" # for NGINX 39 | 40 | # Store uploaded files on the local file system (see config/storage.yml for options). 41 | config.active_storage.service = :local 42 | 43 | # Mount Action Cable outside main process or domain. 44 | # config.action_cable.mount_path = nil 45 | # config.action_cable.url = "wss://example.com/cable" 46 | # config.action_cable.allowed_request_origins = [ "http://example.com", /http:\/\/example.*/ ] 47 | 48 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 49 | # config.force_ssl = true 50 | 51 | # Include generic and useful information about system operation, but avoid logging too much 52 | # information to avoid inadvertent exposure of personally identifiable information (PII). 53 | config.log_level = :info 54 | 55 | # Prepend all log lines with the following tags. 56 | config.log_tags = [:request_id] 57 | 58 | # Use a different cache store in production. 59 | # config.cache_store = :mem_cache_store 60 | 61 | # Use a real queuing backend for Active Job (and separate queues per environment). 62 | # config.active_job.queue_adapter = :resque 63 | # config.active_job.queue_name_prefix = "dummy_production" 64 | 65 | config.action_mailer.perform_caching = false 66 | 67 | # Ignore bad email addresses and do not raise email delivery errors. 68 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 69 | # config.action_mailer.raise_delivery_errors = false 70 | 71 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 72 | # the I18n.default_locale when a translation cannot be found). 73 | config.i18n.fallbacks = true 74 | 75 | # Don't log any deprecations. 76 | config.active_support.report_deprecations = false 77 | 78 | # Use default logging formatter so that PID and timestamp are not suppressed. 79 | config.log_formatter = Logger::Formatter.new 80 | 81 | # Use a different logger for distributed setups. 82 | # require "syslog/logger" 83 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new "app-name") 84 | 85 | if ENV["RAILS_LOG_TO_STDOUT"].present? 86 | logger = ActiveSupport::Logger.new($stdout) 87 | logger.formatter = config.log_formatter 88 | config.logger = ActiveSupport::TaggedLogging.new(logger) 89 | end 90 | 91 | # Do not dump schema after migrations. 92 | config.active_record.dump_schema_after_migration = false 93 | end 94 | -------------------------------------------------------------------------------- /lib/generators/passkeys_rails/templates/passkeys_rails_config.rb: -------------------------------------------------------------------------------- 1 | require 'passkeys-rails' 2 | 3 | PasskeysRails.config do |c| 4 | # Secret used to encode the auth token. 5 | # Changing this value will invalidate all tokens that have been fetched 6 | # through the API. 7 | # Default is the application's `secret_key_base`. You can change it below 8 | # and use your own secret key. 9 | # 10 | # c.auth_token_secret = '<%= SecureRandom.hex(64) %>' 11 | 12 | # Algorithm used to generate the auth token. 13 | # Changing this value will invalidate all tokens that have been fetched 14 | # through the API. 15 | # Default is HS256 16 | # 17 | # c.auth_token_algorithm = "HS256" 18 | 19 | # How long the auth token is valid before requiring a refresh or new login. 20 | # Set it to 0 for no expiration (not recommended in production). 21 | # Default is 30 days 22 | # 23 | # c.auth_token_expires_in = 30.days 24 | 25 | # Model to use when creating or authenticating a passkey. 26 | # This can be overridden when calling the API, but if no 27 | # value is supplied when calling the API, this value is used. 28 | # 29 | # If nil, there is no default, and if none is supplied when 30 | # calling the API, no resource is created other than 31 | # a PaskeysRails::Agent that is used to track the passkey. 32 | # 33 | # This library doesn't assume that there will only be one 34 | # model, but it is a common use case, so setting the 35 | # default_class simplifies the use of the API in that case. 36 | # 37 | # c.default_class = "User" 38 | 39 | # By providing a class_whitelist, the API will require that 40 | # any supplied class is in the whitelist. If it is not, the 41 | # auth API will return an error. This prevents a caller from 42 | # attempting to create an unintended records on registration. 43 | # If nil, any model will be allowed. 44 | # This should be an array of symbols or strings, 45 | # for example: %w[User AdminUser] 46 | # 47 | # c.class_whitelist = nil 48 | 49 | # To subscribe to various events in PasskeysRails, use the subscribe method. 50 | # It can be called multiple times to subscribe to more than one event. 51 | # 52 | # Valid events: 53 | # :did_register 54 | # :did_authenticate 55 | # :did_refresh 56 | # 57 | # Each event will include the event name, current agent and http request. 58 | # 59 | # For example: 60 | # c.subscribe(:did_register) do |event, agent, request| 61 | # puts("#{event} | #{agent.id} | #{request.headers}") 62 | # end 63 | 64 | # PasskeysRails uses webauthn to help with the protocol. 65 | # The following settings are passed throught webauthn. 66 | # wa_origin is the only one requried 67 | 68 | # This value needs to match `window.location.origin` evaluated by 69 | # the User Agent during registration and authentication ceremonies. 70 | # c.wa_origin = ENV['DEFAULT_HOST'] || https://myapp.mydomain.com 71 | 72 | # Relying Party name for display purposes 73 | # c.wa_relying_party_name = "My App Name" 74 | 75 | # Optionally configure a client timeout hint, in milliseconds. 76 | # This hint specifies how long the browser should wait for any 77 | # interaction with the user. 78 | # This hint may be overridden by the browser. 79 | # https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-timeout 80 | # c.wa_credential_options_timeout = 120_000 81 | 82 | # You can optionally specify a different Relying Party ID 83 | # (https://www.w3.org/TR/webauthn/#relying-party-identifier) 84 | # if it differs from the default one. 85 | # 86 | # In this case the default would be "auth.example.com", but you can set it to 87 | # the suffix "example.com" 88 | # 89 | # c.wa_rp_id = "example.com" 90 | 91 | # Configure preferred binary-to-text encoding scheme. This should match the encoding scheme 92 | # used in your client-side (user agent) code before sending the credential to the server. 93 | # Supported values: `:base64url` (default), `:base64` or `false` to disable all encoding. 94 | # 95 | # c.wa_encoding = :base64url 96 | 97 | # Possible values: "ES256", "ES384", "ES512", "PS256", "PS384", "PS512", "RS256", "RS384", "RS512", "RS1" 98 | # Default: ["ES256", "PS256", "RS256"] 99 | # 100 | # c.wa_algorithms = ["ES256", "PS256", "RS256"] 101 | 102 | # Append an algorithm to the existing set 103 | # c.wa_algorithm = "PS512" 104 | end 105 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-rails 3 | - rubocop-performance 4 | - rubocop-rspec 5 | - rubocop-rspec_rails 6 | - rubocop-factory_bot 7 | - rubocop-rake 8 | 9 | AllCops: 10 | NewCops: enable 11 | DisplayCopNames: true 12 | StyleGuideCopsOnly: false 13 | TargetRubyVersion: 3.1 14 | Exclude: 15 | - docs/**/* 16 | - db/**/* 17 | - script/**/* 18 | - bin/**/* 19 | - vendor/**/* 20 | - tmp/**/* 21 | - spec/fixtures/**/* 22 | - config/initializers/**/* 23 | - config/environments/**/* 24 | - config/application.rb 25 | - ./Gemfile 26 | - ./Rakefile 27 | - ./config.ru 28 | - ./node_modules/**/* 29 | - db/schema.rb 30 | - spec/dummy/db/schema.rb 31 | - "*.gemspec" 32 | - rails/* 33 | 34 | Rails/UnknownEnv: 35 | Environments: 36 | - production 37 | - staging 38 | - development 39 | - test 40 | 41 | Gemspec/DevelopmentDependencies: 42 | Enabled: false 43 | 44 | Rails: 45 | Enabled: true 46 | 47 | Rails/HasManyOrHasOneDependent: 48 | Enabled: false 49 | 50 | Rails/SkipsModelValidations: 51 | Enabled: false 52 | 53 | Rails/HasAndBelongsToMany: 54 | Enabled: false 55 | 56 | Rails/InverseOf: 57 | Enabled: false 58 | 59 | Style/Documentation: 60 | Enabled: false 61 | 62 | Layout/TrailingEmptyLines: 63 | Enabled: false 64 | 65 | Layout/MultilineBlockLayout: 66 | Exclude: 67 | - spec/**/* 68 | 69 | Layout/MultilineMethodCallIndentation: 70 | Exclude: 71 | - spec/**/* 72 | 73 | Style/StringLiterals: 74 | Enabled: false 75 | 76 | Style/FrozenStringLiteralComment: 77 | Enabled: false 78 | 79 | Style/ClassAndModuleChildren: 80 | Enabled: false 81 | 82 | Style/MultilineBlockChain: 83 | Enabled: false 84 | 85 | Style/BlockDelimiters: 86 | Exclude: 87 | - spec/**/* 88 | 89 | Style/StructInheritance: 90 | Enabled: false 91 | 92 | Style/ParallelAssignment: 93 | Enabled: false 94 | 95 | Style/RescueModifier: 96 | Enabled: false 97 | 98 | Style/PercentLiteralDelimiters: 99 | PreferredDelimiters: 100 | default: () 101 | "%i": "[]" 102 | "%I": "[]" 103 | "%r": "{}" 104 | "%w": "[]" 105 | "%W": "[]" 106 | 107 | Style/FormatStringToken: 108 | Enabled: false 109 | 110 | Lint/AmbiguousBlockAssociation: 111 | Enabled: false 112 | 113 | Metrics/AbcSize: 114 | Enabled: false 115 | 116 | Layout/LineLength: 117 | Enabled: true 118 | Max: 150 119 | Exclude: 120 | - spec/**/* 121 | 122 | Metrics/MethodLength: 123 | Enabled: true 124 | Max: 20 125 | Exclude: 126 | - spec/**/* 127 | 128 | Metrics/BlockLength: 129 | Exclude: 130 | - app/admin/**/* 131 | - spec/**/* 132 | - lib/tasks/**/* 133 | 134 | # Metrics/ClassLength: 135 | # Exclude: 136 | 137 | # Metrics/ParameterLists: 138 | # Exclude: 139 | 140 | # Metrics/ModuleLength: 141 | # Exclude: 142 | 143 | Style/HashEachMethods: 144 | Enabled: true 145 | 146 | Style/HashTransformKeys: 147 | Enabled: true 148 | 149 | Style/HashTransformValues: 150 | Enabled: true 151 | 152 | Layout/SpaceAroundMethodCallOperator: 153 | Enabled: true 154 | 155 | Lint/RaiseException: 156 | Enabled: true 157 | 158 | Lint/StructNewOverride: 159 | Enabled: true 160 | 161 | Style/ExponentialNotation: 162 | Enabled: true 163 | 164 | Layout/EmptyLinesAroundAttributeAccessor: 165 | Enabled: false 166 | 167 | Lint/DeprecatedOpenSSLConstant: 168 | Enabled: true 169 | 170 | Lint/MixedRegexpCaptureTypes: 171 | Enabled: true 172 | 173 | Style/RedundantRegexpCharacterClass: 174 | Enabled: true 175 | 176 | Style/RedundantRegexpEscape: 177 | Enabled: true 178 | 179 | Style/SlicingWithRange: 180 | Enabled: true 181 | 182 | Style/EvalWithLocation: 183 | Enabled: false 184 | 185 | Naming/VariableNumber: 186 | Enabled: false 187 | EnforcedStyle: snake_case 188 | 189 | Naming/MemoizedInstanceVariableName: 190 | Enabled: true 191 | 192 | Lint/EmptyBlock: 193 | Exclude: 194 | - spec/factories/**/* 195 | 196 | Rails/FilePath: 197 | Enabled: false 198 | 199 | RSpec/SpecFilePathFormat: 200 | Enabled: false 201 | 202 | RSpec/MultipleMemoizedHelpers: 203 | Exclude: 204 | - spec/**/* 205 | 206 | RSpec/MultipleExpectations: 207 | Enabled: false 208 | 209 | RSpec/ExpectChange: 210 | Enabled: false 211 | 212 | RSpec/NestedGroups: 213 | Max: 10 214 | 215 | RSpec/RepeatedExampleGroupBody: 216 | Enabled: false 217 | 218 | RSpec/ExampleLength: 219 | Enabled: false 220 | 221 | RSpecRails/InferredSpecType: 222 | Enabled: false 223 | -------------------------------------------------------------------------------- /app/controllers/passkeys_rails/passkeys_controller.rb: -------------------------------------------------------------------------------- 1 | module PasskeysRails 2 | class PasskeysController < ApplicationController 3 | skip_before_action :verify_authenticity_token 4 | wrap_parameters false 5 | 6 | def challenge 7 | result = PasskeysRails::BeginChallenge.call!(username: challenge_params[:username]) 8 | 9 | # Store the challenge so we can verify the future register or authentication request 10 | cookies.signed[:passkeys_rails] = { 11 | value: result.cookie_data.to_json, 12 | expire: Time.now.utc + (result.response.timeout / 1000), 13 | secure: true, 14 | httponly: true, 15 | same_site: :strict 16 | } 17 | 18 | render json: result.response.as_json 19 | end 20 | 21 | def register 22 | cookie_data = JSON.parse(cookies.signed["passkeys_rails"] || "{}") 23 | result = PasskeysRails::FinishRegistration.call!(credential: attestation_credential_params.to_h, 24 | authenticatable_info: authenticatable_params&.to_h, 25 | username: cookie_data["username"], 26 | challenge: cookie_data["challenge"]) 27 | 28 | broadcast(:did_register, agent: result.agent) 29 | 30 | render json: auth_response(result) 31 | end 32 | 33 | def authenticate 34 | cookie_data = JSON.parse(cookies.signed["passkeys_rails"] || "{}") 35 | result = PasskeysRails::FinishAuthentication.call!(credential: authentication_params.to_h, 36 | challenge: cookie_data["challenge"]) 37 | 38 | broadcast(:did_authenticate, agent: result.agent) 39 | 40 | render json: auth_response(result) 41 | end 42 | 43 | def refresh 44 | result = PasskeysRails::RefreshToken.call!(token: refresh_params[:auth_token]) 45 | 46 | broadcast(:did_refresh, agent: result.agent) 47 | 48 | render json: auth_response(result) 49 | end 50 | 51 | # This action exists to allow easier mobile app debugging as it may not 52 | # be possible to acess Passkey functionality in mobile simulators. 53 | # It is only routable if DEBUG_LOGIN_REGEX is set in the server environment. 54 | # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment. 55 | def debug_login 56 | result = PasskeysRails::DebugLogin.call!(username: debug_login_params[:username]) 57 | 58 | broadcast(:did_authenticate, agent: result.agent) 59 | 60 | render json: auth_response(result) 61 | end 62 | 63 | # This action exists to allow easier mobile app debugging as it may not 64 | # be possible to acess Passkey functionality in mobile simulators. 65 | # It is only routable if DEBUG_LOGIN_REGEX is set in the server environment. 66 | # CAUTION: It is very insecure to set DEBUG_LOGIN_REGEX in a production environment. 67 | def debug_register 68 | result = PasskeysRails::DebugRegister.call!(username: debug_login_params[:username], 69 | authenticatable_info: authenticatable_params&.to_h) 70 | 71 | broadcast(:did_register, agent: result.agent) 72 | 73 | render json: auth_response(result) 74 | end 75 | 76 | protected 77 | 78 | def auth_response(result) 79 | { username: result.username, auth_token: result.auth_token } 80 | end 81 | 82 | def challenge_params 83 | params.permit(:username) 84 | end 85 | 86 | def attestation_credential_params 87 | credential = params.require(:credential) 88 | credential.require(%i[id rawId type response]) 89 | credential.require(:response).require(%i[attestationObject clientDataJSON]) 90 | credential.permit(:id, :rawId, :type, { response: %i[attestationObject clientDataJSON] }) 91 | end 92 | 93 | def authenticatable_params 94 | params.require(:authenticatable).permit(:class, params: {}) if params[:authenticatable].present? 95 | end 96 | 97 | def authentication_params 98 | params.require(%i[id rawId type response]) 99 | params.require(:response).require(%i[authenticatorData clientDataJSON signature userHandle]) 100 | params.permit(:id, :rawId, :type, { response: %i[authenticatorData clientDataJSON signature userHandle] }) 101 | end 102 | 103 | def refresh_params 104 | params.require(:auth_token) 105 | params.permit(:auth_token) 106 | end 107 | 108 | def debug_login_params 109 | params.require(:username) 110 | params.permit(:username) 111 | end 112 | 113 | def broadcast(event_name, agent:) 114 | ActiveSupport::Notifications.instrument("passkeys_rails.#{event_name}", { agent:, request: }) 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/debug_register_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::DebugRegister do 2 | let(:call) { described_class.call username:, authenticatable_info: } 3 | 4 | context "with all parameters" do 5 | let(:username) { "adam-123" } 6 | let(:authenticatable_info) { { class: authenticatable_class, params: authenticatable_params } } 7 | let(:authenticatable_class) { nil } 8 | let(:authenticatable_params) { nil } 9 | let(:resulting_agent) { PasskeysRails::Agent.registered.find_by(username:) } 10 | 11 | shared_examples "a successful call" do |username| 12 | it "creates the agent and returns the username and auth token" do 13 | expect { 14 | result = call 15 | expect(result).to be_success 16 | expect(result.username).to eq username 17 | expect(result.auth_token).to be_present 18 | expect(result.agent).to be_a PasskeysRails::Agent 19 | 20 | expect(resulting_agent).to be_present 21 | } 22 | .to change { PasskeysRails::Agent.registered.count }.by(1) 23 | end 24 | end 25 | 26 | shared_examples "a related user creator" do 27 | it "creates a related user" do 28 | expect { expect(call).to be_success } 29 | .to change { User.count }.by(1) 30 | end 31 | 32 | it "relates a user to the resulting agent" do 33 | expect(call).to be_success 34 | expect(resulting_agent.authenticatable.class.name).to eq "User" 35 | end 36 | end 37 | 38 | shared_examples "only an agent creator" do 39 | it "doesn't change the user count" do 40 | expect { expect(call).to be_success } 41 | .to not_change { User.count } 42 | end 43 | 44 | it "doesn't relates a user to the resulting agent" do 45 | expect(call).to be_success 46 | expect(resulting_agent.authenticatable).to be_blank 47 | end 48 | end 49 | 50 | describe "debug_login_regex" do 51 | context "when there are no Agents with the username" do 52 | context "with an empty debug_login_regex" do 53 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(nil) } 54 | 55 | it_behaves_like "a failing call", :not_allowed, "Action not allowed" 56 | end 57 | 58 | context "with a debug_login_regex that does not match the username" do 59 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^eve-\d+$/) } 60 | 61 | it_behaves_like "a failing call", :not_allowed, "Invalid username" 62 | end 63 | 64 | context "with a debug_login_regex that matches the username" do 65 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^adam-\d+$/) } 66 | 67 | it_behaves_like "a successful call", "adam-123" 68 | end 69 | end 70 | end 71 | 72 | context "with a debug_login_regex that matches the username" do 73 | before { allow(PasskeysRails).to receive(:debug_login_regex).and_return(/^adam-\d+$/) } 74 | 75 | context "when there is already an unregistered Agent with the username" do 76 | before { create(:agent, :unregistered, username:) } 77 | 78 | it_behaves_like "a successful call", "adam-123" 79 | end 80 | 81 | context "when there is already a registered Agent with the username" do 82 | before { create(:agent, :registered, username:) } 83 | 84 | it_behaves_like "a failing call", :validation_errors, "Username has already been taken" 85 | end 86 | 87 | describe 'authenticatable_class' do 88 | context "when there are no Agents with the username" do 89 | context "when the authenticatable_class is not supplied" do 90 | let(:authenticatable_info) { nil } 91 | 92 | context "when the default_class is nil" do 93 | before { PasskeysRails.config.default_class = nil } 94 | 95 | it_behaves_like "a successful call", "adam-123" 96 | it_behaves_like "only an agent creator" 97 | end 98 | 99 | context "when the default_class is User" do 100 | before { PasskeysRails.config.default_class = "User" } 101 | 102 | context "when the class_whitelist is nil" do 103 | before { PasskeysRails.config.class_whitelist = nil } 104 | 105 | it_behaves_like "a successful call", "adam-123" 106 | it_behaves_like "a related user creator" 107 | end 108 | 109 | context "when the class_whitelist is an empty array" do 110 | before { PasskeysRails.config.class_whitelist = [] } 111 | 112 | it_behaves_like "a failing call", :invalid_authenticatable_class, "authenticatable_class (User) is not in the whitelist" 113 | end 114 | 115 | context "when the class_whitelist is an array, but User is not in it" do 116 | before { PasskeysRails.config.class_whitelist = %w[Account AdminUser] } 117 | 118 | it_behaves_like "a failing call", :invalid_authenticatable_class, "authenticatable_class (User) is not in the whitelist" 119 | end 120 | 121 | context "when the class_whitelist includes User" do 122 | before { PasskeysRails.config.class_whitelist = %w[Account User AdminUser] } 123 | 124 | it_behaves_like "a successful call", "adam-123" 125 | it_behaves_like "a related user creator" 126 | end 127 | 128 | context "when the class_whitelist is a string (invalid)" do 129 | before { PasskeysRails.config.class_whitelist = "invalid" } 130 | 131 | it_behaves_like "a failing call", :invalid_class_whitelist, "class_whitelist is invalid. It should be nil or an array of zero or more class names." 132 | end 133 | end 134 | end 135 | 136 | context "when the authenticatable_class is a valid class" do 137 | let(:authenticatable_class) { "User" } 138 | 139 | it_behaves_like "a successful call", "adam-123" 140 | it_behaves_like "a related user creator" 141 | end 142 | 143 | context "when the authenticatable_class matches a class that doesn't pass validation when created" do 144 | let(:authenticatable_class) { "Contact" } 145 | 146 | it_behaves_like "a failing call", :record_invalid, /Validation failed:/ 147 | end 148 | 149 | context "when the authenticatable_class doesn't match any known classes" do 150 | let(:authenticatable_class) { "Unknown" } 151 | 152 | it_behaves_like "a failing call", :invalid_authenticatable_class, "authenticatable_class (Unknown) is not defined" 153 | end 154 | end 155 | end 156 | end 157 | end 158 | end 159 | -------------------------------------------------------------------------------- /spec/interactors/passkeys/rails/finish_registration_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe PasskeysRails::FinishRegistration do 2 | let(:call) { described_class.call credential:, username:, challenge: original_challenge, authenticatable_info: } 3 | 4 | shared_examples "a successful call" do |username| 5 | it "updates the agent and returns the username and auth token" do 6 | expect { 7 | result = call 8 | expect(result).to be_success 9 | expect(result.username).to eq username 10 | expect(result.auth_token).to be_present 11 | expect(result.agent).to be_a PasskeysRails::Agent 12 | } 13 | .to change { agent.reload.registered_at }.from(nil) 14 | .and change { agent.reload.registered? }.to(true) 15 | end 16 | end 17 | 18 | shared_examples "a user creator" do 19 | it "passes authenticatable_params to the related user's registering_with method" do 20 | user = User.new 21 | allow(User).to receive(:new).and_return(user) 22 | allow(user).to receive(:agent=).with(agent) 23 | allow(user).to receive(:registering_with).with(authenticatable_params) 24 | 25 | expect(call).to be_success 26 | 27 | expect(User).to have_received(:new).once 28 | expect(user).to have_received(:agent=).once 29 | expect(user).to have_received(:registering_with).once 30 | end 31 | 32 | it "creates a related user" do 33 | expect { expect(call).to be_success } 34 | .to change { User.count }.by(1) 35 | .and change { agent.reload.authenticatable }.from(nil) 36 | end 37 | end 38 | 39 | context "with all parameters" do 40 | let(:authenticatable_info) { { class: authenticatable_class, params: authenticatable_params } } 41 | let(:authenticatable_class) { nil } 42 | let(:authenticatable_params) { nil } 43 | let(:credential) { nil } 44 | let(:username) { nil } 45 | let(:original_challenge) { "CHALLENGE" } 46 | let(:credential_identifier) { "SOME ID" } 47 | 48 | context "when the creedential doesn't verify because the origin is not set" do 49 | before { 50 | attestation_credential = instance_double(WebAuthn::PublicKeyCredentialWithAttestation, id: credential_identifier, public_key: 'pk', sign_count: 0) 51 | allow(WebAuthn::Credential).to receive(:from_create).with(credential).and_return(attestation_credential) 52 | allow(attestation_credential).to receive(:verify).with(original_challenge).and_raise(StandardError.new("undefined method `end_with?' for nil:NilClass")) 53 | } 54 | 55 | it_behaves_like "a failing call", :webauthn_error, "origin is not set" 56 | end 57 | 58 | context "when the credential doesn't verify" do 59 | before { 60 | attestation_credential = instance_double(WebAuthn::PublicKeyCredentialWithAttestation, id: credential_identifier, public_key: 'pk', sign_count: 0) 61 | allow(WebAuthn::Credential).to receive(:from_create).with(credential).and_return(attestation_credential) 62 | allow(attestation_credential).to receive(:verify).with(original_challenge).and_raise(WebAuthn::Error.new) 63 | } 64 | 65 | it_behaves_like "a failing call", :webauthn_error, "WebAuthn::Error" 66 | end 67 | 68 | context "when the credential and challenge are valid" do 69 | let(:credential) { { id: '123', rawId: '123', type: 'hmm', response: auth_response } } 70 | let(:auth_response) { { attestationObject: '123', clientDataJSON: '{}' } } 71 | 72 | before do 73 | attestation_credential = instance_double(WebAuthn::PublicKeyCredentialWithAttestation, id: credential_identifier, public_key: 'pk', sign_count: 0) 74 | allow(WebAuthn::Credential).to receive(:from_create).with(credential).and_return(attestation_credential) 75 | allow(attestation_credential).to receive(:verify).with(original_challenge) 76 | end 77 | 78 | context "when the username matches an existing agent" do 79 | let!(:agent) { create(:agent, username: "Some User") } 80 | let(:username) { agent.username } 81 | 82 | context "when there is already a passkey with the given identifier" do 83 | before { create(:passkey, identifier: credential_identifier) } 84 | 85 | it_behaves_like "a failing call", :passkey_error, /Validation failed:/ 86 | end 87 | 88 | context "when the authenticatable_class is not supplied" do 89 | context "when the default_class is nil" do 90 | before { PasskeysRails.config.default_class = nil } 91 | 92 | it_behaves_like "a successful call", "Some User" 93 | 94 | it "doesn't change the user count" do 95 | expect { expect(call).to be_success } 96 | .to not_change { User.count } 97 | .and not_change { agent.reload.authenticatable }.from(nil) 98 | end 99 | end 100 | 101 | context "when the default_class is User" do 102 | before { PasskeysRails.config.default_class = "User" } 103 | 104 | context "when the class_whitelist is nil" do 105 | before { PasskeysRails.config.class_whitelist = nil } 106 | 107 | it_behaves_like "a successful call", "Some User" 108 | it_behaves_like "a user creator" 109 | 110 | context "when authenticatable_params are supplied" do 111 | let(:authenticatable_params) { { some: 'more data' } } 112 | 113 | it_behaves_like "a successful call", "Some User" 114 | it_behaves_like "a user creator" 115 | end 116 | end 117 | 118 | context "when the class_whitelist is an empty array" do 119 | before { PasskeysRails.config.class_whitelist = [] } 120 | 121 | it_behaves_like "a failing call", :invalid_authenticatable_class, "authenticatable_class (User) is not in the whitelist" 122 | end 123 | 124 | context "when the class_whitelist is an array, but User is not in it" do 125 | before { PasskeysRails.config.class_whitelist = %w[Account AdminUser] } 126 | 127 | it_behaves_like "a failing call", :invalid_authenticatable_class, "authenticatable_class (User) is not in the whitelist" 128 | end 129 | 130 | context "when the class_whitelist includes User" do 131 | before { PasskeysRails.config.class_whitelist = %w[Account User AdminUser] } 132 | 133 | it_behaves_like "a successful call", "Some User" 134 | it_behaves_like "a user creator" 135 | end 136 | 137 | context "when the class_whitelist is a string (invalid)" do 138 | before { PasskeysRails.config.class_whitelist = "invalid" } 139 | 140 | it_behaves_like "a failing call", :invalid_class_whitelist, "class_whitelist is invalid. It should be nil or an array of zero or more class names." 141 | end 142 | end 143 | end 144 | 145 | context "when the authenticatable_class is a valid class" do 146 | let(:authenticatable_class) { "User" } 147 | 148 | context "when PasskeysRails.config.default_class is nil" do 149 | before { PasskeysRails.config.default_class = nil } 150 | 151 | it_behaves_like "a successful call", "Some User" 152 | it_behaves_like "a user creator" 153 | end 154 | 155 | context "when PasskeysRails.config.default_class is a different valid class" do 156 | before { PasskeysRails.config.default_class = "Contact" } 157 | 158 | it_behaves_like "a successful call", "Some User" 159 | it_behaves_like "a user creator" 160 | end 161 | 162 | it_behaves_like "a successful call", "Some User" 163 | it_behaves_like "a user creator" 164 | end 165 | 166 | context "when the authenticatable_class matches a class that doesn't pass validation when created" do 167 | let(:authenticatable_class) { "Contact" } 168 | 169 | it_behaves_like "a failing call", :record_invalid, /Validation failed:/ 170 | 171 | it "doesn't change the agent" do 172 | expect { expect(call).to be_failure }.to not_change { agent.reload } 173 | end 174 | end 175 | 176 | context "when the authenticatable_class doesn't match any known classes" do 177 | let(:authenticatable_class) { "Unknown" } 178 | 179 | it_behaves_like "a failing call", :invalid_authenticatable_class, "authenticatable_class (Unknown) is not defined" 180 | 181 | it "doesn't change the agent" do 182 | expect { expect(call).to be_failure }.to not_change { agent.reload } 183 | end 184 | end 185 | end 186 | 187 | context "when the username doesn't match any agents" do 188 | let(:username) { "somebody else" } 189 | 190 | it_behaves_like "a failing call", :agent_not_found, "Agent not found for cookie value: \"somebody else\"" 191 | end 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | passkeys-rails (0.3.3) 5 | cbor (~> 0.5.9.8) 6 | interactor (~> 3.1.2) 7 | jwt (~> 2.9.1) 8 | rails (>= 7.2) 9 | webauthn (~> 3.1.0) 10 | 11 | GEM 12 | remote: https://rubygems.org/ 13 | specs: 14 | actioncable (7.2.2.1) 15 | actionpack (= 7.2.2.1) 16 | activesupport (= 7.2.2.1) 17 | nio4r (~> 2.0) 18 | websocket-driver (>= 0.6.1) 19 | zeitwerk (~> 2.6) 20 | actionmailbox (7.2.2.1) 21 | actionpack (= 7.2.2.1) 22 | activejob (= 7.2.2.1) 23 | activerecord (= 7.2.2.1) 24 | activestorage (= 7.2.2.1) 25 | activesupport (= 7.2.2.1) 26 | mail (>= 2.8.0) 27 | actionmailer (7.2.2.1) 28 | actionpack (= 7.2.2.1) 29 | actionview (= 7.2.2.1) 30 | activejob (= 7.2.2.1) 31 | activesupport (= 7.2.2.1) 32 | mail (>= 2.8.0) 33 | rails-dom-testing (~> 2.2) 34 | actionpack (7.2.2.1) 35 | actionview (= 7.2.2.1) 36 | activesupport (= 7.2.2.1) 37 | nokogiri (>= 1.8.5) 38 | racc 39 | rack (>= 2.2.4, < 3.2) 40 | rack-session (>= 1.0.1) 41 | rack-test (>= 0.6.3) 42 | rails-dom-testing (~> 2.2) 43 | rails-html-sanitizer (~> 1.6) 44 | useragent (~> 0.16) 45 | actiontext (7.2.2.1) 46 | actionpack (= 7.2.2.1) 47 | activerecord (= 7.2.2.1) 48 | activestorage (= 7.2.2.1) 49 | activesupport (= 7.2.2.1) 50 | globalid (>= 0.6.0) 51 | nokogiri (>= 1.8.5) 52 | actionview (7.2.2.1) 53 | activesupport (= 7.2.2.1) 54 | builder (~> 3.1) 55 | erubi (~> 1.11) 56 | rails-dom-testing (~> 2.2) 57 | rails-html-sanitizer (~> 1.6) 58 | activejob (7.2.2.1) 59 | activesupport (= 7.2.2.1) 60 | globalid (>= 0.3.6) 61 | activemodel (7.2.2.1) 62 | activesupport (= 7.2.2.1) 63 | activerecord (7.2.2.1) 64 | activemodel (= 7.2.2.1) 65 | activesupport (= 7.2.2.1) 66 | timeout (>= 0.4.0) 67 | activestorage (7.2.2.1) 68 | actionpack (= 7.2.2.1) 69 | activejob (= 7.2.2.1) 70 | activerecord (= 7.2.2.1) 71 | activesupport (= 7.2.2.1) 72 | marcel (~> 1.0) 73 | activesupport (7.2.2.1) 74 | base64 75 | benchmark (>= 0.3) 76 | bigdecimal 77 | concurrent-ruby (~> 1.0, >= 1.3.1) 78 | connection_pool (>= 2.2.5) 79 | drb 80 | i18n (>= 1.6, < 2) 81 | logger (>= 1.4.2) 82 | minitest (>= 5.1) 83 | securerandom (>= 0.3) 84 | tzinfo (~> 2.0, >= 2.0.5) 85 | addressable (2.8.7) 86 | public_suffix (>= 2.0.2, < 7.0) 87 | android_key_attestation (0.3.0) 88 | ast (2.4.2) 89 | awrence (1.2.1) 90 | base64 (0.2.0) 91 | benchmark (0.4.0) 92 | bigdecimal (3.1.9) 93 | bindata (2.5.0) 94 | builder (3.3.0) 95 | cbor (0.5.9.8) 96 | claide (1.1.0) 97 | claide-plugins (0.9.2) 98 | cork 99 | nap 100 | open4 (~> 1.3) 101 | codecov (0.6.0) 102 | simplecov (>= 0.15, < 0.22) 103 | colored2 (3.1.2) 104 | concurrent-ruby (1.3.4) 105 | connection_pool (2.5.0) 106 | cork (0.3.0) 107 | colored2 (~> 3.1) 108 | cose (1.3.1) 109 | cbor (~> 0.5.9) 110 | openssl-signature_algorithm (~> 1.0) 111 | crass (1.0.6) 112 | danger (9.5.1) 113 | base64 (~> 0.2) 114 | claide (~> 1.0) 115 | claide-plugins (>= 0.9.2) 116 | colored2 (~> 3.1) 117 | cork (~> 0.1) 118 | faraday (>= 0.9.0, < 3.0) 119 | faraday-http-cache (~> 2.0) 120 | git (~> 1.13) 121 | kramdown (~> 2.3) 122 | kramdown-parser-gfm (~> 1.0) 123 | octokit (>= 4.0) 124 | pstore (~> 0.1) 125 | terminal-table (>= 1, < 4) 126 | danger-changelog (0.7.1) 127 | danger-plugin-api (~> 1.0) 128 | danger-plugin-api (1.0.0) 129 | danger (> 2.0) 130 | danger-toc (0.2.0) 131 | activesupport 132 | danger-plugin-api (~> 1.0) 133 | kramdown 134 | date (3.4.1) 135 | debug (1.9.2) 136 | irb (~> 1.10) 137 | reline (>= 0.3.8) 138 | diff-lcs (1.5.1) 139 | docile (1.4.1) 140 | dotenv (3.1.7) 141 | drb (2.2.1) 142 | dry-configurable (1.3.0) 143 | dry-core (~> 1.1) 144 | zeitwerk (~> 2.6) 145 | dry-core (1.1.0) 146 | concurrent-ruby (~> 1.0) 147 | logger 148 | zeitwerk (~> 2.6) 149 | dry-inflector (1.2.0) 150 | dry-initializer (3.2.0) 151 | dry-logic (1.6.0) 152 | bigdecimal 153 | concurrent-ruby (~> 1.0) 154 | dry-core (~> 1.1) 155 | zeitwerk (~> 2.6) 156 | dry-schema (1.13.4) 157 | concurrent-ruby (~> 1.0) 158 | dry-configurable (~> 1.0, >= 1.0.1) 159 | dry-core (~> 1.0, < 2) 160 | dry-initializer (~> 3.0) 161 | dry-logic (>= 1.4, < 2) 162 | dry-types (>= 1.7, < 2) 163 | zeitwerk (~> 2.6) 164 | dry-types (1.8.0) 165 | bigdecimal (~> 3.0) 166 | concurrent-ruby (~> 1.0) 167 | dry-core (~> 1.0) 168 | dry-inflector (~> 1.0) 169 | dry-logic (~> 1.4) 170 | zeitwerk (~> 2.6) 171 | erubi (1.13.1) 172 | factory_bot (6.5.0) 173 | activesupport (>= 5.0.0) 174 | factory_bot_rails (6.4.4) 175 | factory_bot (~> 6.5) 176 | railties (>= 5.0.0) 177 | faraday (2.12.2) 178 | faraday-net_http (>= 2.0, < 3.5) 179 | json 180 | logger 181 | faraday-http-cache (2.5.1) 182 | faraday (>= 0.8) 183 | faraday-net_http (3.4.0) 184 | net-http (>= 0.5.0) 185 | generator_spec (0.10.0) 186 | activesupport (>= 3.0.0) 187 | railties (>= 3.0.0) 188 | git (1.19.1) 189 | addressable (~> 2.8) 190 | rchardet (~> 1.8) 191 | globalid (1.2.1) 192 | activesupport (>= 6.1) 193 | i18n (1.14.6) 194 | concurrent-ruby (~> 1.0) 195 | interactor (3.1.2) 196 | io-console (0.8.0) 197 | irb (1.14.3) 198 | rdoc (>= 4.0.0) 199 | reline (>= 0.4.2) 200 | json (2.9.1) 201 | jwt (2.9.3) 202 | base64 203 | kramdown (2.5.1) 204 | rexml (>= 3.3.9) 205 | kramdown-parser-gfm (1.1.0) 206 | kramdown (~> 2.0) 207 | language_server-protocol (3.17.0.3) 208 | logger (1.6.5) 209 | loofah (2.24.0) 210 | crass (~> 1.0.2) 211 | nokogiri (>= 1.12.0) 212 | mail (2.8.1) 213 | mini_mime (>= 0.1.1) 214 | net-imap 215 | net-pop 216 | net-smtp 217 | marcel (1.0.4) 218 | mini_mime (1.1.5) 219 | minitest (5.25.4) 220 | nap (1.1.0) 221 | net-http (0.6.0) 222 | uri 223 | net-imap (0.5.5) 224 | date 225 | net-protocol 226 | net-pop (0.1.2) 227 | net-protocol 228 | net-protocol (0.2.2) 229 | timeout 230 | net-smtp (0.5.0) 231 | net-protocol 232 | nio4r (2.7.4) 233 | nokogiri (1.18.1-arm64-darwin) 234 | racc (~> 1.4) 235 | octokit (9.2.0) 236 | faraday (>= 1, < 3) 237 | sawyer (~> 0.9) 238 | open4 (1.3.4) 239 | openssl (3.3.0) 240 | openssl-signature_algorithm (1.3.0) 241 | openssl (> 2.0) 242 | parallel (1.26.3) 243 | parser (3.3.6.0) 244 | ast (~> 2.4.1) 245 | racc 246 | pstore (0.1.4) 247 | psych (5.2.2) 248 | date 249 | stringio 250 | public_suffix (6.0.1) 251 | puma (6.4.3) 252 | nio4r (~> 2.0) 253 | racc (1.8.1) 254 | rack (3.1.8) 255 | rack-session (2.1.0) 256 | base64 (>= 0.1.0) 257 | rack (>= 3.0.0) 258 | rack-test (2.2.0) 259 | rack (>= 1.3) 260 | rackup (2.2.1) 261 | rack (>= 3) 262 | rails (7.2.2.1) 263 | actioncable (= 7.2.2.1) 264 | actionmailbox (= 7.2.2.1) 265 | actionmailer (= 7.2.2.1) 266 | actionpack (= 7.2.2.1) 267 | actiontext (= 7.2.2.1) 268 | actionview (= 7.2.2.1) 269 | activejob (= 7.2.2.1) 270 | activemodel (= 7.2.2.1) 271 | activerecord (= 7.2.2.1) 272 | activestorage (= 7.2.2.1) 273 | activesupport (= 7.2.2.1) 274 | bundler (>= 1.15.0) 275 | railties (= 7.2.2.1) 276 | rails-dom-testing (2.2.0) 277 | activesupport (>= 5.0.0) 278 | minitest 279 | nokogiri (>= 1.6) 280 | rails-html-sanitizer (1.6.2) 281 | loofah (~> 2.21) 282 | nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) 283 | railties (7.2.2.1) 284 | actionpack (= 7.2.2.1) 285 | activesupport (= 7.2.2.1) 286 | irb (~> 1.13) 287 | rackup (>= 1.0.0) 288 | rake (>= 12.2) 289 | thor (~> 1.0, >= 1.2.2) 290 | zeitwerk (~> 2.6) 291 | rainbow (3.1.1) 292 | rake (13.2.1) 293 | rchardet (1.9.0) 294 | rdoc (6.10.0) 295 | psych (>= 4.0.0) 296 | reek (6.3.0) 297 | dry-schema (~> 1.13.0) 298 | parser (~> 3.3.0) 299 | rainbow (>= 2.0, < 4.0) 300 | rexml (~> 3.1) 301 | regexp_parser (2.10.0) 302 | reline (0.6.0) 303 | io-console (~> 0.5) 304 | rexml (3.4.0) 305 | rspec (3.13.0) 306 | rspec-core (~> 3.13.0) 307 | rspec-expectations (~> 3.13.0) 308 | rspec-mocks (~> 3.13.0) 309 | rspec-core (3.13.2) 310 | rspec-support (~> 3.13.0) 311 | rspec-expectations (3.13.3) 312 | diff-lcs (>= 1.2.0, < 2.0) 313 | rspec-support (~> 3.13.0) 314 | rspec-mocks (3.13.2) 315 | diff-lcs (>= 1.2.0, < 2.0) 316 | rspec-support (~> 3.13.0) 317 | rspec-rails (7.0.2) 318 | actionpack (>= 7.0) 319 | activesupport (>= 7.0) 320 | railties (>= 7.0) 321 | rspec-core (~> 3.13) 322 | rspec-expectations (~> 3.13) 323 | rspec-mocks (~> 3.13) 324 | rspec-support (~> 3.13) 325 | rspec-support (3.13.2) 326 | rubocop (1.66.1) 327 | json (~> 2.3) 328 | language_server-protocol (>= 3.17.0) 329 | parallel (~> 1.10) 330 | parser (>= 3.3.0.2) 331 | rainbow (>= 2.2.2, < 4.0) 332 | regexp_parser (>= 2.4, < 3.0) 333 | rubocop-ast (>= 1.32.2, < 2.0) 334 | ruby-progressbar (~> 1.7) 335 | unicode-display_width (>= 2.4.0, < 3.0) 336 | rubocop-ast (1.37.0) 337 | parser (>= 3.3.1.0) 338 | rubocop-factory_bot (2.26.1) 339 | rubocop (~> 1.61) 340 | rubocop-performance (1.22.1) 341 | rubocop (>= 1.48.1, < 2.0) 342 | rubocop-ast (>= 1.31.1, < 2.0) 343 | rubocop-rails (2.26.2) 344 | activesupport (>= 4.2.0) 345 | rack (>= 1.1) 346 | rubocop (>= 1.52.0, < 2.0) 347 | rubocop-ast (>= 1.31.1, < 2.0) 348 | rubocop-rake (0.6.0) 349 | rubocop (~> 1.0) 350 | rubocop-rspec (3.3.0) 351 | rubocop (~> 1.61) 352 | rubocop-rspec_rails (2.30.0) 353 | rubocop (~> 1.61) 354 | rubocop-rspec (~> 3, >= 3.0.1) 355 | ruby-progressbar (1.13.0) 356 | safety_net_attestation (0.4.0) 357 | jwt (~> 2.0) 358 | sawyer (0.9.2) 359 | addressable (>= 2.3.5) 360 | faraday (>= 0.17.3, < 3) 361 | securerandom (0.4.1) 362 | simplecov (0.21.2) 363 | docile (~> 1.1) 364 | simplecov-html (~> 0.11) 365 | simplecov_json_formatter (~> 0.1) 366 | simplecov-html (0.13.1) 367 | simplecov_json_formatter (0.1.4) 368 | sprockets (4.2.1) 369 | concurrent-ruby (~> 1.0) 370 | rack (>= 2.2.4, < 4) 371 | sprockets-rails (3.5.2) 372 | actionpack (>= 6.1) 373 | activesupport (>= 6.1) 374 | sprockets (>= 3.0.0) 375 | sqlite3 (2.1.1-arm64-darwin) 376 | stringio (3.1.2) 377 | terminal-table (3.0.2) 378 | unicode-display_width (>= 1.1.1, < 3) 379 | thor (1.3.2) 380 | timecop (0.9.10) 381 | timeout (0.4.3) 382 | tpm-key_attestation (0.12.1) 383 | bindata (~> 2.4) 384 | openssl (> 2.0) 385 | openssl-signature_algorithm (~> 1.0) 386 | tzinfo (2.0.6) 387 | concurrent-ruby (~> 1.0) 388 | unicode-display_width (2.6.0) 389 | uri (1.0.2) 390 | useragent (0.16.11) 391 | webauthn (3.1.0) 392 | android_key_attestation (~> 0.3.0) 393 | awrence (~> 1.1) 394 | bindata (~> 2.4) 395 | cbor (~> 0.5.9) 396 | cose (~> 1.1) 397 | openssl (>= 2.2) 398 | safety_net_attestation (~> 0.4.0) 399 | tpm-key_attestation (~> 0.12.0) 400 | websocket-driver (0.7.7) 401 | base64 402 | websocket-extensions (>= 0.1.0) 403 | websocket-extensions (0.1.5) 404 | zeitwerk (2.7.1) 405 | 406 | PLATFORMS 407 | arm64-darwin-22 408 | arm64-darwin-24 409 | 410 | DEPENDENCIES 411 | codecov (~> 0.6.0) 412 | danger-changelog (~> 0.7.0) 413 | danger-toc (~> 0.2.0) 414 | debug (~> 1.9.2) 415 | dotenv (~> 3.1.4) 416 | factory_bot_rails (~> 6.4.3) 417 | generator_spec (~> 0.10.0) 418 | passkeys-rails! 419 | puma (~> 6.4.3) 420 | rake (~> 13.2.1) 421 | reek (~> 6.3.0) 422 | rspec (~> 3.13.0) 423 | rspec-rails (~> 7.0.1) 424 | rubocop (~> 1.66.1) 425 | rubocop-factory_bot (~> 2.26.1) 426 | rubocop-performance (~> 1.22.1) 427 | rubocop-rails (~> 2.26.2) 428 | rubocop-rake (~> 0.6.0) 429 | rubocop-rspec_rails (~> 2.30.0) 430 | simplecov (~> 0.21.2) 431 | sprockets-rails (~> 3.5.2) 432 | sqlite3 (~> 2.1.0) 433 | timecop (~> 0.9.10) 434 | tzinfo-data 435 | 436 | BUNDLED WITH 437 | 2.4.21 438 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PasskeysRails - easy to integrate back end for implementing mobile passkeys 2 | 3 | [![Gem Version](https://badge.fury.io/rb/passkeys-rails.svg?cachebust=0.2.1)](https://badge.fury.io/rb/passkeys-rails) 4 | [![Build Status](https://app.travis-ci.com/alliedcode/passkeys-rails.svg?branch=main)](https://travis-ci.org/alliedcode/passkeys-rails) 5 | [![codecov](https://codecov.io/gh/alliedcode/passkeys-rails/branch/main/graph/badge.svg?token=UHSNJDUL21)](https://codecov.io/gh/alliedcode/passkeys-rails) 6 | 7 |

8 | Created by Troy Anderson, Allied Code - alliedcode.com 9 |

10 | 11 | PasskeysRails is a gem you can add to a Rails app to enable passskey registration and authorization from mobile front ends. PasskeysRails leverages webauthn for the cryptographic work, and presents a simple API interface for passkey registration, authentication, and testing. 12 | 13 | The purpose of this gem is to make it easy to provide a rails back end API that supports PassKey authentication. It uses the [`webauthn`](https://github.com/w3c/webauthn) gem to do the cryptographic work and presents a simple API interface for passkey registration and authentication. 14 | 15 | The target use case for this gem is a mobile application that uses a rails based API service to manage resources. The goal is to make it simple to register and authenticate users using passkeys from mobile applications in a rails API service. 16 | 17 | What about [devise](https://github.com/heartcombo/devise)? Devise is awesome, but we don't need all that UI/UX for PassKeys, especially for an API back end. 18 | 19 | ## Documentation 20 | * [Usage](#usage) 21 | * [Installation](#installation) 22 | * [Rails Integration - Standard](#rails-Integration-standard) 23 | * [Rails Integration - Grape](#rails-Integration-grape) 24 | * [Notifications](#notifications) 25 | * [Failure Codes](#failure-codes) 26 | * [Testing](#testing) 27 | * [Mobile App Integration](#mobile-application-integration) 28 | * [Reference/Example Mobile Applications](#referenceexample-mobile-applications) 29 | 30 | ## Usage 31 | 32 | **PasskeysRails** maintains a `PasskeysRails::Agent` model and related `PasskeysRails::Passkeys`. In rails apps that maintain their own "user" model, add `include PasskeysRails::Authenticatable` to that model and include the name of that class (e.g. `"User"`) in the `authenticatable_class` param when calling the register API or set the `PasskeysRails.default_class` to the name of that class. 33 | 34 | In mobile apps, leverage the platform specific Passkeys APIs for ***registration*** and ***authentication***, and call the **PasskeysRails** API endpoints to complete the ceremony. **PasskeysRails** provides endpoints to support ***registration***, ***authentication***, ***token refresh***, and ***debugging***. 35 | 36 | ### Optionally providing a **"user"** model during registration 37 | 38 | **PasskeysRails** does not require any application specific models, but it's often useful to have one. For example, a User model can be created at registration. **PasskeysRails** provides two mechanisms to support this. Either provide the name of the model in the `authenticatable_class` param when calling the `finishRegistration` endpoint, or set a `default_class` in `config/initializers/passkeys_rails.rb`. 39 | 40 | **PasskeysRails** supports multiple different application specific models. Whatever model name supplied when calling the `finishRegistration` endpoint will be created during a successful the `finishRegistration` process. When created, it will be provided an opportunity to do any initialization at that time. 41 | 42 | There are two **PasskeysRails** configuration options related to this: `default_class` and `class_whitelist`: 43 | 44 | #### `default_class` 45 | 46 | Configure `default_class` in `config/initializers/passkeys_rails.rb`. Its value will be used during registration if none is provided in the API call. The default value is `"User"`. Since the `default_class` is just a default, it can be overridden in the `finishRegiration` API call to use a different model. If no model is to be used by default, set it to nil. 47 | 48 | #### `class_whitelist` 49 | 50 | Configure `class_whitelist` in `config/initializers/passkeys_rails.rb`. The default value is `nil`. When `nil`, no whitelist will be applied. If it is non-nil, it should be an array of class names that are allowed during registration. Supply an empty array to prevent **PasskeysRails** from attempting to create anything other than its own `PasskeysRails::Agent` during registration. 51 | 52 | ## Installation 53 | 54 | Add this line to your application's Gemfile: 55 | 56 | ```ruby 57 | gem "passkeys-rails" 58 | ``` 59 | 60 | And then execute: 61 | 62 | ```bash 63 | $ bundle install 64 | ``` 65 | 66 | Or install it yourself as: 67 | ```bash 68 | $ gem install passkeys_rails 69 | ``` 70 | 71 | Finally, execute: 72 | 73 | ```bash 74 | $ rails generate passkeys_rails:install 75 | ``` 76 | 77 | This will add the `config/initializers/passkeys_rails.rb` configuration file, passkeys routes, and a couple of database migrations to your project. 78 | 79 | 80 | 81 | ## Rails Integration

Adding to a standard rails project

82 | 83 | - ### Add `before_action :authenticate_passkey!` 84 | 85 | To prevent access to controller actions, add `before_action :authenticate_passkey!`. If an action is attempted without an authenticated entity, an error will be rendered in JSON with an :unauthorized result code. 86 | 87 | - ### Use `current_agent` and `current_agent.authenticatable` 88 | 89 | To access the currently authenticated entity, use `current_agent`. If you associated the registration of the agent with one of your own models, use `current_agent.authenticatable`. For example, if you associated the `User` class with the registration, `current_agent.authenticatable` will be a User object. 90 | 91 | - ### Add `include PasskeysRails::Authenticatable` to model class(es) 92 | 93 | If you have one or more classes that you want to use with authentication - e.g. a User class and an AdminUser class - add `include PasskeysRails::Authenticatable` to each of those classes. That adds a `registered?` method that you can call on your model to determine if they are registerd with your service, and a `registering_with(params)` method that you can override to initialize attributes of your model when it is created during registration. `params` is a hash with params passed to the API when registering. When called, your object has been built, but not yet saved. Upon return, **PasskeysRails** will attempt to save your object before finishing registration. If it is not valid, the registration will fail as well, returning the error error details to the caller. 94 | 95 | 96 | ## Rails Integration -

Adding to a Grape API rails project

97 | 98 | - ### Call `PasskeysRails.authenticate(request)` to authenticate the request. 99 | 100 | Call `PasskeysRails.authenticate(request)` to get an object back that responds to `.success?` and `.failure?` as well as `.agent`, `.code`, and `.message`. 101 | 102 | Alternatively, call `PasskeysRails.authenticate!(request)` from a helper in your base class. It will raise a `PasskeysRails.Error` exception if the caller isn't authenticated. You can catch the exception and render an appropriate error. The exception contains the error code and message. 103 | 104 | - ### Consider adding the following helpers to your base API class: 105 | 106 | ```ruby 107 | helpers do 108 | # Authenticate the request and cache the result 109 | def passkey 110 | @passkey ||= PasskeysRails.authenticate(request) 111 | end 112 | 113 | # Raise an exception if the request is not authentic 114 | def authenticate_passkey! 115 | error!({ code: passkey.code, message: passkey.message }, :unauthorized) if passkey.failure? 116 | end 117 | 118 | # Return the Passkeys::Agent if authentic, else return nil 119 | def current_agent 120 | passkey.agent 121 | end 122 | 123 | # If you have set authenticatable to be a User, you can use this to access the user from Grape endpoint methods 124 | def current_user 125 | user = current_agent&.authenticatable 126 | user.is_a?(User) ? user : nil # sanity check to be sure authenticatable is a User 127 | end 128 | end 129 | ``` 130 | 131 | To prevent access to various endpoints, add `before_action :authenticate_passkey!` or call `authenticate_passkey!` from any method that requires authentication. If an action is attempted without an authenticated entity, an error will be rendered in JSON with an :unauthorized result code. 132 | 133 | - ### Use `current_agent` and `current_agent.authenticatable` 134 | 135 | To access the currently authenticated entity, use `current_agent`. If you associated the registration of the agent with one of your own models, use `current_agent.authenticatable`. For example, if you associated the `User` class with the registration, `current_agent.authenticatable` will be a User object. 136 | 137 | ## Notifications 138 | 139 | Certain actions trigger notifications that can be subscribed. See `subscribe` in `config/initializers/passkeys_rails.rb`. 140 | 141 | These are completely optional. **PasskeysRails** will manage all the credentials and keys without these being implemented. They are useful for taking application specific actions like logging based on the authentication related events. 142 | 143 | ### Events 144 | 145 | - `:did_register ` - a new agent has registered 146 | 147 | - `:did_authenticate` - an agent has been authenticated 148 | 149 | - `:did_refresh` - an agent's auth token has been refreshed 150 | 151 | A convenient place to set these up in is in `config/initializers/passkeys_rails.rb` 152 | 153 | ```ruby 154 | PasskeysRails.config do |c| 155 | c.subscribe(:did_register) do |event, agent, request| 156 | # do something with the agent and/or request 157 | end 158 | 159 | c.subscribe(:did_authenticate) do |event, agent, request| 160 | # do something with the agent and/or request 161 | end 162 | end 163 | ``` 164 | 165 | Subscriptions can also be done elsewhere as subscribe is a PasskeysRails class method. 166 | 167 | ```ruby 168 | PasskeysRails.subscribe(:did_register) do |event, agent, request| 169 | # do something with the agent and/or request 170 | end 171 | ``` 172 | 173 | ## Failure Codes 174 | 175 | 1. In the event of authentication failure, **PasskeysRails** API endpoints render an error code and message. 176 | 177 | 1. In a standard rails controller, the error code and message are rendered in JSON if `before_action :authenticate_passkey!` fails. 178 | 179 | 1. In Grape, the error code and message are available in the result of the `PasskeysRails.authenticate(request)` method. 180 | 181 | 1. From standard rails controllers, you can also access `passkey_authentication_result` to get the code and message. 182 | 183 | 1. For `PasskeysRails.authenticate(request)` and `passkey_authentication_result`, the result is an object that respods to `.success?` and `.failure?`. 184 | - When `.success?` is true (`.failure?` is false), the resources is authentic and it also responds to `.agent`, returning a PasskeysRails::Agent 185 | - When `.success?` is false (`.failure?` is true), it responds to `.code` and `.message` to expose the error details. 186 | - When `.code` is `:missing_token`, `.message` is **X-Auth header is required**, which means the caller didn't supply the auth header. 187 | - When `.code` is `:invalid_token`, `.message` is **Invalid token - no agent exists with agent_id**, which means that the auth data is not valid. 188 | - When `.code` is `:expired_token`, `.message` is **The token has expired**, which means that the token is valid, but expired, thuis it's not considered authentic. 189 | - When `.code` is `:token_error`, `.message` is a description of the error. This is a catch-all in the event we are unable to decode the token. 190 | 191 | In the future, the intention is to have the `.code` value stay consistent even if the `.message` changes. This also allows you to localize the messages as needed using the code. 192 | 193 | ## Testing 194 | 195 | PasskeysRails includes some test helpers for integration tests. In order to use them, you need to include the module in your test cases/specs. 196 | 197 | Integration test helpers are available by including the `PasskeysRails::IntegrationHelpers` module. 198 | 199 | ```ruby 200 | class PostTests < ActionDispatch::IntegrationTest 201 | include PasskeysRails::Test::IntegrationHelpers 202 | end 203 | ``` 204 | Now you can use the following `logged_in_headers` method in your integration tests.` 205 | 206 | ```ruby 207 | test 'authenticated users can see posts' do 208 | user = User.create 209 | get '/posts', headers: logged_in_headers('username-123', user) 210 | assert_response :success 211 | end 212 | ``` 213 | 214 | RSpec can include the `IntegrationHelpers` module in their `:feature` and `:request` specs. 215 | 216 | ```ruby 217 | RSpec.configure do |config| 218 | config.include PasskeysRails::Test::IntegrationHelpers, type: :feature 219 | config.include PasskeysRails::Test::IntegrationHelpers, type: :request 220 | end 221 | ``` 222 | 223 | ```ruby 224 | RSpec.describe 'Posts', type: :request do 225 | let(:user) { User.create } 226 | it "allows authenticated users to see posts" do 227 | get '/posts', headers: logged_in_headers('username-123', user) 228 | expect(response).to be_success 229 | end 230 | end 231 | ``` 232 | 233 | ## Mobile Application Integration 234 | 235 | ### Prerequisites 236 | 237 | For iOS, you need to associate your app with your server. This amounts to setting up a special file on your server that defines the association. See [setup your apple-app-site-association](#Ensure-`.well-known/apple-app-site-association`-is-in-place) 238 | 239 | 240 | ### Mobile API Endpoints 241 | 242 | There are 3 groups of API endpoints that your mobile application might consume. 243 | 244 | 1. Unauthenticated (public) endpoints 245 | 1. Authenticated (private) endpoints 246 | 1. Passkey endpoints (for supporting authentication) 247 | 248 | **Unauthenticated endpoints** can be consumed without any authentication. 249 | 250 | **Authenticated endpoints** are protected by `authenticate_passkey!` or `PasskeysRails.authenticate!(request)`. Those methods check for and validate the `X-Auth` header, which must be set to the auth token returned in the `AuthResponse`, described below. 251 | 252 | **Passkey endpoints** are supplied by this gem and allow you to register a user, authenticate (login) a user, and refresh the token. This section describes these endpoints. 253 | 254 | This gem supports the Passkey endpoints. 255 | 256 | ### Passkey Endpoints 257 | 258 | * [POST /passkeys/challenge](post-passkeys-challenge) 259 | * [POST /passkeys/register](post-passkeys-register) 260 | * [POST /passkeys/authenticate](post-passkeys-authenticate) 261 | * [POST /passkeys/refresh](post-passkeys-refresh) 262 | * [POST /passkeys/debug_register](post-passkeys-debug-register) 263 | * [POST /passkeys/debug_login](post-passkeys-debug-login) 264 | 265 | All Passkey endpoints accept and respond with JSON. 266 | 267 | On **success**, they will respond with a 200 or 201 response code and relevant JSON. 268 | 269 | On **error**, they will respond with a status code of `422` (Unprocessable Entity) and a JSON `ErrorResponse` structure: 270 | 271 | ```JSON 272 | { 273 | "error": { 274 | "context": "authentication", 275 | "code": "Specific text code", 276 | "message": "Some human readable message" 277 | } 278 | } 279 | ``` 280 | 281 | Some endpoints return an `AuthResponse`, which has this JSON structure: 282 | 283 | ```JSON 284 | { 285 | "username": String, # the username used during registration 286 | "auth_token": String # an expiring token to use to authenticate with the back end (X-Auth header) 287 | } 288 | ``` 289 | 290 | ### POST /passkeys/challenge 291 | 292 | Submit this to begin registration or authentication. 293 | 294 | #### Registration (register) 295 | 296 | To begin registration of a new credential, supply a `{ "username": "unique username" }`. 297 | If all goes well, the JSON response will be the `options_for_create` from webauthn. 298 | If the username is already in use, or anything else goes wrong, an error with code `validation_errors` will be returned. 299 | 300 | After receiving a successful response, follow up with a POST to `/passkeys/register`, below. 301 | 302 | #### Authentication (login) 303 | To begin authenticating an existing credential, omit the `username`. The JSON response will be the `options_for_get` from webauthn. 304 | 305 | After receiving a successful response, follow up with a POST to `/passkeys/authenticate`, below. 306 | 307 | ### POST /passkeys/register 308 | 309 | After calling the `challenge` endpoint with a `username`, and handling its response, finish registering by calling this endpoint. 310 | 311 | Supply the following JSON structure: 312 | 313 | ```JSON 314 | # POST body 315 | { 316 | # NOTE: credential will likely come directly from the PassKeys class/library on the platform 317 | "credential": { 318 | "id": String, 319 | "rawId": String, 320 | "type": String, 321 | "response": { 322 | "attestationObject": String, 323 | "clientDataJSON": String 324 | } 325 | }, 326 | # authenticatable is optional and is informas PasskeysRails how to build your "user" model 327 | "authenticatable": { # optional 328 | "class": "User", # whatever class to which you want this credential to apply (as described earlier) 329 | "params": { } # Any params you want passed as a hash to the registering_with method on that class 330 | } 331 | } 332 | ``` 333 | 334 | On **success**, the response is an `AuthResponse`. 335 | 336 | Possible **failure codes** (using the `ErrorResponse` structure) are: 337 | 338 | - `webauthn_error` - something is wrong with the credential 339 | - `error` - something else went wrong during credentail validation - see the `message` in the `ErrorResponse` 340 | - `passkey_error` - unable to persist the passkey 341 | - `invalid_authenticatable_class` - the supplied authenticatable class can't be created/found (check spelling & capitalization) 342 | - `invalid_class_whitelist` - the whitelist in the passkeys_rails.rb configuration is invalid - be sure it's nil or an array 343 | - `invalid_authenticatable_class` - the supplied authenticatable class is not allowed - maybe it's not in the whitelist 344 | - `record_invalid` - the object of the supplied authenticatable class cannot be saved due to validation errors 345 | - `agent_not_found` - the agent referenced in the credential cannot be found in the database 346 | 347 | ### POST /passkeys/authenticate 348 | 349 | After calling the `challenge` endpoint without a `username`, and handling its response, finish authenticating by calling this endpoint. 350 | 351 | Supply the following JSON structure: 352 | 353 | ```JSON 354 | # POST body 355 | { 356 | # NOTE: all of this will likely come directly from the PassKeys class/library on the platform 357 | "id": String, # Base64 encoded assertion.credentialID 358 | "rawId": String, # Base64 encoded assertion.credentialID 359 | "type": "public-key", 360 | "response": { 361 | "authenticatorData": String, # Base64 encoded assertion.rawAuthenticatorData 362 | "clientDataJSON": String, # Base64 encoded assertion.rawClientDataJSON 363 | "signature": String, # Base64 encoded signature 364 | "userHandle":String # Base64 encoded assertion.userID 365 | } 366 | } 367 | ``` 368 | On **success**, the response is an `AuthResponse`. 369 | 370 | Possible **failure codes** (using the `ErrorResponse` structure) are: 371 | 372 | - `webauthn_error` - something is wrong with the credential 373 | - `passkey_not_found` - the passkey referenced in the credential cannot be found in the database 374 | 375 | ### POST /passkeys/refresh 376 | 377 | The token will expire after some time (configurable in `config/initializers/passkeys_rails.rb`). Before that happens, refresh it using this API. Once it expires, to get a new token, use the `/authentication` API. 378 | 379 | Supply the following JSON structure: 380 | 381 | ```JSON 382 | # POST body 383 | { 384 | token: String 385 | } 386 | ``` 387 | 388 | On **success**, the response is an `AuthResponse` with a new, refreshed token. 389 | 390 | Possible **failure codes** (using the `ErrorResponse` structure) are: 391 | 392 | - `invalid_token` - the token data is invalid 393 | - `expired_token` - the token is expired 394 | - `token_error` - some other error ocurred when decoding the token 395 | 396 | ### POST /passkeys/debug_register 397 | 398 | As it may not be possible to acess Passkey functionality in mobile simulators, this endpoint may be called to register a username while bypassing the normal challenge/response sequence. 399 | 400 | This endpoint only responds if `DEBUG_LOGIN_REGEX` is set in the server environment. It is **very insecure to set this variable in a production environment** as it bypasses all Passkey checks. It is only intended to be used during mobile application development. 401 | 402 | To use this endpoint: 403 | 404 | 1. Set `DEBUG_LOGIN_REGEX` to a regex that matches any username you want to use during development - for example `^test(-\d+)?$` will match `test`, `test-1`, `test-123`, etc. 405 | 406 | 1. In the mobile application, call this endpoint in stead of the `/passkeys/challenge` and `/passkeys/register`. The response is identicial to that of `/passkeys/register`. 407 | 408 | 1. Use the response as if it was from `/passkeys/register`. 409 | 410 | If you supply a username that doesn't match the `DEBUG_LOGIN_REGEX`, the endpoint will respond with an error. 411 | 412 | Supply the following JSON structure: 413 | 414 | ```JSON 415 | # POST body 416 | { 417 | "username": String 418 | } 419 | ``` 420 | On **success**, the response is an `AuthResponse`. 421 | 422 | Possible **failure codes** (using the `ErrorResponse` structure) are: 423 | 424 | - `not_allowed` - Invalid username (the username doesn't match the regex) 425 | - `invalid_authenticatable_class` - the supplied authenticatable class can't be created/found (check spelling & capitalization) 426 | - `invalid_class_whitelist` - the whitelist in the passkeys_rails.rb configuration is invalid - be sure it's nil or an array 427 | - `invalid_authenticatable_class` - the supplied authenticatable class is not allowed - maybe it's not in the whitelist 428 | - `record_invalid` - the object of the supplied authenticatable class cannot be saved due to validation errors 429 | 430 | ### POST /passkeys/debug_login 431 | 432 | As it may not be possible to acess Passkey functionality in mobile simulators, this endpoint may be called to login (authenticate) a username while bypassing the normal challenge/response sequence. 433 | 434 | This endpoint only responds if `DEBUG_LOGIN_REGEX` is set in the server environment. It is **very insecure to set this variable in a production environment** as it bypasses all Passkey checks. It is only intended to be used during mobile application development. 435 | 436 | To use this endpoint: 437 | 438 | 1. Manually create one or more PasskeysRails::Agent records in the database. A unique username is required for each. 439 | 440 | 1. Set `DEBUG_LOGIN_REGEX` to a regex that matches any username you want to use during development - for example `^test(-\d+)?$` will match `test`, `test-1`, `test-123`, etc. 441 | 442 | 1. In the mobile application, call this endpoint in stead of the `/passkeys/challenge` and `/passkeys/authenticate`. The response is identicial to that of `/passkeys/authenticate`. 443 | 444 | 1. Use the response as if it was from `/passkeys/authenticate`. 445 | 446 | If you supply a username that doesn't match the `DEBUG_LOGIN_REGEX`, the endpoint will respond with an error. 447 | 448 | Supply the following JSON structure: 449 | 450 | ```JSON 451 | # POST body 452 | { 453 | "username": String 454 | } 455 | ``` 456 | On **success**, the response is an `AuthResponse`. 457 | 458 | Possible **failure codes** (using the `ErrorResponse` structure) are: 459 | 460 | - `not_allowed` - Invalid username (the username doesn't match the regex) 461 | - `agent_not_found` - No agent found with that username 462 | 463 | ## Reference/Example Mobile Applications 464 | 465 | There is a sample iOS app that integrates with **passkeys-rails** based server implementations. It's a great place to get a quick start on implementing passkyes in your iOS, iPadOS or MacOS apps. 466 | 467 | Check out the [PasskeysRailsDemo](https://github.com/alliedcode/PasskeysRailsDemo) app. 468 | 469 | ## Contributing 470 | 471 | ### Contribution Guidelines 472 | 473 | Thank you for considering contributing to PasskeysRails! We welcome your help to improve and enhance this project. Whether it's a bug fix, documentation update, or a new feature, your contributions are valuable to the community. 474 | 475 | To ensure a smooth collaboration, please follow the [Contribution Guidelines](https://github.com/alliedcode/passkeys-rails/blob/main/CONTRIBUTION_GUIDELINES.md) when submitting your contributions. 476 | 477 | ### Code of Conduct 478 | 479 | Please note that this project follows the [Code of Conduct](https://github.com/alliedcode/passkeys-rails/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. If you encounter any behavior that violates the code, please report it to the project maintainers. 480 | 481 | ## License 482 | 483 | The gem is available as open source under the terms of the [MIT License](https://github.com/alliedcode/passkeys-rails/blob/main/MIT-LICENSE). 484 | --------------------------------------------------------------------------------