├── .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 | [](https://badge.fury.io/rb/passkeys-rails)
4 | [](https://travis-ci.org/alliedcode/passkeys-rails)
5 | [](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 |
--------------------------------------------------------------------------------