├── .yardopts
├── test
├── rails_app
│ ├── public
│ │ ├── favicon.ico
│ │ ├── 422.html
│ │ ├── 404.html
│ │ └── 500.html
│ ├── app
│ │ ├── views
│ │ │ ├── home
│ │ │ │ ├── join.html.erb
│ │ │ │ ├── index.html.erb
│ │ │ │ ├── private.html.erb
│ │ │ │ ├── user_dashboard.html.erb
│ │ │ │ └── admin_dashboard.html.erb
│ │ │ ├── admins
│ │ │ │ ├── index.html.erb
│ │ │ │ └── sessions
│ │ │ │ │ └── new.html.erb
│ │ │ ├── users
│ │ │ │ ├── sessions
│ │ │ │ │ └── new.html.erb
│ │ │ │ ├── index.html.erb
│ │ │ │ ├── mailer
│ │ │ │ │ └── confirmation_instructions.erb
│ │ │ │ └── edit_form.html.erb
│ │ │ └── layouts
│ │ │ │ └── application.html.erb
│ │ ├── active_record
│ │ │ ├── shim.rb
│ │ │ ├── user_on_engine.rb
│ │ │ ├── user_on_main_app.rb
│ │ │ ├── admin.rb
│ │ │ ├── user_passkey.rb
│ │ │ ├── user_without_email.rb
│ │ │ ├── user_with_validations.rb
│ │ │ └── user.rb
│ │ ├── mailers
│ │ │ └── users
│ │ │ │ ├── mailer.rb
│ │ │ │ ├── from_proc_mailer.rb
│ │ │ │ └── reply_to_mailer.rb
│ │ ├── controllers
│ │ │ ├── publisher
│ │ │ │ ├── sessions_controller.rb
│ │ │ │ └── registrations_controller.rb
│ │ │ ├── admins_controller.rb
│ │ │ ├── admins
│ │ │ │ └── sessions_controller.rb
│ │ │ ├── application_controller.rb
│ │ │ ├── home_controller.rb
│ │ │ ├── streaming_controller.rb
│ │ │ ├── custom
│ │ │ │ └── registrations_controller.rb
│ │ │ ├── application_with_fake_engine.rb
│ │ │ └── users_controller.rb
│ │ ├── helpers
│ │ │ └── application_helper.rb
│ │ └── mongoid
│ │ │ ├── shim.rb
│ │ │ ├── admin.rb
│ │ │ ├── user_without_email.rb
│ │ │ ├── user_with_validations.rb
│ │ │ ├── user_on_engine.rb
│ │ │ ├── user_on_main_app.rb
│ │ │ └── user.rb
│ ├── lib
│ │ ├── lazy_load_test_module.rb
│ │ ├── shared_user.rb
│ │ ├── shared_admin.rb
│ │ └── shared_user_without_email.rb
│ ├── config
│ │ ├── initializers
│ │ │ ├── inflections.rb
│ │ │ ├── session_store.rb
│ │ │ ├── secret_token.rb
│ │ │ ├── backtrace_silencers.rb
│ │ │ └── devise.rb
│ │ ├── environment.rb
│ │ ├── database.yml
│ │ ├── boot.rb
│ │ ├── environments
│ │ │ ├── development.rb
│ │ │ ├── test.rb
│ │ │ └── production.rb
│ │ ├── application.rb
│ │ └── routes.rb
│ ├── bin
│ │ ├── rake
│ │ ├── bundle
│ │ └── rails
│ ├── config.ru
│ ├── Rakefile
│ └── db
│ │ ├── schema.rb
│ │ └── migrate
│ │ └── 20100401102949_create_tables.rb
├── test_helper
│ ├── orm
│ │ ├── mongoid.rb
│ │ └── active_record.rb
│ ├── extra_assertions.rb
│ └── webauthn_test_helpers.rb
├── test_helper.rb
└── devise
│ ├── test_passkeys.rb
│ ├── passkeys
│ └── controllers
│ │ ├── concerns
│ │ ├── test_reauthentication_challenge.rb
│ │ └── test_reauthentication.rb
│ │ ├── test_sessions_controller_concern.rb
│ │ ├── test_reauthentication_controller_concern.rb
│ │ ├── test_registrations_controller_concern.rb
│ │ └── test_passkeys_controller_concern.rb
│ └── test_passkey_issuer.rb
├── gemfiles
├── .bundle
│ └── config
├── rails_6.gemfile
└── rails_7.gemfile
├── bin
├── yard
├── setup
├── test
└── console
├── lib
└── devise
│ ├── passkeys
│ ├── rails.rb
│ ├── version.rb
│ ├── model.rb
│ ├── reauthentication_strategy.rb
│ ├── controllers
│ │ ├── sessions_controller_concern.rb
│ │ ├── concerns
│ │ │ ├── reauthentication_challenge.rb
│ │ │ └── reauthentication.rb
│ │ ├── passkeys_controller_concern.rb
│ │ ├── reauthentication_controller_concern.rb
│ │ └── registrations_controller_concern.rb
│ ├── passkey_issuer.rb
│ ├── strategy.rb
│ └── controllers.rb
│ └── passkeys.rb
├── sig
└── devise
│ └── passkeys.rbs
├── Appraisals
├── .gitignore
├── THANKS.md
├── Rakefile
├── .github
└── dependabot.yml
├── Gemfile
├── .circleci
└── config.yml
├── LICENSE.txt
├── .rubocop.yml
├── CONTRIBUTING.md
├── devise-passkeys.gemspec
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── Gemfile.lock
└── README.md
/.yardopts:
--------------------------------------------------------------------------------
1 | --markup markdown
2 |
--------------------------------------------------------------------------------
/test/rails_app/public/favicon.ico:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/gemfiles/.bundle/config:
--------------------------------------------------------------------------------
1 | ---
2 | BUNDLE_RETRY: "1"
3 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/home/join.html.erb:
--------------------------------------------------------------------------------
1 | Join
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/home/index.html.erb:
--------------------------------------------------------------------------------
1 | Home!
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/home/private.html.erb:
--------------------------------------------------------------------------------
1 | Private!
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/admins/index.html.erb:
--------------------------------------------------------------------------------
1 | Welcome Admin!
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/home/user_dashboard.html.erb:
--------------------------------------------------------------------------------
1 | User dashboard
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/home/admin_dashboard.html.erb:
--------------------------------------------------------------------------------
1 | Admin dashboard
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/users/sessions/new.html.erb:
--------------------------------------------------------------------------------
1 | Special user view
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/users/index.html.erb:
--------------------------------------------------------------------------------
1 | Welcome User #<%= current_user.id %>!
2 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/users/mailer/confirmation_instructions.erb:
--------------------------------------------------------------------------------
1 | <%= @resource.email %>
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/shim.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Shim
4 | end
5 |
--------------------------------------------------------------------------------
/bin/yard:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle exec yard server --reload
7 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/users/edit_form.html.erb:
--------------------------------------------------------------------------------
1 | <%= button_to 'Update', update_form_user_path(current_user), method: 'put' %>
2 |
--------------------------------------------------------------------------------
/test/rails_app/lib/lazy_load_test_module.rb:
--------------------------------------------------------------------------------
1 | module LazyLoadTestModule
2 | def lazy_loading_works?
3 | "yes it does"
4 | end
5 | end
--------------------------------------------------------------------------------
/test/rails_app/app/views/admins/sessions/new.html.erb:
--------------------------------------------------------------------------------
1 | Welcome to "sessions/new" view!
2 | <%= render template: "devise/sessions/new" %>
3 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/rails.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise::Passkeys
4 | class Engine < ::Rails::Engine
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | VERSION = "0.3.0"
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/test/rails_app/config/initializers/inflections.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ActiveSupport::Inflector.inflections do |inflect|
4 | end
5 |
--------------------------------------------------------------------------------
/test/rails_app/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require_relative "../config/boot"
5 | require "rake"
6 | Rake.application.run
7 |
--------------------------------------------------------------------------------
/test/rails_app/app/mailers/users/mailer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Users::Mailer < Devise::Mailer
4 | default from: 'custom@example.com'
5 | end
6 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/publisher/sessions_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Publisher::SessionsController < ApplicationController
4 | end
5 |
--------------------------------------------------------------------------------
/test/rails_app/config/initializers/session_store.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RailsApp::Application.config.session_store :cookie_store, key: '_rails_app_session'
4 |
--------------------------------------------------------------------------------
/sig/devise/passkeys.rbs:
--------------------------------------------------------------------------------
1 | module Devise
2 | module Passkeys
3 | VERSION: String
4 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5 | end
6 | end
7 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/publisher/registrations_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Publisher::RegistrationsController < ApplicationController
4 | end
5 |
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/user_on_engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class UserOnEngine < ActiveRecord::Base
4 | self.table_name = 'users'
5 | include Shim
6 | end
7 |
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/user_on_main_app.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class UserOnMainApp < ActiveRecord::Base
4 | self.table_name = 'users'
5 | include Shim
6 | end
7 |
--------------------------------------------------------------------------------
/test/rails_app/app/mailers/users/from_proc_mailer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Users::FromProcMailer < Devise::Mailer
4 | default from: proc { 'custom@example.com' }
5 | end
6 |
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/admin.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_admin'
4 |
5 | class Admin < ActiveRecord::Base
6 | include Shim
7 | include SharedAdmin
8 | end
9 |
--------------------------------------------------------------------------------
/test/rails_app/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__)
5 | load Gem.bin_path("bundler", "bundle")
6 |
--------------------------------------------------------------------------------
/bin/setup:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 | bundle exec appraisal update
10 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -euo pipefail
3 | IFS=$'\n\t'
4 | set -vx
5 |
6 | bundle install
7 |
8 | # Do any other automated setup that you need to do here
9 | bundle exec appraisal rake test
10 |
--------------------------------------------------------------------------------
/test/rails_app/app/helpers/application_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Methods added to this helper will be available to all templates in the application.
4 | module ApplicationHelper
5 | end
6 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/admins_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class AdminsController < ApplicationController
4 | before_action :authenticate_admin!
5 |
6 | def index
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/rails_app/bin/rails:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | APP_PATH = File.expand_path("../config/application", __dir__)
5 | require_relative "../config/boot"
6 | require "rails/commands"
7 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | appraise "rails-7" do
4 | gem "rails", "~> 7"
5 | gem "sqlite3"
6 | end
7 |
8 | appraise "rails-6" do
9 | gem "rails", "~> 6"
10 | gem "sqlite3"
11 | end
12 |
--------------------------------------------------------------------------------
/test/rails_app/config.ru:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file is used by Rack-based servers to start the application.
4 |
5 | require ::File.expand_path("config/environment", __dir__)
6 | run RailsApp::Application
7 |
--------------------------------------------------------------------------------
/test/rails_app/app/mailers/users/reply_to_mailer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Users::ReplyToMailer < Devise::Mailer
4 | default from: 'custom@example.com'
5 | default reply_to: 'custom_reply_to@example.com'
6 | end
7 |
--------------------------------------------------------------------------------
/test/rails_app/config/environment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Load the rails application.
4 | require File.expand_path('../application', __FILE__)
5 |
6 | # Initialize the rails application.
7 | RailsApp::Application.initialize!
8 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/admins/sessions_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Admins::SessionsController < Devise::SessionsController
4 | def new
5 | flash[:special] = "Welcome to #{controller_path.inspect} controller!"
6 | super
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/test/rails_app/config/initializers/secret_token.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | config = Rails.application.config
4 |
5 | config.secret_key_base = 'd588e99efff13a86461fd6ab82327823ad2f8feb5dc217ce652cdd9f0dfc5eb4b5a62a92d24d2574d7d51dfb1ea8dd453ea54e00cf672159a13104a135422a10'
6 |
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/user_passkey.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_user'
4 |
5 | class UserPasskey < ActiveRecord::Base
6 |
7 | belongs_to :user, inverse_of: :passkeys
8 |
9 | validates :label, presence: true, allow_blank: false
10 | end
11 |
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/user_without_email.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "shared_user_without_email"
4 |
5 | class UserWithoutEmail < ActiveRecord::Base
6 | self.table_name = 'users'
7 | include Shim
8 | include SharedUserWithoutEmail
9 | end
10 |
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 | .DS_Store
10 | gemfiles/*.gemfile.lock
11 | test/rails_app/log/*
12 | test/rails_app/tmp/*
13 | *~
14 | *.sqlite3
15 | rdoc/*
16 | pkg
17 | log
18 | test/tmp/*
19 | test/reports
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/user_with_validations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_user'
4 |
5 | class UserWithValidations < ActiveRecord::Base
6 | self.table_name = 'users'
7 | include Shim
8 | include SharedUser
9 |
10 | validates :email, presence: true
11 | end
12 |
13 |
--------------------------------------------------------------------------------
/test/rails_app/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Add your own tasks in files placed in lib/tasks ending in .rake,
4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
5 |
6 | require File.expand_path("config/application", __dir__)
7 |
8 | Rails.application.load_tasks
9 |
--------------------------------------------------------------------------------
/THANKS.md:
--------------------------------------------------------------------------------
1 | # Thanks to everyone's who's contributed or helped out!
2 |
3 | ## Contributors
4 |
5 | - [@heliocola](https://github.com/heliocola)
6 | - [@JanaganSaravanan](https://github.com/JanaganSaravanan)
7 | - [@johalloran01](https://github.com/johalloran01)
8 | - [@tcannonfodder](https://github.com/tcannonfodder)
9 | - [@Vagab](https://github.com/Vagab)
10 |
--------------------------------------------------------------------------------
/test/test_helper/orm/mongoid.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "mongoid/version"
4 |
5 | Mongoid.configure do |config|
6 | config.load!("test/support/mongoid.yml")
7 | config.use_utc = true
8 | config.include_root_in_json = true
9 | end
10 |
11 | class ActiveSupport::TestCase
12 | setup do
13 | Mongoid.default_session.drop
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/gem_tasks"
4 | require "rake/testtask"
5 |
6 | Rake::TestTask.new(:test) do |t|
7 | t.libs << "test"
8 | t.libs << "lib"
9 | t.test_files = FileList["test/**/test_*.rb"]
10 | t.warning = false
11 | end
12 |
13 | require "rubocop/rake_task"
14 |
15 | RuboCop::RakeTask.new
16 |
17 | task default: %i[test rubocop]
18 |
--------------------------------------------------------------------------------
/bin/console:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | require "bundler/setup"
5 | require "devise/passkeys"
6 |
7 | # You can add fixtures and/or initialization code here to make experimenting
8 | # with your gem easier. You can also use a different console, if you like.
9 |
10 | # (If you use this, don't forget to add pry to your Gemfile!)
11 | # require "pry"
12 | # Pry.start
13 |
14 | require "irb"
15 | IRB.start(__FILE__)
16 |
--------------------------------------------------------------------------------
/test/rails_app/config/initializers/backtrace_silencers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Be sure to restart your server when you modify this file.
4 |
5 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces.
6 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ }
7 |
8 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code.
9 | Rails.backtrace_cleaner.remove_silencers!
10 |
--------------------------------------------------------------------------------
/test/rails_app/lib/shared_user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SharedUser
4 | extend ActiveSupport::Concern
5 |
6 | included do
7 | devise :passkey_authenticatable, :registerable
8 |
9 | attr_accessor :other_key
10 |
11 | def self.passkeys_class
12 | UserPasskey
13 | end
14 |
15 | def self.find_for_passkey(passkey)
16 | self.find_by(id: passkey.user.id)
17 | end
18 |
19 | end
20 |
21 | def raw_confirmation_token
22 | @raw_confirmation_token
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "ruby" # See documentation for possible values
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/test/rails_app/config/database.yml:
--------------------------------------------------------------------------------
1 | # SQLite version 3.x
2 | # gem install sqlite3-ruby (not necessary on OS X Leopard)
3 | development:
4 | adapter: sqlite3
5 | database: db/development.sqlite3
6 | pool: 5
7 | timeout: 5000
8 |
9 | # Warning: The database defined as "test" will be erased and
10 | # re-generated from your development database when you run "rake".
11 | # Do not set this db to the same as development or production.
12 | test:
13 | adapter: sqlite3
14 | database: ":memory:"
15 |
16 | production:
17 | adapter: sqlite3
18 | database: ":memory:"
19 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/application_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Filters added to this controller apply to all controllers in the application.
4 | # Likewise, all the methods added will be available for all controllers.
5 |
6 | class ApplicationController < ActionController::Base
7 | protect_from_forgery
8 | before_action :current_user, unless: :devise_controller?
9 | before_action :authenticate_user!, if: :devise_controller?
10 | respond_to(*Mime::SET.map(&:to_sym))
11 |
12 | devise_group :commenter, contains: [:user, :admin]
13 | end
14 |
--------------------------------------------------------------------------------
/Gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | source "https://rubygems.org"
4 |
5 | # Specify your gem's dependencies in devise-passkeys.gemspec
6 | gemspec
7 |
8 | group :development, :test do
9 | gem "appraisal"
10 | gem "debug"
11 | gem "rake", "~> 13.0"
12 | gem "rubocop", "~> 1.21"
13 | gem "webrick"
14 | gem "yard"
15 | end
16 |
17 | group :test do
18 | gem "database_cleaner-active_record"
19 | gem "database_cleaner-mongoid"
20 | gem "m"
21 | gem "minitest", "~> 5.0"
22 | gem "minitest-ci", require: false
23 | gem "rack"
24 | gem "simplecov"
25 | end
26 |
--------------------------------------------------------------------------------
/test/rails_app/app/active_record/user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_user'
4 |
5 | class User < ActiveRecord::Base
6 | include Shim
7 | include SharedUser
8 |
9 | has_many :passkeys, class_name: "UserPasskey", dependent: :destroy
10 |
11 | validates :sign_in_count, presence: true
12 |
13 | cattr_accessor :after_passkey_authentication_passkey
14 |
15 | def after_passkey_authentication(passkey:)
16 | # used to check in our test if the callbacks were called
17 | @@after_passkey_authentication_passkey = passkey.label
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/test/rails_app/app/mongoid/shim.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Shim
4 | extend ::ActiveSupport::Concern
5 |
6 | included do
7 | include ::Mongoid::Timestamps
8 | field :created_at, type: DateTime
9 | end
10 |
11 | module ClassMethods
12 | def order(attribute)
13 | asc(attribute)
14 | end
15 |
16 | def find_by_email(email)
17 | find_by(email: email)
18 | end
19 | end
20 |
21 | # overwrite equality (because some devise tests use this for asserting model equality)
22 | def ==(other)
23 | other.is_a?(self.class) && _id == other._id
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/gemfiles/rails_6.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file was generated by Appraisal
4 |
5 | source "https://rubygems.org"
6 |
7 | gem "rails", "~> 6"
8 | gem "sqlite3"
9 |
10 | group :development, :test do
11 | gem "appraisal"
12 | gem "debug"
13 | gem "rake", "~> 13.0"
14 | gem "rubocop", "~> 1.21"
15 | gem "webrick"
16 | gem "yard"
17 | end
18 |
19 | group :test do
20 | gem "database_cleaner-active_record"
21 | gem "database_cleaner-mongoid"
22 | gem "m"
23 | gem "minitest", "~> 5.0"
24 | gem "minitest-ci", require: false
25 | gem "rack"
26 | gem "simplecov"
27 | end
28 |
29 | gemspec path: "../"
30 |
--------------------------------------------------------------------------------
/gemfiles/rails_7.gemfile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # This file was generated by Appraisal
4 |
5 | source "https://rubygems.org"
6 |
7 | gem "rails", "~> 7"
8 | gem "sqlite3"
9 |
10 | group :development, :test do
11 | gem "appraisal"
12 | gem "debug"
13 | gem "rake", "~> 13.0"
14 | gem "rubocop", "~> 1.21"
15 | gem "webrick"
16 | gem "yard"
17 | end
18 |
19 | group :test do
20 | gem "database_cleaner-active_record"
21 | gem "database_cleaner-mongoid"
22 | gem "m"
23 | gem "minitest", "~> 5.0"
24 | gem "minitest-ci", require: false
25 | gem "rack"
26 | gem "simplecov"
27 | end
28 |
29 | gemspec path: "../"
30 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/home_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class HomeController < ApplicationController
4 | def index
5 | end
6 |
7 | def private
8 | end
9 |
10 | def user_dashboard
11 | end
12 |
13 | def admin_dashboard
14 | end
15 |
16 | def join
17 | end
18 |
19 | def set
20 | session["devise.foo_bar"] = "something"
21 | head :ok
22 | end
23 |
24 | def unauthenticated
25 | if Devise::Test.rails5_and_up?
26 | render body: "unauthenticated", status: :unauthorized
27 | else
28 | render text: "unauthenticated", status: :unauthorized
29 | end
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/streaming_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class StreamingController < ApplicationController
4 | include ActionController::Live
5 |
6 | before_action :authenticate_user!
7 |
8 | def index
9 | render (Devise::Test.rails5_and_up? ? :body : :text) => 'Index'
10 | end
11 |
12 | # Work around https://github.com/heartcombo/devise/issues/2332, which affects
13 | # tests in Rails 4.x (and affects production in Rails >= 5)
14 | def process(name)
15 | super(name)
16 | rescue ArgumentError => e
17 | if e.message == 'uncaught throw :warden'
18 | throw :warden
19 | else
20 | raise e
21 | end
22 | end
23 | end
24 |
--------------------------------------------------------------------------------
/test/test_helper/extra_assertions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module ExtraAssertions
4 | def assert_translation_missing_message(translation_key:)
5 | assert_translation_missing(translation_key: translation_key, field: "message")
6 | end
7 |
8 | def assert_translation_missing_error(translation_key:)
9 | assert_translation_missing(translation_key: translation_key, field: "error")
10 | end
11 |
12 | def assert_translation_missing(translation_key:, field:)
13 | assert_equal [field], response.parsed_body.keys
14 | assert_match(/^translation missing/i, response.parsed_body[field])
15 | assert_equal true, response.parsed_body[field].include?(translation_key)
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/test/rails_app/lib/shared_admin.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SharedAdmin
4 | extend ActiveSupport::Concern
5 |
6 | included do
7 | devise :database_authenticatable,
8 | :timeoutable, :lockable, :confirmable,
9 | unlock_strategy: :time, lock_strategy: :none,
10 | allow_unconfirmed_access_for: 2.weeks, reconfirmable: true
11 |
12 | if Devise::Test.rails51?
13 | validates_uniqueness_of :email, allow_blank: true, if: :will_save_change_to_email?
14 | else
15 | validates_uniqueness_of :email, allow_blank: true, if: :email_changed?
16 | end
17 | end
18 |
19 | def raw_confirmation_token
20 | @raw_confirmation_token
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2.1
2 | jobs:
3 | build:
4 | docker:
5 | - image: ruby:3.1.1
6 | steps:
7 | - checkout
8 | - run: gem install bundler -v 2.3.7
9 | - run: bundle install
10 | - run: bundle exec appraisal
11 | - run: bundle exec appraisal rake test
12 | - store_test_results:
13 | path: test/reports
14 | - store_artifacts:
15 | path: coverage
16 | rubocop:
17 | docker:
18 | - image: ruby:3.1.1
19 | steps:
20 | - checkout
21 | - run: gem install bundler -v 2.3.7
22 | - run: bundle install
23 | - run: bundle exec rubocop
24 |
25 | workflows:
26 | build:
27 | jobs:
28 | - build
29 | - rubocop
--------------------------------------------------------------------------------
/test/test_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Bundler.require(:test)
4 | SimpleCov.start do
5 | add_filter "/test/"
6 | end
7 |
8 | ENV["RAILS_ENV"] = "test"
9 | DEVISE_ORM = (ENV["DEVISE_ORM"] || :active_record).to_sym
10 | puts "\n==> Devise.orm = #{DEVISE_ORM.inspect}"
11 | require "rails_app/config/environment"
12 | require "rails/test_help"
13 | require "test_helper/orm/#{DEVISE_ORM}"
14 |
15 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
16 | require "devise/passkeys"
17 |
18 | require "minitest/autorun"
19 |
20 | if ENV["CIRCLECI"]
21 | require "minitest/ci"
22 | Minitest::Ci.report_dir = "#{Minitest::Ci.report_dir}/#{Rails.version}"
23 | end
24 |
25 | SimpleCov.coverage_dir("coverage/#{Rails.version}")
26 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/custom/registrations_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class Custom::RegistrationsController < Devise::RegistrationsController
4 | def new
5 | super do |resource|
6 | @new_block_called = true
7 | end
8 | end
9 |
10 | def create
11 | super do |resource|
12 | @create_block_called = true
13 | end
14 | end
15 |
16 | def update
17 | super do |resource|
18 | @update_block_called = true
19 | end
20 | end
21 |
22 | def create_block_called?
23 | @create_block_called == true
24 | end
25 |
26 | def update_block_called?
27 | @update_block_called == true
28 | end
29 |
30 | def new_block_called?
31 | @new_block_called == true
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/test/rails_app/app/views/layouts/application.html.erb:
--------------------------------------------------------------------------------
1 |
3 |
4 |
5 | Devise Test App
6 |
7 |
8 |
9 | <%- flash.each do |name, msg| -%>
10 | <%= content_tag :div, msg, id: "flash_#{name}" %>
11 | <%- end -%>
12 |
13 | <% if user_signed_in? -%>
14 |
Hello User <%= current_user.email %>! You are signed in!
15 | <% end -%>
16 |
17 | <% if admin_signed_in? -%>
18 |
Hello Admin <%= current_admin.email %>! You are signed in!
19 | <% end -%>
20 |
21 | <%= yield %>
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/model.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Models
5 | # This is the actual module that gets included in your
6 | # model when you include `:passkey_authenticatable` in the
7 | # `devise` call (eg: `devise :passkey_authenticatable, ...`).
8 | module PasskeyAuthenticatable
9 | # This is a callback that is called right after a successful passkey authentication.
10 | #
11 | # By default, it is a no-op, but you can override it in your model for any custom behavior
12 | # (such as notifying the user of a new login).
13 | # @param passkey [String] the passkey that was used for authentication
14 | def after_passkey_authentication(passkey:); end
15 | end
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/reauthentication_strategy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "devise/strategies/authenticatable"
4 | require_relative "passkey_issuer"
5 |
6 | module Devise
7 | module Strategies
8 | class PasskeyReauthentication < PasskeyAuthenticatable
9 | def authentication_challenge_key
10 | "#{mapping.singular}_current_reauthentication_challenge"
11 | end
12 |
13 | # Reauthentication runs through Authentication (user_set)
14 | # as part of its cycle, which would normally reset CSRF
15 | # data in the session
16 | def clean_up_csrf?
17 | false
18 | end
19 | end
20 | end
21 | end
22 |
23 | Warden::Strategies.add(:passkey_reauthentication, Devise::Strategies::PasskeyReauthentication)
24 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/application_with_fake_engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class ApplicationWithFakeEngine < ApplicationController
4 | private
5 |
6 | helper_method :fake_engine
7 | def fake_engine
8 | @fake_engine ||= FakeEngine.new
9 | end
10 | end
11 |
12 | class FakeEngine
13 | def user_on_engine_confirmation_path
14 | '/user_on_engine/confirmation'
15 | end
16 |
17 | def new_user_on_engine_session_path
18 | '/user_on_engine/confirmation/new'
19 | end
20 |
21 | def new_user_on_engine_registration_path
22 | '/user_on_engine/registration/new'
23 | end
24 |
25 | def new_user_on_engine_password_path
26 | '/user_on_engine/password/new'
27 | end
28 |
29 | def new_user_on_engine_unlock_path
30 | '/user_on_engine/unlock/new'
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/test/rails_app/public/422.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The change you wanted was rejected (422)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
The change you wanted was rejected.
23 |
Maybe you tried to change something you didn't have access to.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/test/rails_app/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | The page you were looking for doesn't exist (404)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
The page you were looking for doesn't exist.
23 |
You may have mistyped the address or the page may have moved.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/test/rails_app/public/500.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | We're sorry, but something went wrong (500)
5 |
17 |
18 |
19 |
20 |
21 |
22 |
We're sorry, but something went wrong.
23 |
We've been notified about this issue and we'll take a look at it shortly.
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/test/rails_app/app/mongoid/admin.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_admin'
4 |
5 | class Admin
6 | include Mongoid::Document
7 | include Shim
8 | include SharedAdmin
9 |
10 | ## Database authenticatable
11 | field :email, type: String
12 | field :encrypted_password, type: String
13 |
14 | ## Recoverable
15 | field :reset_password_token, type: String
16 | field :reset_password_sent_at, type: Time
17 |
18 | ## Rememberable
19 | field :remember_created_at, type: Time
20 |
21 | ## Confirmable
22 | field :confirmation_token, type: String
23 | field :confirmed_at, type: Time
24 | field :confirmation_sent_at, type: Time
25 | field :unconfirmed_email, type: String # Only if using reconfirmable
26 |
27 | ## Lockable
28 | field :locked_at, type: Time
29 |
30 | field :active, type: Boolean, default: false
31 | end
32 |
--------------------------------------------------------------------------------
/test/test_helper/orm/active_record.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | ActiveRecord::Migration.verbose = false
4 | ActiveRecord::Base.logger = Logger.new(nil)
5 | ActiveRecord::Base.include_root_in_json = true
6 |
7 | migrate_path = File.expand_path("../../rails_app/db/migrate", __dir__)
8 | if Devise::Test.rails6_and_up?
9 | ActiveRecord::MigrationContext.new(migrate_path, ActiveRecord::SchemaMigration).migrate
10 | elsif Devise::Test.rails52_and_up?
11 | ActiveRecord::MigrationContext.new(migrate_path).migrate
12 | else
13 | ActiveRecord::Migrator.migrate(migrate_path)
14 | end
15 |
16 | class ActiveSupport::TestCase
17 | if Devise::Test.rails5_and_up?
18 | self.use_transactional_tests = true
19 | else
20 | # Let `after_commit` work with transactional fixtures, however this is not needed for Rails 5.
21 | require "test_after_commit"
22 | self.use_transactional_fixtures = true
23 | end
24 |
25 | self.use_instantiated_fixtures = false
26 | end
27 |
--------------------------------------------------------------------------------
/test/rails_app/app/controllers/users_controller.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class UsersController < ApplicationController
4 | prepend_before_action :current_user, only: :exhibit
5 | before_action :authenticate_user!, except: [:accept, :exhibit]
6 | clear_respond_to
7 | respond_to :html, :json
8 |
9 | def index
10 | user_session[:cart] = "Cart"
11 | respond_with(current_user)
12 | end
13 |
14 | def edit_form
15 | user_session['last_request_at'] = params.fetch(:last_request_at, 31.minutes.ago.utc)
16 | end
17 |
18 | def update_form
19 | render (Devise::Test.rails5_and_up? ? :body : :text) => 'Update'
20 | end
21 |
22 | def accept
23 | @current_user = current_user
24 | end
25 |
26 | def exhibit
27 | render (Devise::Test.rails5_and_up? ? :body : :text) => current_user ? "User is authenticated" : "User is not authenticated"
28 | end
29 |
30 | def expire
31 | user_session['last_request_at'] = 31.minutes.ago.utc
32 | render (Devise::Test.rails5_and_up? ? :body : :text) => 'User will be expired on next request'
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/test/rails_app/lib/shared_user_without_email.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SharedUserWithoutEmail
4 | extend ActiveSupport::Concern
5 |
6 | included do
7 | # NOTE: This is missing :validatable and :confirmable, as they both require
8 | # an email field at the moment. It is also missing :omniauthable because that
9 | # adds unnecessary complexity to the setup
10 | devise :database_authenticatable, :lockable, :recoverable,
11 | :registerable, :rememberable, :timeoutable,
12 | :trackable
13 | end
14 |
15 | # This test stub is a bit rubbish because it's tied very closely to the
16 | # implementation where we care about this one case. However, completely
17 | # removing the email field breaks "recoverable" tests completely, so we are
18 | # just taking the approach here that "email" is something that is a not an
19 | # ActiveRecord field.
20 | def email_changed?
21 | raise NoMethodError
22 | end
23 |
24 | def respond_to?(method_name, include_all = false)
25 | return false if method_name.to_sym == :email_changed?
26 | super(method_name, include_all)
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2022 Thomas Cannon
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test/rails_app/app/mongoid/user_without_email.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "shared_user_without_email"
4 |
5 | class UserWithoutEmail
6 | include Mongoid::Document
7 | include Shim
8 | include SharedUserWithoutEmail
9 |
10 | field :username, type: String
11 | field :facebook_token, type: String
12 |
13 | ## Database authenticatable
14 | field :email, type: String, default: ""
15 | field :encrypted_password, type: String, default: ""
16 |
17 | ## Recoverable
18 | field :reset_password_token, type: String
19 | field :reset_password_sent_at, type: Time
20 |
21 | ## Rememberable
22 | field :remember_created_at, type: Time
23 |
24 | ## Trackable
25 | field :sign_in_count, type: Integer, default: 0
26 | field :current_sign_in_at, type: Time
27 | field :last_sign_in_at, type: Time
28 | field :current_sign_in_ip, type: String
29 | field :last_sign_in_ip, type: String
30 |
31 | ## Lockable
32 | field :failed_attempts, type: Integer, default: 0 # Only if lock strategy is :failed_attempts
33 | field :unlock_token, type: String # Only if unlock strategy is :email or :both
34 | field :locked_at, type: Time
35 | end
36 |
--------------------------------------------------------------------------------
/test/rails_app/app/mongoid/user_with_validations.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "shared_user"
4 |
5 | class UserWithValidations
6 | include Mongoid::Document
7 | include Shim
8 | include SharedUser
9 |
10 | field :username, type: String
11 | field :facebook_token, type: String
12 |
13 | ## Database authenticatable
14 | field :email, type: String, default: ""
15 | field :encrypted_password, type: String, default: ""
16 |
17 | ## Recoverable
18 | field :reset_password_token, type: String
19 | field :reset_password_sent_at, type: Time
20 |
21 | ## Rememberable
22 | field :remember_created_at, type: Time
23 |
24 | ## Trackable
25 | field :sign_in_count, type: Integer, default: 0
26 | field :current_sign_in_at, type: Time
27 | field :last_sign_in_at, type: Time
28 | field :current_sign_in_ip, type: String
29 | field :last_sign_in_ip, type: String
30 |
31 | ## Lockable
32 | field :failed_attempts, type: Integer, default: 0 # Only if lock strategy is :failed_attempts
33 | field :unlock_token, type: String # Only if unlock strategy is :email or :both
34 | field :locked_at, type: Time
35 |
36 | validates :email, presence: true
37 | end
38 |
--------------------------------------------------------------------------------
/.rubocop.yml:
--------------------------------------------------------------------------------
1 | AllCops:
2 | TargetRubyVersion: 2.6
3 | Exclude:
4 | - 'test/rails_app/**/*.rb'
5 |
6 | Style/StringLiterals:
7 | Enabled: true
8 | EnforcedStyle: double_quotes
9 |
10 | Style/StringLiteralsInInterpolation:
11 | Enabled: true
12 | EnforcedStyle: double_quotes
13 |
14 | Layout/LineLength:
15 | Max: 120
16 |
17 |
18 | Style/SignalException:
19 | Exclude:
20 | - 'lib/devise/passkeys/strategy.rb'
21 |
22 | Style/ClassAndModuleChildren:
23 | Enabled: false
24 |
25 | Metrics/MethodLength:
26 | Max: 50
27 |
28 | Metrics/ModuleLength:
29 | Max: 300
30 |
31 | Layout/LineLength:
32 | Max: 180
33 |
34 | Lint/UselessAssignment:
35 | Exclude:
36 | - 'test/**/*.rb'
37 |
38 | Metrics/BlockLength:
39 | Exclude:
40 | - 'test/**/*.rb'
41 |
42 | Metrics/ClassLength:
43 | Exclude:
44 | - 'test/**/*.rb'
45 |
46 | Naming/VariableNumber:
47 | Exclude:
48 | - 'test/**/*.rb'
49 |
50 | Metrics/AbcSize:
51 | Exclude:
52 | - 'test/**/*.rb'
53 |
54 | Gemspec/DeprecatedAttributeAssignment: # new in 1.30
55 | Enabled: true
56 | Gemspec/DevelopmentDependencies: # new in 1.44
57 | Enabled: true
58 | Gemspec/RequireMFA: # new in 1.23
59 | Enabled: true
--------------------------------------------------------------------------------
/test/rails_app/config/boot.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | unless defined?(DEVISE_ORM)
4 | DEVISE_ORM = (ENV["DEVISE_ORM"] || :active_record).to_sym
5 | end
6 |
7 | module Devise
8 | module Test
9 | # Detection for minor differences between Rails versions in tests.
10 |
11 | def self.rails71_and_up?
12 | !rails70? && Rails::VERSION::MAJOR >= 7
13 | end
14 |
15 | def self.rails70?
16 | Rails.version.start_with? '7.0'
17 | end
18 |
19 | def self.rails6_and_up?
20 | Rails::VERSION::MAJOR >= 6
21 | end
22 |
23 | def self.rails52_and_up?
24 | Rails::VERSION::MAJOR > 5 || rails52?
25 | end
26 |
27 | def self.rails52?
28 | Rails.version.start_with? '5.2'
29 | end
30 |
31 | def self.rails51?
32 | Rails.version.start_with? '5.1'
33 | end
34 |
35 | def self.rails5_and_up?
36 | Rails::VERSION::MAJOR >= 5
37 | end
38 |
39 | def self.rails5?
40 | Rails.version.start_with? '5'
41 | end
42 | end
43 | end
44 |
45 | # Set up gems listed in the Gemfile.
46 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../../../Gemfile', __FILE__)
47 | require 'bundler/setup' if File.exist?(ENV['BUNDLE_GEMFILE'])
48 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Installation
4 |
5 | After checking out the repo, run the following to install both Bundler and Appraisal dependencies:
6 |
7 | ```sh
8 | bin/setup
9 | ```
10 |
11 | # Run tests
12 |
13 | To run the test suite for all Appraisal variants, run:
14 |
15 | ```sh
16 | bundle exec appraisal rake test
17 | ```
18 |
19 | or
20 |
21 | ```sh
22 | bin/test
23 | ```
24 |
25 | ## Writing Documentation
26 |
27 | Documentation is written using [YARD](http://yardoc.org/).
28 |
29 | You can start a YARD server to view the generated documentation (with automatic reloading) by running:
30 |
31 | ```sh
32 | bin/yard
33 | ```
34 |
35 | The documentation site will now be available at [http://localhost:8808](http://localhost:8808)
36 |
37 | ## Interactive Console
38 |
39 | You can also run the following for an interactive prompt that will allow you to experiment:
40 |
41 | ```sh
42 | bin/console
43 | ```
44 |
45 | # Gem installation & cutting
46 |
47 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
48 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/controllers/sessions_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | module Controllers
6 | module SessionsControllerConcern
7 | extend ActiveSupport::Concern
8 |
9 | included do
10 | include Warden::WebAuthn::AuthenticationInitiationHelpers
11 | include Warden::WebAuthn::RackHelpers
12 |
13 | # Prepending is crucial to ensure that the relying party is set in the
14 | # request.env before the strategy is executed
15 | prepend_before_action :set_relying_party_in_request_env
16 |
17 | def authentication_challenge_key
18 | "#{resource_name}_current_webauthn_authentication_challenge"
19 | end
20 | end
21 |
22 | def new_challenge
23 | options_for_authentication = generate_authentication_options(relying_party: relying_party)
24 |
25 | store_challenge_in_session(options_for_authentication: options_for_authentication)
26 |
27 | render json: options_for_authentication
28 | end
29 |
30 | protected
31 |
32 | def relying_party
33 | raise NoMethodError, "need to define relying_party for this #{self.class.name}"
34 | end
35 | end
36 | end
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/test/rails_app/config/environments/development.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RailsApp::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 on
7 | # every request. 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 and disable caching.
15 | config.consider_all_requests_local = true
16 | config.action_controller.perform_caching = false
17 |
18 | # Don't care if the mailer can't send.
19 | config.action_mailer.raise_delivery_errors = false
20 |
21 | # Print deprecation notices to the Rails logger.
22 | config.active_support.deprecation = :log
23 |
24 | # Only use best-standards-support built into browsers.
25 | config.action_dispatch.best_standards_support = :builtin
26 |
27 | # Raise an error on page load if there are pending migrations
28 | config.active_record.migration_error = :page_load
29 |
30 | # Debug mode disables concatenation and preprocessing of assets.
31 | config.assets.debug = true
32 | end
33 |
--------------------------------------------------------------------------------
/test/rails_app/app/mongoid/user_on_engine.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_user_without_omniauth'
4 |
5 | class UserOnEngine
6 | include Mongoid::Document
7 | include Shim
8 | include SharedUserWithoutOmniauth
9 |
10 | field :username, type: String
11 | field :facebook_token, type: String
12 |
13 | ## Database authenticatable
14 | field :email, type: String, default: ""
15 | field :encrypted_password, type: String, default: ""
16 |
17 | ## Recoverable
18 | field :reset_password_token, type: String
19 | field :reset_password_sent_at, type: Time
20 |
21 | ## Rememberable
22 | field :remember_created_at, type: Time
23 |
24 | ## Trackable
25 | field :sign_in_count, type: Integer, default: 0
26 | field :current_sign_in_at, type: Time
27 | field :last_sign_in_at, type: Time
28 | field :current_sign_in_ip, type: String
29 | field :last_sign_in_ip, type: String
30 |
31 | ## Confirmable
32 | field :confirmation_token, type: String
33 | field :confirmed_at, type: Time
34 | field :confirmation_sent_at, type: Time
35 | # field :unconfirmed_email, type: String # Only if using reconfirmable
36 |
37 | ## Lockable
38 | field :failed_attempts, type: Integer, default: 0 # Only if lock strategy is :failed_attempts
39 | field :unlock_token, type: String # Only if unlock strategy is :email or :both
40 | field :locked_at, type: Time
41 | end
42 |
--------------------------------------------------------------------------------
/test/rails_app/app/mongoid/user_on_main_app.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_user_without_omniauth'
4 |
5 | class UserOnMainApp
6 | include Mongoid::Document
7 | include Shim
8 | include SharedUserWithoutOmniauth
9 |
10 | field :username, type: String
11 | field :facebook_token, type: String
12 |
13 | ## Database authenticatable
14 | field :email, type: String, default: ""
15 | field :encrypted_password, type: String, default: ""
16 |
17 | ## Recoverable
18 | field :reset_password_token, type: String
19 | field :reset_password_sent_at, type: Time
20 |
21 | ## Rememberable
22 | field :remember_created_at, type: Time
23 |
24 | ## Trackable
25 | field :sign_in_count, type: Integer, default: 0
26 | field :current_sign_in_at, type: Time
27 | field :last_sign_in_at, type: Time
28 | field :current_sign_in_ip, type: String
29 | field :last_sign_in_ip, type: String
30 |
31 | ## Confirmable
32 | field :confirmation_token, type: String
33 | field :confirmed_at, type: Time
34 | field :confirmation_sent_at, type: Time
35 | # field :unconfirmed_email, type: String # Only if using reconfirmable
36 |
37 | ## Lockable
38 | field :failed_attempts, type: Integer, default: 0 # Only if lock strategy is :failed_attempts
39 | field :unlock_token, type: String # Only if unlock strategy is :email or :both
40 | field :locked_at, type: Time
41 | end
42 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/passkey_issuer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | class PasskeyIssuer
6 | def self.build
7 | new
8 | end
9 |
10 | def create_and_return_passkey(resource:, label:, webauthn_credential:, extra_attributes: {})
11 | # rubocop:disable Lint/UselessAssignment
12 | passkey_class = passkey_class(resource)
13 | # rubocop:enable Lint/UselessAssignment
14 |
15 | resource.passkeys.create!({
16 | label: label,
17 | public_key: webauthn_credential.public_key,
18 | external_id: Base64.strict_encode64(webauthn_credential.raw_id),
19 | sign_count: webauthn_credential.sign_count,
20 | last_used_at: nil
21 | }.merge(extra_attributes))
22 | end
23 |
24 | class CredentialFinder
25 | attr_reader :resource_class
26 |
27 | def initialize(resource_class:)
28 | @resource_class = resource_class
29 | end
30 |
31 | def find_with_credential_id(encoded_credential_id)
32 | resource_class.passkeys_class.where(external_id: encoded_credential_id).first
33 | end
34 | end
35 |
36 | private
37 |
38 | def passkey_class(resource)
39 | if resource.respond_to?(:association) # ActiveRecord
40 | resource.association(:passkeys).klass
41 | elsif resource.respond_to?(:relations) # Mongoid
42 | resource.relations["passkeys"].klass
43 | else
44 | raise "Cannot determine passkey class, unsupported ORM/ODM?"
45 | end
46 | end
47 | end
48 | end
49 | end
50 |
--------------------------------------------------------------------------------
/test/rails_app/app/mongoid/user.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require 'shared_user'
4 |
5 | class User
6 | include Mongoid::Document
7 | include Shim
8 | include SharedUser
9 |
10 | field :username, type: String
11 | field :facebook_token, type: String
12 |
13 | ## Database authenticatable
14 | field :email, type: String, default: ""
15 | field :encrypted_password, type: String, default: ""
16 |
17 | ## Recoverable
18 | field :reset_password_token, type: String
19 | field :reset_password_sent_at, type: Time
20 |
21 | ## Rememberable
22 | field :remember_created_at, type: Time
23 |
24 | ## Trackable
25 | field :sign_in_count, type: Integer, default: 0
26 | field :current_sign_in_at, type: Time
27 | field :last_sign_in_at, type: Time
28 | field :current_sign_in_ip, type: String
29 | field :last_sign_in_ip, type: String
30 |
31 | ## Confirmable
32 | field :confirmation_token, type: String
33 | field :confirmed_at, type: Time
34 | field :confirmation_sent_at, type: Time
35 | # field :unconfirmed_email, type: String # Only if using reconfirmable
36 |
37 | ## Lockable
38 | field :failed_attempts, type: Integer, default: 0 # Only if lock strategy is :failed_attempts
39 | field :unlock_token, type: String # Only if unlock strategy is :email or :both
40 | field :locked_at, type: Time
41 |
42 | cattr_accessor :after_passkey_authentication_passkey
43 |
44 | def after_passkey_authentication(passkey:)
45 | # used to check in our test if the callbacks were called
46 | @@after_passkey_authentication_passkey = passkey.label
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/devise-passkeys.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/devise/passkeys/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "devise-passkeys"
7 | spec.version = Devise::Passkeys::VERSION
8 | spec.authors = ["Thomas Cannon"]
9 | spec.email = ["tcannon00@gmail.com"]
10 |
11 | spec.summary = "Use passkeys instead of passwords for Devise"
12 | spec.description = "A Devise extension to use passkeys instead of passwords for authentication, using warden-webauthn"
13 | spec.homepage = "https://github.com/ruby-passkeys/devise-passkeys"
14 | spec.license = "MIT"
15 | spec.required_ruby_version = ">= 2.6.0"
16 |
17 | spec.metadata["allowed_push_host"] = "https://rubygems.org"
18 |
19 | spec.metadata["homepage_uri"] = spec.homepage
20 | spec.metadata["source_code_uri"] = "https://github.com/ruby-passkeys/devise-passkeys"
21 | spec.metadata["changelog_uri"] = "https://github.com/ruby-passkeys/devise-passkeys/blob/main/CHANGELOG.md"
22 |
23 | # Specify which files should be added to the gem when it is released.
24 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
25 | spec.files = Dir.chdir(File.expand_path(__dir__)) do
26 | `git ls-files -z`.split("\x0").reject do |f|
27 | (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
28 | end
29 | end
30 | spec.bindir = "exe"
31 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
32 | spec.require_paths = ["lib"]
33 |
34 | # Uncomment to register a new dependency of your gem
35 | spec.add_dependency "devise", ">= 4.7.1"
36 | spec.add_dependency "warden-webauthn", ">= 0.3.0"
37 |
38 | # For more information and examples about making a new gem, check out our
39 | # guide at: https://bundler.io/guides/creating_gem.html
40 | spec.metadata["rubygems_mfa_required"] = "true"
41 | end
42 |
--------------------------------------------------------------------------------
/lib/devise/passkeys.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "devise"
4 | require "warden/webauthn"
5 | require_relative "passkeys/rails"
6 | require_relative "passkeys/model"
7 | require_relative "passkeys/controllers"
8 | require_relative "passkeys/passkey_issuer"
9 | require_relative "passkeys/strategy"
10 | require_relative "passkeys/reauthentication_strategy"
11 | require_relative "passkeys/version"
12 |
13 | module Devise
14 | # This module provides a devise extension to use passkeys instead
15 | # of passwords for user authentication.
16 | #
17 | # It is lightweight and non-configurable. It does what it has to do and
18 | # leaves some manual implementation to you.
19 | #
20 | # Please consult the {file:README.md#label-Usage} for installation & configuration instructions;
21 | # and the links below for additional reading about:
22 | #
23 | # - What passkeys are
24 | # - The underlying gems used to build this devise extension
25 | # - Platform support & user interface implementation guides
26 | #
27 | # @see https://webauthn.guide
28 | # @see https://passkeys.dev
29 | # @see https://fidoalliance.org/passkeys
30 | # @see https://github.com/cedarcode/webauthn-ruby
31 | # @see https://github.com/ruby-passkeys/warden-webauthn
32 | module Passkeys
33 | # This is a helper method that creates and returns a passkey for
34 | # the given user (`resource`), using the provided label & `WebAuthn::Credential`
35 | # @see PasskeyIssuer#create_and_return_passkey
36 | # @return A saved passkey for the the given user (`resource`)
37 | def self.create_and_return_passkey(resource:, label:, webauthn_credential:, extra_attributes: {})
38 | PasskeyIssuer.build.create_and_return_passkey(
39 | resource: resource,
40 | label: label,
41 | webauthn_credential: webauthn_credential,
42 | extra_attributes: extra_attributes
43 | )
44 | end
45 | end
46 | end
47 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/strategy.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "devise/strategies/authenticatable"
4 | require_relative "passkey_issuer"
5 |
6 | module Devise
7 | module Strategies
8 | class PasskeyAuthenticatable < Authenticatable
9 | include Warden::WebAuthn::StrategyHelpers
10 |
11 | def store?
12 | super && !mapping.to.skip_session_storage.include?(:passkey_auth)
13 | end
14 |
15 | def valid?
16 | return true unless parsed_credential.nil?
17 |
18 | # rubocop:disable Lint/UnreachableCode
19 | fail(:credential_missing_or_could_not_be_parsed)
20 | false
21 | # rubocop:enable Lint/UnreachableCode
22 | end
23 |
24 | def authenticate!
25 | passkey = verify_authentication_and_find_stored_credential
26 |
27 | return if passkey.nil?
28 |
29 | resource = mapping.to.find_for_passkey(passkey)
30 |
31 | return fail(:invalid_passkey) unless resource
32 |
33 | if validate(resource)
34 | remember_me(resource)
35 | resource.after_passkey_authentication(passkey: passkey)
36 | record_passkey_use(passkey: passkey)
37 | success!(resource)
38 | return
39 | end
40 |
41 | # In paranoid mode, fail with a generic invalid error
42 | Devise.paranoid ? fail(:invalid_passkey) : fail(:not_found_in_database)
43 | end
44 |
45 | def credential_finder
46 | Devise::Passkeys::PasskeyIssuer::CredentialFinder.new(resource_class: mapping.to)
47 | end
48 |
49 | def raw_credential
50 | params.dig(mapping.singular, :passkey_credential)
51 | end
52 |
53 | def authentication_challenge_key
54 | "#{mapping.singular}_current_webauthn_authentication_challenge"
55 | end
56 |
57 | def record_passkey_use(passkey:)
58 | passkey.update_attribute(:last_used_at, Time.current)
59 | end
60 | end
61 | end
62 | end
63 |
64 | Warden::Strategies.add(:passkey_authenticatable, Devise::Strategies::PasskeyAuthenticatable)
65 |
--------------------------------------------------------------------------------
/test/rails_app/config/application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require File.expand_path('../boot', __FILE__)
4 |
5 | require "action_controller/railtie"
6 | require "action_mailer/railtie"
7 | require "rails/test_unit/railtie"
8 |
9 | Bundler.require :default, DEVISE_ORM
10 |
11 | begin
12 | require "#{DEVISE_ORM}/railtie"
13 | rescue LoadError
14 | end
15 |
16 | require "devise"
17 |
18 | module RailsApp
19 | class Application < Rails::Application
20 | # Add additional load paths for your own custom dirs
21 | config.autoload_paths.reject!{ |p| p =~ /\/app\/(\w+)$/ && !%w(controllers helpers mailers views).include?($1) }
22 | config.autoload_paths += ["#{config.root}/app/#{DEVISE_ORM}"]
23 |
24 | # Configure generators values. Many other options are available, be sure to check the documentation.
25 | # config.generators do |g|
26 | # g.orm :active_record
27 | # g.template_engine :erb
28 | # g.test_framework :test_unit, fixture: true
29 | # end
30 |
31 | # Configure sensitive parameters which will be filtered from the log file.
32 | config.filter_parameters << :password
33 | # config.assets.enabled = false
34 |
35 | config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
36 | rails_version = Gem::Version.new(Rails.version)
37 | if DEVISE_ORM == :active_record &&
38 | rails_version >= Gem::Version.new('4.2.0') &&
39 | rails_version < Gem::Version.new('5.1.0')
40 | config.active_record.raise_in_transactional_callbacks = true
41 | end
42 |
43 | # This was used to break devise in some situations
44 | config.to_prepare do
45 | Devise::SessionsController.layout "application"
46 | end
47 |
48 | # Remove the first check once Rails 5.0 support is removed.
49 | if Devise::Test.rails52_and_up? && !Devise::Test.rails6_and_up?
50 | Rails.application.config.active_record.sqlite3.represent_boolean_as_integer = true
51 | end
52 |
53 | if Devise::Test.rails70?
54 | config.active_record.legacy_connection_handling = false
55 | end
56 | end
57 | end
58 |
--------------------------------------------------------------------------------
/test/rails_app/config/environments/test.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RailsApp::Application.configure do
4 | # Settings specified here will take precedence over those in config/application.rb.
5 |
6 | # The test environment is used exclusively to run your application's
7 | # test suite. You never need to work with it otherwise. Remember that
8 | # your test database is "scratch space" for the test suite and is wiped
9 | # and recreated between test runs. Don't rely on the data there!
10 | config.cache_classes = true
11 |
12 | # Do not eager load code on boot. This avoids loading your whole application
13 | # just for the purpose of running a single test. If you are using a tool that
14 | # preloads Rails for running tests, you may have to set it to true.
15 | config.eager_load = false
16 |
17 | # Disable serving static files from the `/public` folder by default since
18 | # Apache or NGINX already handles this.
19 | if Devise::Test.rails5_and_up?
20 | config.public_file_server.enabled = true
21 | config.public_file_server.headers = {'Cache-Control' => 'public, max-age=3600'}
22 | elsif Rails.version >= "4.2.0"
23 | config.serve_static_files = true
24 | config.static_cache_control = "public, max-age=3600"
25 | else
26 | config.serve_static_assets = true
27 | config.static_cache_control = "public, max-age=3600"
28 | end
29 |
30 | # Show full error reports and disable caching.
31 | config.consider_all_requests_local = true
32 | config.action_controller.perform_caching = false
33 |
34 | # Raise exceptions instead of rendering exception templates.
35 | config.action_dispatch.show_exceptions = false
36 |
37 | # Disable request forgery protection in test environment.
38 | config.action_controller.allow_forgery_protection = false
39 |
40 | # Tell Action Mailer not to deliver emails to the real world.
41 | # The :test delivery method accumulates sent emails in the
42 | # ActionMailer::Base.deliveries array.
43 | config.action_mailer.delivery_method = :test
44 |
45 | # Print deprecation notices to the stderr.
46 | config.active_support.deprecation = :stderr
47 | end
48 |
--------------------------------------------------------------------------------
/test/test_helper/webauthn_test_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "webauthn/fake_client"
4 |
5 | module WebAuthnTestHelpers
6 | def example_relying_party(options: {})
7 | WebAuthn::RelyingParty.new(**{
8 | origin: "https://example.test",
9 | name: "Example Relying Party"
10 | }.merge(options))
11 | end
12 |
13 | def fake_client(origin: "https://example.test")
14 | WebAuthn::FakeClient.new(origin)
15 | end
16 |
17 | def generate_raw_challenge
18 | SecureRandom.random_bytes(32)
19 | end
20 |
21 | def encode_challenge(raw_challenge: generate_raw_challenge)
22 | Base64.strict_encode64(raw_challenge)
23 | end
24 |
25 | def assertion_from_client(client:, challenge:, user_verified: true)
26 | client.get(challenge: challenge, user_verified: user_verified)
27 | end
28 |
29 | def create_raw_credential(credential_hash:, relying_party:)
30 | WebAuthn::Credential.from_create(credential_hash, relying_party: relying_party)
31 | end
32 |
33 | def assertion_response(assertion:)
34 | WebAuthn::AuthenticatorAssertionResponse.new(
35 | client_data_json: assertion["response"]["clientDataJSON"],
36 | authenticator_data: assertion["response"]["authenticatorData"],
37 | signature: assertion["response"]["signature"]
38 | )
39 | end
40 |
41 | def create_credential(client:, relying_party:, rp_id: nil)
42 | rp_id ||= relying_party.id || URI.parse(client.origin).host
43 |
44 | create_result = client.create(rp_id: rp_id)
45 |
46 | attestation_object =
47 | if client.encoding
48 | relying_party.encoder.decode(create_result["response"]["attestationObject"])
49 | else
50 | create_result["response"]["attestationObject"]
51 | end
52 |
53 | client_data_json =
54 | if client.encoding
55 | relying_party.encoder.decode(create_result["response"]["clientDataJSON"])
56 | else
57 | create_result["response"]["clientDataJSON"]
58 | end
59 |
60 | response = WebAuthn::AuthenticatorAttestationResponse.new(
61 | attestation_object: attestation_object,
62 | client_data_json: client_data_json,
63 | relying_party: relying_party
64 | )
65 |
66 | response.credential
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/controllers/concerns/reauthentication_challenge.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | module Controllers
6 | module Concerns
7 | # This concern is responsible for storing the reauthentication challenge in the session.
8 | #
9 | # A reauthentication challenge is a WebAuthn challenge exchange (i.e. authentication)
10 | # to verify the user's identity and confirm they're able to perform a sensitive action
11 | # by performing the entire authentication process.
12 | #
13 | # This can be used for scenarios such as:
14 | #
15 | # - Adding a new passkey
16 | # - Deleting a passkey
17 | # - Performing sensitive actions inside your application
18 | #
19 | # You can customize which reauthentication challenge you're using by changing
20 | # the `passkey_reauthentication_challenge_session_key` method after including this concern
21 | #
22 | # @see Devise::Passkeys::Controllers::ReauthenticationControllerConcern
23 | module ReauthenticationChallenge
24 | extend ActiveSupport::Concern
25 |
26 | # This method is responsible for generating the key that will be used to store the
27 | # reauthentication challenge in the session hash.
28 | #
29 | # @return [String] The reauthentication challenge session key
30 | def passkey_reauthentication_challenge_session_key
31 | "#{resource_name}_current_reauthentication_challenge"
32 | end
33 |
34 | # This method is responsible for storing the reauthentication challenge in the session.
35 | #
36 | # @param [WebAuthn::PublicKeyCredential::RequestOptions] options_for_authentication the options for authentication,
37 | # generated by `webauthn-ruby`
38 | # @return [String] The reauthentication challenge
39 | # @see Devise::Passkeys::Controllers::ReauthenticationControllerConcern#new_challenge
40 | def store_reauthentication_challenge_in_session(options_for_authentication:)
41 | session[passkey_reauthentication_challenge_session_key] = options_for_authentication.challenge
42 | end
43 | end
44 | end
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/test/devise/test_passkeys.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../test_helper/webauthn_test_helpers"
5 |
6 | class Devise::TestPasskeys < ActiveSupport::TestCase
7 | include WebAuthnTestHelpers
8 | test "has version number" do
9 | refute_nil ::Devise::Passkeys::VERSION
10 | end
11 |
12 | test "create_and_return_passkey" do
13 | user = User.create!(email: "test@test.com")
14 |
15 | relying_party = example_relying_party
16 | client = fake_client(origin: relying_party.origin)
17 | credential = create_raw_credential(credential_hash: client.create, relying_party: relying_party)
18 |
19 | passkey = Devise::Passkeys.create_and_return_passkey(resource: user, label: "Test Key",
20 | webauthn_credential: credential)
21 | assert_equal true, passkey.persisted?
22 |
23 | UserPasskey.find(passkey.id)
24 |
25 | user.passkeys.reload
26 |
27 | assert_equal user, passkey.user
28 | assert_equal "Test Key", passkey.label
29 |
30 | assert_equal credential.public_key, passkey.public_key
31 | assert_equal Base64.strict_encode64(credential.raw_id), passkey.external_id
32 | assert_equal credential.sign_count, passkey.sign_count
33 | assert_nil passkey.last_used_at
34 | end
35 |
36 | test "create_and_return_passkey with extra attributes" do
37 | user = User.create!(email: "test@test.com")
38 |
39 | relying_party = example_relying_party
40 | client = fake_client(origin: relying_party.origin)
41 | credential = create_raw_credential(credential_hash: client.create, relying_party: relying_party)
42 |
43 | registration_time = Time.current
44 |
45 | passkey = Devise::Passkeys.create_and_return_passkey(
46 | resource: user,
47 | label: "Test Key",
48 | webauthn_credential: credential,
49 | extra_attributes: { last_used_at: registration_time, sign_count: 234 }
50 | )
51 | assert_equal true, passkey.persisted?
52 |
53 | UserPasskey.find(passkey.id)
54 |
55 | user.passkeys.reload
56 |
57 | assert_equal user, passkey.user
58 | assert_equal "Test Key", passkey.label
59 |
60 | assert_equal credential.public_key, passkey.public_key
61 | assert_equal Base64.strict_encode64(credential.raw_id), passkey.external_id
62 | assert_equal 234, passkey.sign_count
63 | assert_equal registration_time, passkey.last_used_at
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/test/rails_app/db/schema.rb:
--------------------------------------------------------------------------------
1 | # encoding: UTF-8
2 | # frozen_string_literal: true
3 |
4 | # This file is auto-generated from the current state of the database. Instead
5 | # of editing this file, please use the migrations feature of Active Record to
6 | # incrementally modify your database, and then regenerate this schema definition.
7 | #
8 | # Note that this schema.rb definition is the authoritative source for your
9 | # database schema. If you need to create the application database on another
10 | # system, you should be using db:schema:load, not running all the migrations
11 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations
12 | # you'll amass, the slower it'll run and the greater likelihood for issues).
13 | #
14 | # It's strongly recommended that you check this file into your version control system.
15 |
16 | ActiveRecord::Schema.define(version: 20100401102949) do
17 |
18 | create_table "admins", force: true do |t|
19 | t.string "email"
20 | t.datetime "remember_created_at"
21 | t.string "confirmation_token"
22 | t.datetime "confirmed_at"
23 | t.datetime "confirmation_sent_at"
24 | t.string "unconfirmed_email"
25 | t.datetime "locked_at"
26 | t.boolean "active", default: false
27 | t.datetime "created_at"
28 | t.datetime "updated_at"
29 | end
30 |
31 | create_table "users", force: true do |t|
32 | t.string "username"
33 | t.string "email", default: "", null: false
34 | t.string "webauthn_id", null: false
35 | t.datetime "remember_created_at"
36 | t.integer "sign_in_count", default: 0
37 | t.datetime "current_sign_in_at"
38 | t.datetime "last_sign_in_at"
39 | t.string "current_sign_in_ip"
40 | t.string "last_sign_in_ip"
41 | t.string "confirmation_token"
42 | t.datetime "confirmed_at"
43 | t.datetime "confirmation_sent_at"
44 | t.integer "failed_attempts", default: 0
45 | t.string "unlock_token"
46 | t.datetime "locked_at"
47 | t.datetime "created_at"
48 | t.datetime "updated_at"
49 | end
50 |
51 | create_table "passkeys", force: :cascade do |t|
52 | t.integer "user_id"
53 | t.string "label"
54 | t.string "external_id"
55 | t.string "public_key"
56 | t.integer "sign_count", default: 0, null: false
57 | t.datetime "last_used_at"
58 | t.datetime "created_at", null: false
59 | t.datetime "updated_at", null: false
60 | end
61 |
62 | end
63 |
--------------------------------------------------------------------------------
/test/rails_app/db/migrate/20100401102949_create_tables.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | superclass = ActiveRecord::Migration
4 | # TODO: Inherit from the 5.0 Migration class directly when we drop support for Rails 4.
5 | superclass = ActiveRecord::Migration[5.0] if superclass.respond_to?(:[])
6 |
7 | class CreateTables < superclass
8 | def self.up
9 | create_table :users do |t|
10 | t.string :username
11 |
12 | t.string :webauthn_id
13 |
14 | ## Database authenticatable
15 | t.string :email, null: false, default: ""
16 |
17 | ## Rememberable
18 | t.datetime :remember_created_at
19 |
20 | ## Trackable
21 | t.integer :sign_in_count, default: 0
22 | t.datetime :current_sign_in_at
23 | t.datetime :last_sign_in_at
24 | t.string :current_sign_in_ip
25 | t.string :last_sign_in_ip
26 |
27 | ## Confirmable
28 | t.string :confirmation_token
29 | t.datetime :confirmed_at
30 | t.datetime :confirmation_sent_at
31 | # t.string :unconfirmed_email # Only if using reconfirmable
32 |
33 | ## Lockable
34 | t.integer :failed_attempts, default: 0 # Only if lock strategy is :failed_attempts
35 | t.string :unlock_token # Only if unlock strategy is :email or :both
36 | t.datetime :locked_at
37 |
38 | t.timestamps null: false
39 | end
40 |
41 | create_table :admins do |t|
42 | ## Database authenticatable
43 | t.string :email, null: true
44 | t.string :encrypted_password, null: true
45 |
46 | ## Rememberable
47 | t.datetime :remember_created_at
48 |
49 | ## Confirmable
50 | t.string :confirmation_token
51 | t.datetime :confirmed_at
52 | t.datetime :confirmation_sent_at
53 | t.string :unconfirmed_email # Only if using reconfirmable
54 |
55 | ## Lockable
56 | t.datetime :locked_at
57 |
58 | ## Attribute for testing route blocks
59 | t.boolean :active, default: false
60 |
61 | t.timestamps null: false
62 | end
63 |
64 | create_table :user_passkeys do |t|
65 | t.integer :user_id, null: false
66 | t.string :label, null: false
67 |
68 | t.string :external_id, null: false
69 | t.string :public_key, null: false
70 | t.integer :sign_count, default: 0, null: false
71 |
72 | t.datetime :last_used_at
73 |
74 | t.timestamps null: false
75 | end
76 | end
77 |
78 | def self.down
79 | drop_table :users
80 | drop_table :admins
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/test/devise/passkeys/controllers/concerns/test_reauthentication_challenge.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class Devise::Passkeys::Controllers::Concerns::TestReauthenticationChallenge < ActiveSupport::TestCase
6 | class TestClass
7 | include Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge
8 |
9 | attr_accessor :session
10 |
11 | def initialize
12 | self.session = {}
13 | end
14 |
15 | def resource_name
16 | "test_value:1234"
17 | end
18 | end
19 |
20 | setup do
21 | @test_class = TestClass.new
22 | end
23 |
24 | test "#store_reauthentication_challenge_in_session" do
25 | options_for_authentication = OpenStruct.new(challenge: SecureRandom.random_bytes(50))
26 | assert_nil @test_class.session["test_value:1234_current_reauthentication_challenge"]
27 |
28 | token = @test_class.store_reauthentication_challenge_in_session(options_for_authentication: options_for_authentication)
29 | refute_nil token
30 |
31 | assert_equal token, @test_class.session["test_value:1234_current_reauthentication_challenge"]
32 | end
33 |
34 | test "#passkey_reauthentication_challenge_session_key" do
35 | assert_equal "test_value:1234_current_reauthentication_challenge",
36 | @test_class.passkey_reauthentication_challenge_session_key
37 | end
38 | end
39 |
40 | class Devise::Passkeys::Controllers::Concerns::TestReauthenticationChallengeCustomization < ActiveSupport::TestCase
41 | class TestClass
42 | include Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge
43 |
44 | attr_accessor :session
45 |
46 | def initialize
47 | self.session = {}
48 | end
49 |
50 | def passkey_reauthentication_challenge_session_key
51 | "passkey_reauth_challenge"
52 | end
53 | end
54 |
55 | setup do
56 | @test_class = TestClass.new
57 | end
58 |
59 | test "#store_reauthentication_challenge_in_session" do
60 | options_for_authentication = OpenStruct.new(challenge: SecureRandom.random_bytes(50))
61 | assert_nil @test_class.session["passkey_reauth_challenge"]
62 |
63 | token = @test_class.store_reauthentication_challenge_in_session(options_for_authentication: options_for_authentication)
64 | refute_nil token
65 |
66 | assert_equal token, @test_class.session["passkey_reauth_challenge"]
67 | end
68 |
69 | test "#passkey_reauthentication_challenge_session_key" do
70 | assert_equal "passkey_reauth_challenge", @test_class.passkey_reauthentication_challenge_session_key
71 | end
72 | end
73 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## [0.3.0] - 2023-08-18
2 |
3 | ### Bugfixes
4 |
5 | - Remove default `Devise.add_module`, remove incorrect `no_input: true` declaration in documentation
6 | - https://github.com/ruby-passkeys/devise-passkeys/pull/48
7 |
8 | ## [0.2.0] - 2023-07-07
9 |
10 | ### Bugfixes
11 | - Fixed bug with `Devise::Strategies::PasskeyReauthentication` clearing the CSRF token after reauthentication
12 | - https://github.com/ruby-passkeys/devise-passkeys/pull/45
13 | - Fixed bug where `RegistrationsControllerConcern` was using `:user` as the Strong Parameters key, rather than `resource_key`
14 | - https://github.com/ruby-passkeys/devise-passkeys/commit/5ef8c83ffe57b3719ab574a01c710ee3ba7dcfb1
15 | - Rename `create_resource_and_passkey` => `create_passkey_for_resource`
16 | - https://github.com/ruby-passkeys/devise-passkeys/pull/37
17 | - `ReauthenticationControllerConcern` and `SessionsControllerConcern` raise `NoMethodError` if the `relying_party` has not been overridden
18 | - https://github.com/ruby-passkeys/devise-passkeys/pull/32
19 |
20 | ### Refactoring
21 |
22 | - Refactor PasskeysControllerConcern to have clearer credential verify with `verify_credential_integrity`
23 | -https://github.com/ruby-passkeys/devise-passkeys/pull/29/commits/f1400cb4b217c20b9e74fda3f55f74284e373d25
24 | - Refactor Controller concerns to not use `Warden::WebAuthn::StrategyHelpers`
25 | - https://github.com/ruby-passkeys/devise-passkeys/pull/29
26 | - Rename `Devise::Passkeys::Controllers::Concerns::PasskeyReauthentication` => `Devise::Passkeys::Controllers::Concerns::Reauthentication`
27 | - https://github.com/ruby-passkeys/devise-passkeys/pull/7/
28 | - Add `passkey:` keyword param to `after_passkey_authentication` callback
29 | - https://github.com/ruby-passkeys/devise-passkeys/pull/26
30 | - Removed unused `:maximum_passkeys_per_user` attribute
31 | - https://github.com/ruby-passkeys/devise-passkeys/pull/41
32 |
33 | ### Etc.
34 | - Bump to warden-webauthn 0.2.1
35 | - https://github.com/ruby-passkeys/devise-passkeys/pull/29/commits/d825ffded91aa98801bdd5530442761aa60538f9
36 | - Use `Warden::WebAuthn::RackHelper.set_relying_party_in_request_env` to streamline setup
37 | https://github.com/ruby-passkeys/devise-passkeys/pull/29/commits/7b7d50129ebe83b0a224d0ace0e4cff8ea407f4a
38 | - Bump `Devise` requirement to `>= 4.7.1`
39 | - https://github.com/ruby-passkeys/devise-passkeys/pull/11
40 | - Documentation
41 | - https://github.com/ruby-passkeys/devise-passkeys/pull/12
42 | - https://github.com/ruby-passkeys/devise-passkeys/pull/44
43 | - https://github.com/ruby-passkeys/devise-passkeys/pull/43
44 | - https://github.com/ruby-passkeys/devise-passkeys/pull/39
45 | - https://github.com/ruby-passkeys/devise-passkeys/pull/38
46 |
47 |
48 | ## [0.1.0] - 2023-05-07
49 |
50 | - Initial release
51 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/controllers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "controllers/concerns/reauthentication"
4 | require_relative "controllers/concerns/reauthentication_challenge"
5 | require_relative "controllers/sessions_controller_concern"
6 | require_relative "controllers/registrations_controller_concern"
7 | require_relative "controllers/reauthentication_controller_concern"
8 | require_relative "controllers/passkeys_controller_concern"
9 |
10 | module Devise
11 | module Passkeys
12 | # This module contains all the controller-level logic for:
13 | #
14 | # - User (resource) registration management (signup/delete account) using passkeys
15 | # - User (resource) management of their passkeys
16 | # - User (resource) authentication & reauthenticating using their passkeys
17 | #
18 | # Rather than having base classes, `Devise::Passkeys::Controllers` has a series of concerns
19 | # that can be mixed into your app's controllers. This allows you to change behavior,
20 | # and does not keep you stuck down a path that could be incompatible with your
21 | # existing authentication setup.
22 | #
23 | # @example
24 | # class Users::RegistrationsController < Devise::RegistrationsController
25 | # include Devise::Passkeys::Controllers::RegistrationsControllerConcern
26 | # end
27 | #
28 | #
29 | # class Users::SessionsController < Devise::SessionsController
30 | # include Devise::Passkeys::Controllers::SessionsControllerConcern
31 | # # ... any custom code you need
32 | #
33 | # def relying_party
34 | # WebAuthn::RelyingParty.new(...)
35 | # end
36 | # end
37 | #
38 | # # frozen_string_literal: true
39 | #
40 | # class Users::ReauthenticationController < DeviseController
41 | # include Devise::Passkeys::Controllers::ReauthenticationControllerConcern
42 | # # ... any custom code you need
43 | #
44 | # def relying_party
45 | # WebAuthn::RelyingParty.new(...)
46 | # end
47 | # end
48 | #
49 | # # frozen_string_literal: true
50 | #
51 | # class Users::PasskeysController < DeviseController
52 | # include Devise::Passkeys::Controllers::PasskeysControllerConcern
53 | # # ... any custom code you need
54 | #
55 | # def relying_party
56 | # WebAuthn::RelyingParty.new(...)
57 | # end
58 | # end
59 | #
60 | # *Note:* The `Devise::Passkeys::Controllers::Concerns` namespace is for:
61 | # > Code, related to the concerns for controllers, that can be extracted into a standalone
62 | # > module that can be included & extended as needed for apps that need
63 | # > to do something custom with their setup.
64 | # >
65 | # > https://github.com/ruby-passkeys/devise-passkeys/issues/4#issuecomment-1590357907
66 | module Controllers
67 | end
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/test/devise/test_passkey_issuer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../test_helper/webauthn_test_helpers"
5 |
6 | class Devise::TestPasskeyIssuer < ActiveSupport::TestCase
7 | include WebAuthnTestHelpers
8 |
9 | test "create_and_return_passkey" do
10 | user = User.create!(email: "test@test.com")
11 |
12 | relying_party = example_relying_party
13 | client = fake_client(origin: relying_party.origin)
14 | credential = create_raw_credential(credential_hash: client.create, relying_party: relying_party)
15 |
16 | passkey = Devise::Passkeys::PasskeyIssuer.build.create_and_return_passkey(resource: user, label: "Test Key",
17 | webauthn_credential: credential)
18 | assert_equal true, passkey.persisted?
19 |
20 | UserPasskey.find(passkey.id)
21 |
22 | user.passkeys.reload
23 |
24 | assert_equal user, passkey.user
25 | assert_equal "Test Key", passkey.label
26 |
27 | assert_equal credential.public_key, passkey.public_key
28 | assert_equal Base64.strict_encode64(credential.raw_id), passkey.external_id
29 | assert_equal credential.sign_count, passkey.sign_count
30 | assert_nil passkey.last_used_at
31 | end
32 |
33 | test "create_and_return_passkey with extra attributes" do
34 | user = User.create!(email: "test@test.com")
35 |
36 | relying_party = example_relying_party
37 | client = fake_client(origin: relying_party.origin)
38 | credential = create_raw_credential(credential_hash: client.create, relying_party: relying_party)
39 |
40 | registration_time = Time.current
41 |
42 | passkey = Devise::Passkeys::PasskeyIssuer.build.create_and_return_passkey(
43 | resource: user,
44 | label: "Test Key",
45 | webauthn_credential: credential,
46 | extra_attributes: { last_used_at: registration_time, sign_count: 234 }
47 | )
48 | assert_equal true, passkey.persisted?
49 |
50 | UserPasskey.find(passkey.id)
51 |
52 | user.passkeys.reload
53 |
54 | assert_equal user, passkey.user
55 | assert_equal "Test Key", passkey.label
56 |
57 | assert_equal credential.public_key, passkey.public_key
58 | assert_equal Base64.strict_encode64(credential.raw_id), passkey.external_id
59 | assert_equal 234, passkey.sign_count
60 | assert_equal registration_time, passkey.last_used_at
61 | end
62 | end
63 |
64 | class Devise::TestPasskeyCredentialFinder < ActiveSupport::TestCase
65 | test "find_with_credential_id" do
66 | finder = Devise::Passkeys::PasskeyIssuer::CredentialFinder.new(resource_class: User)
67 |
68 | user = User.create!(email: "test@test.com")
69 |
70 | encoded_credential_id_1 = Base64.strict_encode64(SecureRandom.random_bytes(32))
71 | encoded_credential_id_2 = Base64.strict_encode64(SecureRandom.random_bytes(32))
72 |
73 | passkey_1 = user.passkeys.create!(label: "dummy key", external_id: encoded_credential_id_1, public_key: "abbbcvcc")
74 | passkey_2 = user.passkeys.create!(label: "dummy key", external_id: encoded_credential_id_2, public_key: "abbbcvcc")
75 |
76 | assert_equal passkey_1, finder.find_with_credential_id(encoded_credential_id_1)
77 | assert_equal passkey_2, finder.find_with_credential_id(encoded_credential_id_2)
78 | assert_nil finder.find_with_credential_id(Base64.strict_encode64(SecureRandom.random_bytes(32)))
79 | end
80 |
81 | test "find_with_credential_id: no credentials" do
82 | finder = Devise::Passkeys::PasskeyIssuer::CredentialFinder.new(resource_class: User)
83 |
84 | assert_nil finder.find_with_credential_id(Base64.strict_encode64(SecureRandom.random_bytes(32)))
85 | end
86 | end
87 |
--------------------------------------------------------------------------------
/test/devise/passkeys/controllers/test_sessions_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../test_helper/webauthn_test_helpers"
5 |
6 | class Devise::Passkeys::Controllers::TestSessionsControllerConcern < ActionDispatch::IntegrationTest
7 | include WebAuthnTestHelpers
8 |
9 | class TestSessionController < ActionController::Base
10 | include Devise::Passkeys::Controllers::SessionsControllerConcern
11 |
12 | def relying_party
13 | WebAuthn::RelyingParty.new(origin: "test.host")
14 | end
15 |
16 | def resource_name
17 | :user
18 | end
19 | end
20 |
21 | setup do
22 | Rails.application.routes.draw do
23 | post "/session/new_challenge" => "devise/passkeys/controllers/test_sessions_controller_concern/test_session#new_challenge"
24 | end
25 | end
26 |
27 | teardown do
28 | Rails.application.reload_routes!
29 | end
30 |
31 | test "#new_challenge" do
32 | post "/session/new_challenge"
33 |
34 | response_json = JSON.parse(response.body)
35 |
36 | assert_equal response_json["challenge"], session["user_current_webauthn_authentication_challenge"]
37 | assert_equal 120_000, response_json["timeout"]
38 | assert_equal ({}), response_json["extensions"]
39 | assert_empty response_json["allowCredentials"]
40 | assert_equal "required", response_json["userVerification"]
41 | end
42 | end
43 |
44 | class Devise::Passkeys::Controllers::TestSessionsControllerConcernCustomization < ActionDispatch::IntegrationTest
45 | include WebAuthnTestHelpers
46 |
47 | class TestSessionController < ActionController::Base
48 | include Devise::Passkeys::Controllers::SessionsControllerConcern
49 |
50 | def relying_party
51 | WebAuthn::RelyingParty.new(origin: "test.host")
52 | end
53 |
54 | def resource_name
55 | "user"
56 | end
57 |
58 | def authentication_challenge_key
59 | "passkey_challenge"
60 | end
61 | end
62 |
63 | setup do
64 | Rails.application.routes.draw do
65 | post "/session/new_challenge" => "devise/passkeys/controllers/test_sessions_controller_concern_customization/test_session#new_challenge"
66 | end
67 | end
68 |
69 | teardown do
70 | Rails.application.reload_routes!
71 | end
72 |
73 | test "#new_challenge" do
74 | post "/session/new_challenge"
75 |
76 | response_json = JSON.parse(response.body)
77 |
78 | assert_equal response_json["challenge"], session["passkey_challenge"]
79 | assert_equal 120_000, response_json["timeout"]
80 | assert_equal ({}), response_json["extensions"]
81 | assert_empty response_json["allowCredentials"]
82 | assert_equal "required", response_json["userVerification"]
83 | end
84 | end
85 |
86 | class Devise::Passkeys::Controllers::TestSessionsControllerConcernSetup < ActionDispatch::IntegrationTest
87 | include WebAuthnTestHelpers
88 |
89 | class TestSessionController < ActionController::Base
90 | include Devise::Passkeys::Controllers::SessionsControllerConcern
91 |
92 | def resource_name
93 | "user"
94 | end
95 | end
96 |
97 | setup do
98 | Rails.application.routes.draw do
99 | post "/session/new_challenge" => "devise/passkeys/controllers/test_sessions_controller_concern_setup/test_session#new_challenge"
100 | end
101 | end
102 |
103 | teardown do
104 | Rails.application.reload_routes!
105 | end
106 |
107 | test "#new_challenge: raises RuntimeError if relying_party has not been implemented" do
108 | assert_raises NoMethodError do
109 | post "/session/new_challenge"
110 | end
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/test/rails_app/config/environments/production.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RailsApp::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 thread 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 | # Enable Rack::Cache to put a simple HTTP cache in front of your application
20 | # Add `rack-cache` to your Gemfile before enabling this.
21 | # For large-scale production use, consider using a caching reverse proxy like nginx, varnish or squid.
22 | # config.action_dispatch.rack_cache = true
23 |
24 | # Disable Rails's static asset server (Apache or nginx will already do this).
25 | if Devise::Test.rails5_and_up?
26 | config.public_file_server.enabled = false
27 | elsif Rails.version >= "4.2.0"
28 | config.serve_static_files = false
29 | else
30 | config.serve_static_assets = false
31 | end
32 |
33 | # Compress JavaScripts and CSS.
34 | config.assets.js_compressor = :uglifier
35 | # config.assets.css_compressor = :sass
36 |
37 | # Whether to fallback to assets pipeline if a precompiled asset is missed.
38 | config.assets.compile = false
39 |
40 | # Generate digests for assets URLs.
41 | config.assets.digest = true
42 |
43 | # Version of your assets, change this if you want to expire all your assets.
44 | config.assets.version = '1.0'
45 |
46 | # Specifies the header that your server uses for sending files.
47 | # config.action_dispatch.x_sendfile_header = "X-Sendfile" # for apache
48 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for nginx
49 |
50 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies.
51 | # config.force_ssl = true
52 |
53 | # Set to :debug to see everything in the log.
54 | config.log_level = :info
55 |
56 | # Prepend all log lines with the following tags.
57 | # config.log_tags = [:subdomain, :uuid]
58 |
59 | # Use a different logger for distributed setups.
60 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new)
61 |
62 | # Use a different cache store in production.
63 | # config.cache_store = :mem_cache_store
64 |
65 | # Enable serving of images, stylesheets, and JavaScripts from an asset server.
66 | # config.action_controller.asset_host = "http://assets.example.com"
67 |
68 | # Precompile additional assets.
69 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added.
70 | # config.assets.precompile += %w( search.js )
71 |
72 | # Ignore bad email addresses and do not raise email delivery errors.
73 | # Set this to true and configure the email server for immediate delivery to raise delivery errors.
74 | # config.action_mailer.raise_delivery_errors = false
75 |
76 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to
77 | # the I18n.default_locale when a translation can not be found).
78 | config.i18n.fallbacks = true
79 |
80 | # Send deprecation notices to registered listeners.
81 | config.active_support.deprecation = :notify
82 |
83 | # Disable automatic flushing of the log to improve performance.
84 | # config.autoflush_log = false
85 |
86 | # Use default logging formatter so that PID and timestamp are not suppressed.
87 | config.log_formatter = ::Logger::Formatter.new
88 | end
89 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/controllers/concerns/reauthentication.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | module Controllers
6 | module Concerns
7 | # This concern is responsible for storing, retrieving, clearing, consuming,
8 | # and validating the reauthentication token in the session.
9 | #
10 | # A reauthentication token is a one-time random value that is used to
11 | # indicate that the user has successfully been reauthenticated. This can be
12 | # used for scenarios such as:
13 | #
14 | # - Adding a new passkey
15 | # - Deleting a passkey
16 | # - Performing sensitive actions inside your application
17 | #
18 | # You can customize which reauthentication token you're using by changing
19 | # the `passkey_reauthentication_token_key` method after including this concern
20 | #
21 | # @see Devise::Passkeys::Controllers::ReauthenticationControllerConcern
22 | module Reauthentication
23 | extend ActiveSupport::Concern
24 |
25 | # This method is responsible for storing the reauthentication token
26 | # in the session.
27 | #
28 | # The reauthentication token is securely generated using `Devise.friendly_token`
29 | #
30 | # @return [String] The reauthentication token
31 | # @see passkey_reauthentication_token_key
32 | def store_reauthentication_token_in_session
33 | session[passkey_reauthentication_token_key] = Devise.friendly_token(50)
34 | end
35 |
36 | # This method is responsible for retrieving the reauthentication token
37 | # from the session.
38 | #
39 | # @return [String] The reauthentication token
40 | # @see passkey_reauthentication_token_key
41 | # @see store_reauthentication_token_in_session
42 | def stored_reauthentication_token
43 | session[passkey_reauthentication_token_key]
44 | end
45 |
46 | # This method is responsible for clearing the reauthentication token from
47 | # the session.
48 | #
49 | # @return [String] The reauthentication token
50 | # @see passkey_reauthentication_token_key
51 | def clear_reauthentication_token!
52 | session.delete(passkey_reauthentication_token_key)
53 | end
54 |
55 | # This method is responsible for consuming (i.e. retrieving & clearing)
56 | # the reauthentication token from the session.
57 | #
58 | # @return [String] The reauthentication token
59 | # @see stored_reauthentication_token
60 | # @see clear_reauthentication_token!
61 | def consume_reauthentication_token!
62 | value = stored_reauthentication_token
63 | clear_reauthentication_token!
64 | value
65 | end
66 |
67 | # This method is responsible for validating the given reauthentication token
68 | # against the one currently in the session.
69 | #
70 | # **Note**: Whenever a reauthentication token is checked using `valid_reauthentication_token?`,
71 | # It will be consumed. This means that a new token will need to be generated & stored
72 | # (by reauthenticating the user) if there were any issues.
73 | #
74 | # @param [String] given_reauthentication_token token to compare store token against
75 | # @return [Boolean] whether the `given_reauthentication_token` is the same as the
76 | # `stored_reauthentication_token`
77 | # @see consume_reauthentication_token!
78 | def valid_reauthentication_token?(given_reauthentication_token:)
79 | Devise.secure_compare(consume_reauthentication_token!, given_reauthentication_token)
80 | end
81 |
82 | # This method is responsible for generating the key that will be used
83 | # to store the reauthentication token in the session hash.
84 | #
85 | # @return [String] The key that will be used to access the reauthentication token in the session
86 | def passkey_reauthentication_token_key
87 | "#{resource_name}_current_reauthentication_token"
88 | end
89 | end
90 | end
91 | end
92 | end
93 | end
94 |
--------------------------------------------------------------------------------
/test/rails_app/config/routes.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Rails.application.routes.draw do
4 | # Resources for testing
5 | resources :users, only: [:index] do
6 | member do
7 | get :expire
8 | get :accept
9 | get :edit_form
10 | put :update_form
11 | end
12 |
13 | authenticate do
14 | post :exhibit, on: :member
15 | end
16 | end
17 |
18 | resources :admins, only: [:index]
19 |
20 | resources :streaming, only: [:index]
21 |
22 | # Users scope
23 | devise_for :users
24 |
25 | devise_for :user_on_main_apps,
26 | class_name: 'UserOnMainApp',
27 | router_name: :main_app,
28 | module: :devise
29 |
30 | devise_for :user_on_engines,
31 | class_name: 'UserOnEngine',
32 | router_name: :fake_engine,
33 | module: :devise
34 |
35 | devise_for :user_without_email,
36 | class_name: 'UserWithoutEmail',
37 | router_name: :main_app,
38 | module: :devise
39 |
40 | as :user do
41 | get "/as/sign_in", to: "devise/sessions#new"
42 | end
43 |
44 | get "/sign_in", to: "devise/sessions#new"
45 |
46 | # Routes for custom controller testing
47 | devise_for :user, only: [:registrations], controllers: { registrations: "custom/registrations" }, as: :custom, path: :custom
48 |
49 | # Admin scope
50 | devise_for :admin, path: "admin_area", controllers: { sessions: :"admins/sessions" }, skip: :passwords
51 |
52 | get "/admin_area/home", to: "admins#index", as: :admin_root
53 | get "/anywhere", to: "foo#bar", as: :new_admin_password
54 |
55 | authenticate(:admin) do
56 | get "/private", to: "home#private", as: :private
57 | end
58 |
59 | authenticate(:admin, lambda { |admin| admin.active? }) do
60 | get "/private/active", to: "home#private", as: :private_active
61 | end
62 |
63 | authenticated :admin do
64 | get "/dashboard", to: "home#admin_dashboard"
65 | end
66 |
67 | authenticated :admin, lambda { |admin| admin.active? } do
68 | get "/dashboard/active", to: "home#admin_dashboard"
69 | end
70 |
71 | authenticated do
72 | get "/dashboard", to: "home#user_dashboard"
73 | end
74 |
75 | unauthenticated do
76 | get "/join", to: "home#join"
77 | end
78 |
79 | # Routes for constraints testing
80 | devise_for :headquarters_admin, class_name: "Admin", path: "headquarters", constraints: {host: /192\.168\.1\.\d\d\d/}
81 |
82 | constraints(host: /192\.168\.1\.\d\d\d/) do
83 | devise_for :homebase_admin, class_name: "Admin", path: "homebase"
84 | end
85 |
86 | scope(subdomain: 'sub') do
87 | devise_for :subdomain_users, class_name: "User", only: [:sessions]
88 | end
89 |
90 | devise_for :skip_admin, class_name: "Admin", skip: :all
91 |
92 | # Routes for format=false testing
93 | devise_for :htmlonly_admin, class_name: "Admin", skip: [:confirmations, :unlocks], path: "htmlonly_admin", format: false, skip_helpers: [:confirmations, :unlocks]
94 | devise_for :htmlonly_users, class_name: "User", only: [:confirmations, :unlocks], path: "htmlonly_users", format: false, skip_helpers: true
95 |
96 | # Other routes for routing_test.rb
97 | devise_for :reader, class_name: "User", only: :passwords
98 |
99 | scope host: "sub.example.com" do
100 | devise_for :sub_admin, class_name: "Admin"
101 | end
102 |
103 | namespace :publisher, path_names: { sign_in: "i_dont_care", sign_out: "get_out" } do
104 | devise_for :accounts, class_name: "Admin", path_names: { sign_in: "get_in" }
105 | end
106 |
107 | scope ":locale", module: :invalid do
108 | devise_for :accounts, singular: "manager", class_name: "Admin",
109 | path_names: {
110 | sign_in: "login", sign_out: "logout",
111 | password: "secret", confirmation: "verification",
112 | unlock: "unblock", sign_up: "register",
113 | registration: "management",
114 | cancel: "giveup", edit: "edit/profile"
115 | }, failure_app: lambda { |env| [404, {"Content-Type" => "text/plain"}, ["Oops, not found"]] }, module: :devise
116 | end
117 |
118 | namespace :sign_out_via, module: "devise" do
119 | devise_for :deletes, sign_out_via: :delete, class_name: "Admin"
120 | devise_for :posts, sign_out_via: :post, class_name: "Admin"
121 | devise_for :gets, sign_out_via: :get, class_name: "Admin"
122 | devise_for :delete_or_posts, sign_out_via: [:delete, :post], class_name: "Admin"
123 | end
124 |
125 | get "/set", to: "home#set"
126 | get "/unauthenticated", to: "home#unauthenticated"
127 | get "/custom_strategy/new"
128 |
129 | root to: "home#index", via: [:get, :post]
130 | end
131 |
--------------------------------------------------------------------------------
/test/devise/passkeys/controllers/concerns/test_reauthentication.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 |
5 | class Devise::Passkeys::Controllers::Concerns::TestReauthentication < ActiveSupport::TestCase
6 | class TestClass
7 | include Devise::Passkeys::Controllers::Concerns::Reauthentication
8 |
9 | attr_accessor :session
10 |
11 | def initialize
12 | self.session = {}
13 | end
14 |
15 | def resource_name
16 | "test_value:1234"
17 | end
18 | end
19 |
20 | setup do
21 | @test_class = TestClass.new
22 | end
23 |
24 | test "#store_reauthentication_token_in_session" do
25 | assert_nil @test_class.session["test_value:1234_current_reauthentication_token"]
26 |
27 | token = @test_class.store_reauthentication_token_in_session
28 | refute_nil token
29 |
30 | assert_equal token, @test_class.session["test_value:1234_current_reauthentication_token"]
31 | end
32 |
33 | test "#stored_reauthentication_token" do
34 | token = "test123123"
35 |
36 | assert_nil @test_class.stored_reauthentication_token
37 | @test_class.session["test_value:1234_current_reauthentication_token"] = token
38 | assert_equal token, @test_class.stored_reauthentication_token
39 | end
40 |
41 | test "#clear_reauthentication_token!" do
42 | token = "test123123"
43 | @test_class.session["test_value:1234_current_reauthentication_token"] = token
44 |
45 | @test_class.clear_reauthentication_token!
46 |
47 | assert_nil @test_class.session["test_value:1234_current_reauthentication_token"]
48 | end
49 |
50 | test "#consume_reauthentication_token!" do
51 | token = "test123123"
52 | @test_class.session["test_value:1234_current_reauthentication_token"] = token
53 |
54 | assert_equal token, @test_class.consume_reauthentication_token!
55 |
56 | assert_nil @test_class.session["test_value:1234_current_reauthentication_token"]
57 | end
58 |
59 | test "#valid_reauthentication_token?: consumes token on comparison" do
60 | token = "test123123"
61 | @test_class.session["test_value:1234_current_reauthentication_token"] = token
62 |
63 | assert_equal true, @test_class.valid_reauthentication_token?(given_reauthentication_token: token)
64 | assert_nil @test_class.session["test_value:1234_current_reauthentication_token"]
65 |
66 | token = "oeuifjhweoirjweoirj"
67 | @test_class.session["test_value:1234_current_reauthentication_token"] = token
68 | assert_equal false, @test_class.valid_reauthentication_token?(given_reauthentication_token: "blah")
69 | assert_nil @test_class.session["test_value:1234_current_reauthentication_token"]
70 | end
71 |
72 | test "#passkey_reauthentication_token_key" do
73 | assert_equal "test_value:1234_current_reauthentication_token", @test_class.passkey_reauthentication_token_key
74 | end
75 | end
76 |
77 | class Devise::Passkeys::Controllers::Concerns::TestReauthenticationCustomization < ActiveSupport::TestCase
78 | class TestClass
79 | include Devise::Passkeys::Controllers::Concerns::Reauthentication
80 |
81 | attr_accessor :session
82 |
83 | def initialize
84 | self.session = {}
85 | end
86 |
87 | def passkey_reauthentication_token_key
88 | "passkey_reauth"
89 | end
90 | end
91 |
92 | setup do
93 | @test_class = TestClass.new
94 | end
95 |
96 | test "#store_reauthentication_token_in_session" do
97 | assert_nil @test_class.session["passkey_reauth"]
98 |
99 | token = @test_class.store_reauthentication_token_in_session
100 | refute_nil token
101 |
102 | assert_equal token, @test_class.session["passkey_reauth"]
103 | end
104 |
105 | test "#stored_reauthentication_token" do
106 | token = "test123123"
107 |
108 | assert_nil @test_class.stored_reauthentication_token
109 | @test_class.session["passkey_reauth"] = token
110 | assert_equal token, @test_class.stored_reauthentication_token
111 | end
112 |
113 | test "#clear_reauthentication_token!" do
114 | token = "test123123"
115 | @test_class.session["passkey_reauth"] = token
116 |
117 | @test_class.clear_reauthentication_token!
118 |
119 | assert_nil @test_class.session["passkey_reauth"]
120 | end
121 |
122 | test "#consume_reauthentication_token!" do
123 | token = "test123123"
124 | @test_class.session["passkey_reauth"] = token
125 |
126 | assert_equal token, @test_class.consume_reauthentication_token!
127 |
128 | assert_nil @test_class.session["passkey_reauth"]
129 | end
130 |
131 | test "#valid_reauthentication_token?: consumes token on comparison" do
132 | token = "test123123"
133 | @test_class.session["passkey_reauth"] = token
134 |
135 | assert_equal true, @test_class.valid_reauthentication_token?(given_reauthentication_token: token)
136 | assert_nil @test_class.session["passkey_reauth"]
137 |
138 | token = "oeuifjhweoirjweoirj"
139 | @test_class.session["passkey_reauth"] = token
140 | assert_equal false, @test_class.valid_reauthentication_token?(given_reauthentication_token: "blah")
141 | assert_nil @test_class.session["passkey_reauth"]
142 | end
143 |
144 | test "#passkey_reauthentication_token_key" do
145 | assert_equal "passkey_reauth", @test_class.passkey_reauthentication_token_key
146 | end
147 | end
148 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
6 |
7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
8 |
9 | ## Our Standards
10 |
11 | Examples of behavior that contributes to a positive environment for our community include:
12 |
13 | * Demonstrating empathy and kindness toward other people
14 | * Being respectful of differing opinions, viewpoints, and experiences
15 | * Giving and gracefully accepting constructive feedback
16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
17 | * Focusing on what is best not just for us as individuals, but for the overall community
18 |
19 | Examples of unacceptable behavior include:
20 |
21 | * The use of sexualized language or imagery, and sexual attention or
22 | advances of any kind
23 | * Trolling, insulting or derogatory comments, and personal or political attacks
24 | * Public or private harassment
25 | * Publishing others' private information, such as a physical or email
26 | address, without their explicit permission
27 | * Other conduct which could reasonably be considered inappropriate in a
28 | professional setting
29 |
30 | ## Enforcement Responsibilities
31 |
32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
33 |
34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate.
35 |
36 | ## Scope
37 |
38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
39 |
40 | ## Enforcement
41 |
42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at tcannon00@gmail.com. All complaints will be reviewed and investigated promptly and fairly.
43 |
44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident.
45 |
46 | ## Enforcement Guidelines
47 |
48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct:
49 |
50 | ### 1. Correction
51 |
52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community.
53 |
54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested.
55 |
56 | ### 2. Warning
57 |
58 | **Community Impact**: A violation through a single incident or series of actions.
59 |
60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban.
61 |
62 | ### 3. Temporary Ban
63 |
64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior.
65 |
66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban.
67 |
68 | ### 4. Permanent Ban
69 |
70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals.
71 |
72 | **Consequence**: A permanent ban from any sort of public interaction within the community.
73 |
74 | ## Attribution
75 |
76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0,
77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
78 |
79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity).
80 |
81 | [homepage]: https://www.contributor-covenant.org
82 |
83 | For answers to common questions about this code of conduct, see the FAQ at
84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations.
85 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/controllers/passkeys_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | module Controllers
6 | module PasskeysControllerConcern
7 | extend ActiveSupport::Concern
8 |
9 | included do
10 | include Devise::Passkeys::Controllers::Concerns::Reauthentication
11 | include Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge
12 | include Warden::WebAuthn::AuthenticationInitiationHelpers
13 | include Warden::WebAuthn::RegistrationHelpers
14 |
15 | prepend_before_action :authenticate_scope!
16 | before_action :ensure_at_least_one_passkey, only: %i[new_destroy_challenge destroy]
17 | before_action :find_passkey, only: %i[new_destroy_challenge destroy]
18 |
19 | before_action :verify_credential_integrity, only: [:create]
20 | before_action :verify_passkey_challenge, only: [:create]
21 | before_action :verify_reauthentication_token, only: %i[create destroy]
22 |
23 | # Authenticates the current scope and gets the current resource from the session.
24 | def authenticate_scope!
25 | send(:"authenticate_#{resource_name}!", force: true)
26 | self.resource = send(:"current_#{resource_name}")
27 | end
28 |
29 | def registration_challenge_key
30 | "#{resource_name}_passkey_creation_challenge"
31 | end
32 |
33 | def errors
34 | warden.errors
35 | end
36 |
37 | def raw_credential
38 | passkey_params[:credential]
39 | end
40 | end
41 |
42 | def new_create_challenge
43 | options_for_registration = generate_registration_options(
44 | relying_party: relying_party,
45 | user_details: user_details_for_registration,
46 | exclude: exclude_external_ids_for_registration
47 | )
48 |
49 | store_challenge_in_session(options_for_registration: options_for_registration)
50 |
51 | render json: options_for_registration
52 | end
53 |
54 | def create
55 | create_passkey(resource: resource)
56 | end
57 |
58 | def new_destroy_challenge
59 | allowed_passkeys = (resource.passkeys - [@passkey])
60 |
61 | options_for_authentication = generate_authentication_options(relying_party: relying_party,
62 | options: { allow: allowed_passkeys.pluck(:external_id) })
63 |
64 | store_reauthentication_challenge_in_session(options_for_authentication: options_for_authentication)
65 |
66 | render json: options_for_authentication
67 | end
68 |
69 | def destroy
70 | @passkey.destroy
71 | redirect_to root_path
72 | end
73 |
74 | protected
75 |
76 | def create_passkey(resource:)
77 | passkey = resource.passkeys.create!(
78 | label: passkey_params[:label],
79 | public_key: @webauthn_credential.public_key,
80 | external_id: Base64.strict_encode64(@webauthn_credential.raw_id),
81 | sign_count: @webauthn_credential.sign_count,
82 | last_used_at: nil
83 | )
84 | yield [resource, passkey] if block_given?
85 | redirect_to root_path
86 | end
87 |
88 | def exclude_external_ids_for_registration
89 | resource.passkeys.pluck(:external_id)
90 | end
91 |
92 | def user_details_for_registration
93 | { id: resource.webauthn_id, name: resource.email }
94 | end
95 |
96 | def verify_credential_integrity
97 | render_credential_missing_or_could_not_be_parsed_error if parsed_credential.nil?
98 | rescue JSON::JSONError, TypeError
99 | render_credential_missing_or_could_not_be_parsed_error
100 | end
101 |
102 | def verify_passkey_challenge
103 | @webauthn_credential = verify_registration(relying_party: relying_party)
104 | rescue ::WebAuthn::Error => e
105 | error_key = Warden::WebAuthn::ErrorKeyFinder.webauthn_error_key(exception: e)
106 | render json: { message: find_message(error_key) }, status: :bad_request
107 | end
108 |
109 | def passkey_params
110 | params.require(:passkey).permit(:label, :credential)
111 | end
112 |
113 | def ensure_at_least_one_passkey
114 | return unless current_user.passkeys.count <= 1
115 |
116 | render json: { error: find_message(:must_be_at_least_one_passkey) }, status: :bad_request
117 | end
118 |
119 | def find_passkey
120 | @passkey = resource.passkeys.where(id: params[:id]).first
121 | return unless @passkey.nil?
122 |
123 | head :not_found
124 | nil
125 | end
126 |
127 | def verify_reauthentication_token
128 | return if valid_reauthentication_token?(given_reauthentication_token: reauthentication_params[:reauthentication_token])
129 |
130 | render json: { error: find_message(:not_reauthenticated) }, status: :bad_request
131 | end
132 |
133 | def reauthentication_params
134 | params.require(:passkey).permit(:reauthentication_token)
135 | end
136 |
137 | def render_credential_missing_or_could_not_be_parsed_error
138 | render json: { message: find_message(:credential_missing_or_could_not_be_parsed) }, status: :bad_request
139 | delete_registration_challenge
140 |
141 | false
142 | end
143 | end
144 | end
145 | end
146 | end
147 |
--------------------------------------------------------------------------------
/Gemfile.lock:
--------------------------------------------------------------------------------
1 | PATH
2 | remote: .
3 | specs:
4 | devise-passkeys (0.3.0)
5 | devise (>= 4.7.1)
6 | warden-webauthn (>= 0.3.0)
7 |
8 | GEM
9 | remote: https://rubygems.org/
10 | specs:
11 | actionpack (7.0.6)
12 | actionview (= 7.0.6)
13 | activesupport (= 7.0.6)
14 | rack (~> 2.0, >= 2.2.4)
15 | rack-test (>= 0.6.3)
16 | rails-dom-testing (~> 2.0)
17 | rails-html-sanitizer (~> 1.0, >= 1.2.0)
18 | actionview (7.0.6)
19 | activesupport (= 7.0.6)
20 | builder (~> 3.1)
21 | erubi (~> 1.4)
22 | rails-dom-testing (~> 2.0)
23 | rails-html-sanitizer (~> 1.1, >= 1.2.0)
24 | activemodel (7.0.6)
25 | activesupport (= 7.0.6)
26 | activerecord (7.0.6)
27 | activemodel (= 7.0.6)
28 | activesupport (= 7.0.6)
29 | activesupport (7.0.6)
30 | concurrent-ruby (~> 1.0, >= 1.0.2)
31 | i18n (>= 1.6, < 2)
32 | minitest (>= 5.1)
33 | tzinfo (~> 2.0)
34 | android_key_attestation (0.3.0)
35 | appraisal (2.4.1)
36 | bundler
37 | rake
38 | thor (>= 0.14.0)
39 | ast (2.4.2)
40 | awrence (1.2.1)
41 | bcrypt (3.1.19)
42 | bindata (2.4.15)
43 | bson (4.15.0)
44 | builder (3.2.4)
45 | cbor (0.5.9.6)
46 | concurrent-ruby (1.2.2)
47 | cose (1.3.0)
48 | cbor (~> 0.5.9)
49 | openssl-signature_algorithm (~> 1.0)
50 | crass (1.0.6)
51 | database_cleaner-active_record (2.1.0)
52 | activerecord (>= 5.a)
53 | database_cleaner-core (~> 2.0.0)
54 | database_cleaner-core (2.0.1)
55 | database_cleaner-mongoid (2.0.1)
56 | database_cleaner-core (~> 2.0.0)
57 | mongoid
58 | debug (1.8.0)
59 | irb (>= 1.5.0)
60 | reline (>= 0.3.1)
61 | devise (4.9.2)
62 | bcrypt (~> 3.0)
63 | orm_adapter (~> 0.1)
64 | railties (>= 4.1.0)
65 | responders
66 | warden (~> 1.2.3)
67 | docile (1.4.0)
68 | erubi (1.12.0)
69 | i18n (1.14.1)
70 | concurrent-ruby (~> 1.0)
71 | io-console (0.6.0)
72 | irb (1.7.1)
73 | reline (>= 0.3.0)
74 | json (2.6.3)
75 | jwt (2.7.1)
76 | language_server-protocol (3.17.0.3)
77 | loofah (2.21.3)
78 | crass (~> 1.0.2)
79 | nokogiri (>= 1.12.0)
80 | m (1.6.1)
81 | method_source (>= 0.6.7)
82 | rake (>= 0.9.2.2)
83 | method_source (1.0.0)
84 | minitest (5.18.1)
85 | minitest-ci (3.4.0)
86 | minitest (>= 5.0.6)
87 | mongo (2.19.0)
88 | bson (>= 4.14.1, < 5.0.0)
89 | mongoid (8.1.0)
90 | activemodel (>= 5.1, < 7.1, != 7.0.0)
91 | concurrent-ruby (>= 1.0.5, < 2.0)
92 | mongo (>= 2.18.0, < 3.0.0)
93 | ruby2_keywords (~> 0.0.5)
94 | nokogiri (1.15.3-arm64-darwin)
95 | racc (~> 1.4)
96 | nokogiri (1.15.3-x86_64-linux)
97 | racc (~> 1.4)
98 | openssl (3.1.0)
99 | openssl-signature_algorithm (1.3.0)
100 | openssl (> 2.0)
101 | orm_adapter (0.5.0)
102 | parallel (1.23.0)
103 | parser (3.2.2.3)
104 | ast (~> 2.4.1)
105 | racc
106 | racc (1.7.1)
107 | rack (2.2.8)
108 | rack-test (2.1.0)
109 | rack (>= 1.3)
110 | rails-dom-testing (2.1.1)
111 | activesupport (>= 5.0.0)
112 | minitest
113 | nokogiri (>= 1.6)
114 | rails-html-sanitizer (1.6.0)
115 | loofah (~> 2.21)
116 | nokogiri (~> 1.14)
117 | railties (7.0.6)
118 | actionpack (= 7.0.6)
119 | activesupport (= 7.0.6)
120 | method_source
121 | rake (>= 12.2)
122 | thor (~> 1.0)
123 | zeitwerk (~> 2.5)
124 | rainbow (3.1.1)
125 | rake (13.0.6)
126 | regexp_parser (2.8.1)
127 | reline (0.3.5)
128 | io-console (~> 0.5)
129 | responders (3.1.0)
130 | actionpack (>= 5.2)
131 | railties (>= 5.2)
132 | rexml (3.2.5)
133 | rubocop (1.54.1)
134 | json (~> 2.3)
135 | language_server-protocol (>= 3.17.0)
136 | parallel (~> 1.10)
137 | parser (>= 3.2.2.3)
138 | rainbow (>= 2.2.2, < 4.0)
139 | regexp_parser (>= 1.8, < 3.0)
140 | rexml (>= 3.2.5, < 4.0)
141 | rubocop-ast (>= 1.28.0, < 2.0)
142 | ruby-progressbar (~> 1.7)
143 | unicode-display_width (>= 2.4.0, < 3.0)
144 | rubocop-ast (1.29.0)
145 | parser (>= 3.2.1.0)
146 | ruby-progressbar (1.13.0)
147 | ruby2_keywords (0.0.5)
148 | safety_net_attestation (0.4.0)
149 | jwt (~> 2.0)
150 | simplecov (0.22.0)
151 | docile (~> 1.1)
152 | simplecov-html (~> 0.11)
153 | simplecov_json_formatter (~> 0.1)
154 | simplecov-html (0.12.3)
155 | simplecov_json_formatter (0.1.4)
156 | thor (1.2.2)
157 | tpm-key_attestation (0.12.0)
158 | bindata (~> 2.4)
159 | openssl (> 2.0)
160 | openssl-signature_algorithm (~> 1.0)
161 | tzinfo (2.0.6)
162 | concurrent-ruby (~> 1.0)
163 | unicode-display_width (2.4.2)
164 | warden (1.2.9)
165 | rack (>= 2.0.9)
166 | warden-webauthn (0.3.0)
167 | warden
168 | webauthn (>= 3)
169 | webauthn (3.0.0)
170 | android_key_attestation (~> 0.3.0)
171 | awrence (~> 1.1)
172 | bindata (~> 2.4)
173 | cbor (~> 0.5.9)
174 | cose (~> 1.1)
175 | openssl (>= 2.2)
176 | safety_net_attestation (~> 0.4.0)
177 | tpm-key_attestation (~> 0.12.0)
178 | webrick (1.8.1)
179 | yard (0.9.34)
180 | zeitwerk (2.6.8)
181 |
182 | PLATFORMS
183 | arm64-darwin-21
184 | x86_64-linux
185 |
186 | DEPENDENCIES
187 | appraisal
188 | database_cleaner-active_record
189 | database_cleaner-mongoid
190 | debug
191 | devise-passkeys!
192 | m
193 | minitest (~> 5.0)
194 | minitest-ci
195 | rack
196 | rake (~> 13.0)
197 | rubocop (~> 1.21)
198 | simplecov
199 | webrick
200 | yard
201 |
202 | BUNDLED WITH
203 | 2.4.12
204 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/controllers/reauthentication_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | module Controllers
6 | # This concern is responsible for handling reauthentication.
7 | # It should be included in any controller that handles reauthentication, and defines:
8 | #
9 | # - Useful methods to assist with the reauthentication process
10 | # - Concerns that are required to complete the reauthentication process
11 | # - Helper modules from `Warden::WebAuthn` that are required to complete the reauthentication process
12 | #
13 | # **Note**: the implementing controller **must** define a `relying_party` method in order for
14 | # reauthentications to work.
15 | #
16 | # @example
17 | # class ReauthenticationController < ApplicationController
18 | # include Devise::Passkeys::Controllers::ReauthenticationControllerConcern
19 | #
20 | # def relying_party
21 | # WebAuthn::RelyingParty.new
22 | # end
23 | # end
24 | #
25 | # The `authenticate_scope!` is called as a `before_action` to verify the authentication and set the
26 | # `resource` for the controller.
27 | #
28 | # Likewise, `Warden::WebAuthn::RackHelpers#set_relying_party_in_request_env` is a `before_action` to ensure that the relying party is set in the
29 | # `request.env` before the Warden strategy is executed
30 | #
31 | # @see relying_party
32 | # @see Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge
33 | # @see Devise::Passkeys::Controllers::Concerns::Reauthentication
34 | # @see Warden::WebAuthn::StrategyHelpers
35 | # @see Warden::WebAuthn::RackHelpers
36 | module ReauthenticationControllerConcern
37 | extend ActiveSupport::Concern
38 |
39 | included do
40 | include Devise::Passkeys::Controllers::Concerns::Reauthentication
41 | include Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge
42 | include Warden::WebAuthn::AuthenticationInitiationHelpers
43 | include Warden::WebAuthn::RackHelpers
44 |
45 | prepend_before_action :authenticate_scope!
46 |
47 | before_action :prepare_params, only: [:reauthenticate]
48 |
49 | # Prepending is crucial to ensure that the relying party is set in the
50 | # request.env before the strategy is executed
51 | prepend_before_action :set_relying_party_in_request_env
52 |
53 | # Authenticates the current scope and gets the current resource from the session.
54 | def authenticate_scope!
55 | send(:"authenticate_#{resource_name}!", force: true)
56 | self.resource = send(:"current_#{resource_name}")
57 | end
58 | end
59 |
60 | # A controller action that stores the reauthentication challenge in session
61 | # and renders the options for authentication from `webauthn-ruby`.
62 | #
63 | # The response is rendered as JSON, with a status of `200 OK`.
64 | #
65 | # @see Devise::Passkeys::Controllers::Concerns::ReauthenticationChallenge#store_reauthentication_challenge_in_session
66 | # @see Warden::WebAuthn::AuthenticationInitiationHelpers#generate_authentication_options
67 | # @see Warden::WebAuthn::RackHelpers#set_relying_party_in_request_env
68 | def new_challenge
69 | options_for_authentication = generate_authentication_options(relying_party: relying_party,
70 | options: { allow: resource.passkeys.pluck(:external_id) })
71 |
72 | store_reauthentication_challenge_in_session(options_for_authentication: options_for_authentication)
73 |
74 | render json: options_for_authentication
75 | end
76 |
77 | # A controller action that:
78 | #
79 | # 1. Uses the `warden` strategy to authenticate the current user with the defined strategy
80 | # 2. Calls `sign_in` with `event: :passkey_reauthentication` to verify that the user can authenticate
81 | # 3. Stores the reauthentication token in the session
82 | # 4. Renders a JSON object with the reauthentication token
83 | # 5. Ensures that the reauthentication challenge from the session, regardless of any errors
84 | #
85 | # @example
86 | # {"reauthentication_token": "abcd1234"}
87 | #
88 | # `prepare_params` is called as a `before_action` to prepare the passkey credential for use by the
89 | # Warden strategy.
90 | #
91 | # Optionally accepts a block that will be executed after the user has been reauthenticated.
92 | # @see strategy
93 | # @see Devise::Passkeys::Controllers::Concerns::Reauthentication#store_reauthentication_token_in_session
94 | # @see prepare_params
95 | def reauthenticate
96 | sign_out(resource)
97 | self.resource = warden.authenticate!(strategy, auth_options)
98 | sign_in(resource, event: :passkey_reauthentication)
99 | yield resource if block_given?
100 |
101 | store_reauthentication_token_in_session
102 |
103 | render json: { reauthentication_token: stored_reauthentication_token }
104 | ensure
105 | delete_reauthentication_challenge
106 | end
107 |
108 | protected
109 |
110 | # @!visibility public
111 | # Prepares the request parameters for use by the Warden strategy
112 | def prepare_params
113 | request.params[resource_name] = ActionController::Parameters.new({
114 | passkey_credential: params[:passkey_credential]
115 | })
116 | end
117 |
118 | # @!visibility public
119 | # A method that can be overridden to customize the Warden strategy used.
120 | # @return [Symbol] The key that identifies which `Warden` strategy will be used to handle the
121 | # authentication flow for the reauthentication. Defaults to `:passkey_reauthentication`
122 | def strategy
123 | :passkey_reauthentication
124 | end
125 |
126 | def auth_options
127 | { scope: resource_name, recall: root_path }
128 | end
129 |
130 | def delete_reauthentication_challenge
131 | session.delete(passkey_reauthentication_challenge_session_key)
132 | end
133 |
134 | # @!visibility public
135 | # @abstract
136 | # The method that returns the `WebAuthn::RelyingParty` for this request.
137 | # @return [WebAuthn::RelyingParty] when overridden, this method should return a `WebAuthn::RelyingParty` instance
138 | def relying_party
139 | raise NoMethodError, "need to define relying_party for this #{self.class.name}"
140 | end
141 | end
142 | end
143 | end
144 | end
145 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Devise::Passkeys
2 |
3 | This Devise extension allows you to use passkeys instead of passwords for user authentication.
4 |
5 | `Devise::Passkeys` is lightweight and non-configurable. It does what it has to do and leaves some manual implementation to you.
6 |
7 |
8 | # Installation
9 |
10 | Add this line to your application's Gemfile:
11 | ```ruby
12 | gem 'devise-passkeys'
13 | ```
14 | And then execute:
15 |
16 | ```sh
17 | $ bundle
18 | ```
19 |
20 | # Usage
21 |
22 | ## Add `:passkey_authenticatable`
23 |
24 | ```ruby
25 | class User < ApplicationRecord
26 | devise :passkey_authenticatable, ...
27 |
28 | has_many :passkeys
29 |
30 | def self.passkeys_class
31 | Passkey
32 | end
33 |
34 | def self.find_for_passkey(passkey)
35 | self.find_by(id: passkey.user.id)
36 | end
37 |
38 | def after_passkey_authentication(passkey:)
39 | end
40 | end
41 | ```
42 |
43 | The Devise-enabled model must have a `webauthn_id` field in the model; which is:
44 |
45 | - A string
46 | - Has a unique index
47 |
48 | This will allow you to explictly establish the relationship between a user & its passkeys (to help both your app & the user's authenticator with credential management)
49 |
50 | ## Generate the Model That Will Store Passkeys. Should Have:
51 | - A `has_many :passkeys` association
52 | - A `passkey_class` class method that returns the passkey class
53 | - A `find_for_passkey(passkey)` class method that finds the user for a given passkey
54 |
55 | ```sh
56 | rails g model Passkey user:references label:string external_id:string:index:uniq public_key:string:index sign_count:integer last_used_at:datetime
57 | ```
58 |
59 | The following fields are required:
60 |
61 | - `label:string` (required, cannot be blank you'll want to scope it to the Devise-enabled model)
62 | - `external_id:string`
63 | - `public_key:string`
64 | - `sign_count:integer`
65 | - `last_used_at:datetime`
66 |
67 | It's recommended to add unique indexes on `external_id` and `public_key`
68 |
69 | ## Generate Custom Devise Controllers & Views
70 |
71 | [Since Devise does not have built-in passkeys support yet](https://github.com/heartcombo/devise/issues/5527), you'll need to customize both the controllers & the views
72 |
73 | ```shell
74 | rails generate devise:controllers users
75 | rails generate devise:views users
76 | ```
77 |
78 | If you're trying to keep your codebase small, these instructions only concern the `Users::SessionsController` & `Users::RegistrationsController`, so you can delete any other generated custom controllers if needed. You will likely need to modify the `views/users/shared/*` partials though, because they assume passwords are being used.
79 |
80 | ## Include the Passkeys Concerns in Your Controllers
81 |
82 | Rather than having base classes, `Devise::Passkeys` has a series of concerns that can be mixed into your controllers. This allows you to change behavior, and does not keep you stuck down a path that could be incompatible with your existing authentication setup.
83 |
84 | Here are examples of common controllers
85 |
86 | ```ruby
87 | class Users::RegistrationsController < Devise::RegistrationsController
88 | include Devise::Passkeys::Controllers::RegistrationsControllerConcern
89 | end
90 |
91 |
92 | class Users::SessionsController < Devise::SessionsController
93 | include Devise::Passkeys::Controllers::SessionsControllerConcern
94 | # ... any custom code you need
95 |
96 | def relying_party
97 | WebAuthn::RelyingParty.new(...)
98 | end
99 | end
100 |
101 | # frozen_string_literal: true
102 |
103 | class Users::ReauthenticationController < DeviseController
104 | include Devise::Passkeys::Controllers::ReauthenticationControllerConcern
105 | # ... any custom code you need
106 |
107 | def relying_party
108 | WebAuthn::RelyingParty.new(...)
109 | end
110 | end
111 |
112 | # frozen_string_literal: true
113 |
114 | class Users::PasskeysController < DeviseController
115 | include Devise::Passkeys::Controllers::PasskeysControllerConcern
116 | # ... any custom code you need
117 |
118 | def relying_party
119 | WebAuthn::RelyingParty.new(...)
120 | end
121 | end
122 |
123 | ```
124 |
125 | ## Add Routes
126 |
127 | Given the customization routes usually require, you'll need to hook up the routes yourself. Here's an example:
128 |
129 | ```ruby
130 | devise_for :users, controllers: {
131 | registrations: 'users/registrations',
132 | sessions: 'users/sessions'
133 | }
134 |
135 | devise_scope :user do
136 | post 'sign_up/new_challenge', to: 'users/registrations#new_challenge', as: :new_user_registration_challenge
137 | post 'sign_in/new_challenge', to: 'users/sessions#new_challenge', as: :new_user_session_challenge
138 |
139 | post 'reauthenticate/new_challenge', to: 'users/reauthentication#new_challenge', as: :new_user_reauthentication_challenge
140 | post 'reauthenticate', to: 'users/reauthentication#reauthenticate', as: :user_reauthentication
141 |
142 | namespace :users do
143 | resources :passkeys, only: [:index, :create, :destroy] do
144 | collection do
145 | post :new_create_challenge
146 | end
147 |
148 | member do
149 | post :new_destroy_challenge
150 | end
151 | end
152 | end
153 | end
154 | ```
155 |
156 | ## Reimplement the `:passkey_authenticatable` Module
157 |
158 | **Important**: You will need to reimplement the `:passkey_authenticatable` Devise module. This will override the module definition with your implementation specific definitions; pointing to the specific route, controller, etc.
159 |
160 | Here's an example from [devise-passkeys-template](https://github.com/ruby-passkeys/devise-passkeys-template/blob/main/app/models/user.rb#L18):
161 |
162 | ```ruby
163 | Devise.add_module :passkey_authenticatable,
164 | model: 'devise/passkeys/model',
165 | route: {session: [nil, :new, :create, :destroy] },
166 | controller: 'controller/sessions',
167 | strategy: true
168 | ```
169 |
170 | # FAQs
171 |
172 | ## What about the Webauthn javascript? Mailers? Error handling?
173 |
174 | You will have to implement these, since `Devise::Passkeys` is focused on the authentication handshakes, and each app is different (with different javascript setups, mailer needs, etc.)
175 |
176 | ## I need to see it in action
177 |
178 | Here's a template repo! https://github.com/ruby-passkeys/devise-passkeys-template
179 |
180 | ## Development
181 |
182 | Please see [CONTRIBUTING.md](https://github.com/ruby-passkeys/devise-passkeys/blob/main/CONTRIBUTING.md) for guidance on how to help out!
183 |
184 | ## Contributing
185 |
186 | Bug reports and pull requests are welcome on GitHub at https://github.com/ruby-passkeys/devise-passkeys. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/ruby-passkeys/devise-passkeys/blob/main/CODE_OF_CONDUCT.md).
187 |
188 | ## License
189 |
190 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
191 |
192 | ## Code of Conduct
193 |
194 | Everyone interacting in the Devise::Passkeys project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/ruby-passkeys/devise-passkeys/blob/main/CODE_OF_CONDUCT.md).
195 |
196 |
197 | ## Acknowledgements
198 |
199 | This work is based on [Petr Hlavicka](https://github.com/CiTroNaK)'s [webauthn-with-devise](https://github.com/CiTroNaK/webauthn-with-devise/compare/main...3-passwordless).
200 |
201 | The ethos of the library is inspired from [Tiddle](https://github.com/adamniedzielski/tiddle)'s straightforward, minimally-scoped approach.
202 |
--------------------------------------------------------------------------------
/test/rails_app/config/initializers/devise.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Assuming you have not yet modified this file, each configuration option below
4 | # is set to its default value. Note that some are commented out while others
5 | # are not: uncommented lines are intended to protect your configuration from
6 | # breaking changes in upgrades (i.e., in the event that future versions of
7 | # Devise change the default values for those options).
8 | #
9 | # Use this hook to configure devise mailer, warden hooks and so forth. The first
10 | # four configuration values can also be set straight in your models.
11 | Devise.setup do |config|
12 | config.secret_key = "d9eb5171c59a4c817f68b0de27b8c1e340c2341b52cdbc60d3083d4e8958532" \
13 | "18dcc5f589cafde048faec956b61f864b9b5513ff9ce29bf9e5d58b0f234f8e3b"
14 |
15 | # ==> Mailer Configuration
16 | # Configure the e-mail address which will be shown in Devise::Mailer,
17 | # note that it will be overwritten if you use your own mailer class with default "from" parameter.
18 | config.mailer_sender = "please-change-me@config-initializers-devise.com"
19 |
20 |
21 | config.parent_controller = "ApplicationWithFakeEngine"
22 | # Configure the class responsible to send e-mails.
23 | # config.mailer = "Devise::Mailer"
24 |
25 | # ==> ORM configuration
26 | # Load and configure the ORM. Supports :active_record (default) and
27 | # :mongoid (bson_ext recommended) by default. Other ORMs may be
28 | # available as additional gems.
29 | require "devise/orm/#{DEVISE_ORM}"
30 |
31 | # ==> Configuration for any authentication mechanism
32 | # Configure which keys are used when authenticating a user. By default is
33 | # just :email. You can configure it to use [:username, :subdomain], so for
34 | # authenticating a user, both parameters are required. Remember that those
35 | # parameters are used only when authenticating and not when retrieving from
36 | # session. If you need permissions, you should implement that in a before filter.
37 | # You can also supply hash where the value is a boolean expliciting if authentication
38 | # should be aborted or not if the value is not present. By default is empty.
39 | # config.authentication_keys = [:email]
40 |
41 | # Configure parameters from the request object used for authentication. Each entry
42 | # given should be a request method and it will automatically be passed to
43 | # find_for_authentication method and considered in your model lookup. For instance,
44 | # if you set :request_keys to [:subdomain], :subdomain will be used on authentication.
45 | # The same considerations mentioned for authentication_keys also apply to request_keys.
46 | # config.request_keys = []
47 |
48 | # Configure which authentication keys should be case-insensitive.
49 | # These keys will be downcased upon creating or modifying a user and when used
50 | # to authenticate or find a user. Default is :email.
51 | config.case_insensitive_keys = [:email]
52 |
53 | # Configure which authentication keys should have whitespace stripped.
54 | # These keys will have whitespace before and after removed upon creating or
55 | # modifying a user and when used to authenticate or find a user. Default is :email.
56 | config.strip_whitespace_keys = [:email]
57 |
58 | # Tell if authentication through request.params is enabled. True by default.
59 | # config.params_authenticatable = true
60 |
61 | # Tell if authentication through HTTP Basic Auth is enabled. False by default.
62 | config.http_authenticatable = true
63 |
64 | # If http headers should be returned for AJAX requests. True by default.
65 | # config.http_authenticatable_on_xhr = true
66 |
67 | # The realm used in Http Basic Authentication. "Application" by default.
68 | # config.http_authentication_realm = "Application"
69 |
70 | # ==> Configuration for :database_authenticatable
71 | # For bcrypt, this is the cost for hashing the password and defaults to 10. If
72 | # using other encryptors, it sets how many times you want the password re-encrypted.
73 | config.stretches = Rails.env.test? ? 1 : 10
74 |
75 | # ==> Configuration for :confirmable
76 | # The time you want to give your user to confirm their account. During this time
77 | # they will be able to access your application without confirming. Default is nil.
78 | # When allow_unconfirmed_access_for is zero, the user won't be able to sign in without confirming.
79 | # You can use this to let your user access some features of your application
80 | # without confirming the account, but blocking it after a certain period
81 | # (ie 2 days).
82 | # config.allow_unconfirmed_access_for = 2.days
83 |
84 | # Defines which key will be used when confirming an account
85 | # config.confirmation_keys = [:email]
86 |
87 | # ==> Configuration for :rememberable
88 | # The time the user will be remembered without asking for credentials again.
89 | # config.remember_for = 2.weeks
90 |
91 | # If true, extends the user's remember period when remembered via cookie.
92 | # config.extend_remember_period = false
93 |
94 | # ==> Configuration for :validatable
95 | # Range for password length. Default is 8..72.
96 | # config.password_length = 8..72
97 |
98 | # Regex to use to validate the email address
99 | # config.email_regexp = /^([\w\.%\+\-]+)@([\w\-]+\.)+([\w]{2,})$/i
100 |
101 | # ==> Configuration for :timeoutable
102 | # The time you want to timeout the user session without activity. After this
103 | # time the user will be asked for credentials again. Default is 30 minutes.
104 | # config.timeout_in = 30.minutes
105 |
106 | # ==> Configuration for :lockable
107 | # Defines which strategy will be used to lock an account.
108 | # :failed_attempts = Locks an account after a number of failed attempts to sign in.
109 | # :none = No lock strategy. You should handle locking by yourself.
110 | # config.lock_strategy = :failed_attempts
111 |
112 | # Defines which key will be used when locking and unlocking an account
113 | # config.unlock_keys = [:email]
114 |
115 | # Defines which strategy will be used to unlock an account.
116 | # :email = Sends an unlock link to the user email
117 | # :time = Re-enables login after a certain amount of time (see :unlock_in below)
118 | # :both = Enables both strategies
119 | # :none = No unlock strategy. You should handle unlocking by yourself.
120 | # config.unlock_strategy = :both
121 |
122 | # Number of authentication tries before locking an account if lock_strategy
123 | # is failed attempts.
124 | # config.maximum_attempts = 20
125 |
126 | # Time interval to unlock the account if :time is enabled as unlock_strategy.
127 | # config.unlock_in = 1.hour
128 |
129 | # ==> Configuration for :recoverable
130 | #
131 | # Defines which key will be used when recovering the password for an account
132 | # config.reset_password_keys = [:email]
133 |
134 | # Time interval you can reset your password with a reset password key.
135 | # Don't put a too small interval or your users won't have the time to
136 | # change their passwords.
137 | config.reset_password_within = 2.hours
138 |
139 | # When set to false, does not sign a user in automatically after their password is
140 | # reset. Defaults to true, so a user is signed in automatically after a reset.
141 | # config.sign_in_after_reset_password = true
142 |
143 | # Set up a pepper to generate the encrypted password.
144 | config.pepper = "d142367154e5beacca404b1a6a4f8bc52c6fdcfa3ccc3cf8eb49f3458a688ee6ac3b9fae488432a3bfca863b8a90008368a9f3a3dfbe5a962e64b6ab8f3a3a1a"
145 |
146 | # ==> Scopes configuration
147 | # Turn scoped views on. Before rendering "sessions/new", it will first check for
148 | # "users/sessions/new". It's turned off by default because it's slower if you
149 | # are using only default views.
150 | # config.scoped_views = false
151 |
152 | # Configure the default scope given to Warden. By default it's the first
153 | # devise role declared in your routes (usually :user).
154 | # config.default_scope = :user
155 |
156 | # Configure sign_out behavior.
157 | # Sign_out action can be scoped (i.e. /users/sign_out affects only :user scope).
158 | # The default is true, which means any logout action will sign out all active scopes.
159 | # config.sign_out_all_scopes = true
160 |
161 | # ==> Navigation configuration
162 | # Lists the formats that should be treated as navigational. Formats like
163 | # :html, should redirect to the sign in page when the user does not have
164 | # access, but formats like :xml or :json, should return 401.
165 | # If you have any extra navigational formats, like :iphone or :mobile, you
166 | # should add them to the navigational formats lists. Default is [:html]
167 | # config.navigational_formats = [:html, :iphone]
168 |
169 | # The default HTTP method used to sign out a resource. Default is :get.
170 | # config.sign_out_via = :get
171 |
172 | # ==> OmniAuth
173 | # config.omniauth :facebook, 'APP_ID', 'APP_SECRET', scope: 'email,offline_access'
174 | # config.omniauth :openid
175 | # config.omniauth :openid, name: 'google', identifier: 'https://www.google.com/accounts/o8/id'
176 |
177 | # ==> Warden configuration
178 | # If you want to use other strategies, that are not supported by Devise, or
179 | # change the failure app, you can configure them inside the config.warden block.
180 | #
181 | # config.warden do |manager|
182 | # manager.failure_app = AnotherApp
183 | # manager.default_strategies(scope: :user).unshift :some_external_strategy
184 | # end
185 |
186 | # ==> Configuration for :registerable
187 |
188 | # When set to false, does not sign a user in automatically after their password is
189 | # changed. Defaults to true, so a user is signed in automatically after changing a password.
190 | # config.sign_in_after_change_password = true
191 |
192 | ActiveSupport.on_load(:devise_failure_app) do
193 | require "lazy_load_test_module"
194 | include LazyLoadTestModule
195 | end
196 | end
197 |
--------------------------------------------------------------------------------
/lib/devise/passkeys/controllers/registrations_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module Devise
4 | module Passkeys
5 | module Controllers
6 | # This concern should be included in any controller that handles
7 | # user (`resource`) registration management (ie: signup/deleting an account),
8 | # and defines:
9 | #
10 | # - Useful methods and before filters to streamline user (`resource`) registration management using session variables
11 | # - Controller actions for:
12 | # - Issuing a new WebAuthn challenge
13 | # - A `create` action that creates a passkey if the user (`resource`) has been persisted
14 | # - Helper modules from `Warden::WebAuthn` that are required to complete the registration process
15 | #
16 | # The `registration_user_id_key` and `registration_challenge_key` are defined
17 | # using the `resource_name`, to keep the generated IDs unique between resources
18 | # during the registration process.
19 | #
20 | # A `raw_credential` method is provided to streamline access to
21 | # `passkey_params[:passkey_credential]`.
22 | #
23 | # **Note**: the implementing controller **must** define a `relying_party` method in order for
24 | # registrations to work.
25 | #
26 | # @example
27 | # class RegistrationsController < ApplicationController
28 | # include Devise::Passkeys::Controllers::RegistrationsControllerConcern
29 | #
30 | # def relying_party
31 | # WebAuthn::RelyingParty.new
32 | # end
33 | # end
34 | #
35 | #
36 | # @see Devise::Passkeys::Controllers::Concerns::Reauthentication
37 | # @see Warden::WebAuthn::RegistrationHelpers
38 | module RegistrationsControllerConcern
39 | extend ActiveSupport::Concern
40 |
41 | included do
42 | include Devise::Passkeys::Controllers::Concerns::Reauthentication
43 | include Warden::WebAuthn::RegistrationHelpers
44 |
45 | before_action :require_no_authentication, only: [:new_challenge]
46 | before_action :require_email_and_passkey_label, only: %i[new_challenge create]
47 | before_action :verify_passkey_registration_challenge, only: [:create]
48 | before_action :configure_sign_up_params, only: [:create]
49 |
50 | before_action :verify_reauthentication_token, only: %i[update destroy]
51 |
52 | def registration_user_id_key
53 | "#{resource_name}_current_webauthn_user_id"
54 | end
55 |
56 | def registration_challenge_key
57 | "#{resource_name}_current_webauthn_registration_challenge"
58 | end
59 |
60 | def raw_credential
61 | passkey_params[:passkey_credential]
62 | end
63 | end
64 |
65 | # This controller action issues a new challenge for the registration handshake.
66 | #
67 | # The challenge is stored in a session variable, and renders the WebAuthn
68 | # registration options as a JSON response.
69 | #
70 | # The following before filters are called:
71 | #
72 | # - `require_no_authentication`
73 | # - `require_email_and_passkey_label`
74 | #
75 | # @see DeviseController#require_no_authentication
76 | # @see require_email_and_passkey_label
77 | # @see Warden::WebAuthn#generate_registration_options
78 | # @see https://github.com/cedarcode/webauthn-ruby#initiation-phase
79 | def new_challenge
80 | options_for_registration = generate_registration_options(
81 | relying_party: relying_party,
82 | user_details: user_details_for_registration,
83 | exclude: exclude_external_ids_for_registration
84 | )
85 |
86 | store_challenge_in_session(options_for_registration: options_for_registration)
87 |
88 | render json: options_for_registration
89 | end
90 |
91 | # This controller action creates a new user (`resource`), using the given
92 | # email & passkey. It:
93 | #
94 | # 1. calls the parent class's `#create` method
95 | # 2. calls `#create_passkey_for_resource` to finish creating the passkey
96 | # if the user (`resource`) was actually persisted
97 | # 3. Finishes the rest of the parent class's `#create` method
98 | #
99 | #
100 | # The following before actions are called:
101 | #
102 | # - `require_email_and_passkey_label`
103 | # - `verify_passkey_registration_challenge`
104 | # - `configure_sign_up_params`
105 | #
106 | # @see require_email_and_passkey_label
107 | # @see verify_passkey_registration_challenge
108 | # @see configure_sign_up_params
109 | # @see create_passkey_for_resource
110 | def create
111 | super do |resource|
112 | create_passkey_for_resource(resource: resource)
113 | end
114 | end
115 |
116 | protected
117 |
118 | # @!visibility public
119 | #
120 | # Creates a passkey for given user (`resource`).
121 | #
122 | # The method tests that the user (`resource`) is in the database
123 | # before saving the passkey for the given user (`resource`).
124 | #
125 | #
126 | # This method also ensures that the generated WebAuthn User ID is deleted from the session to prevent
127 | # data leaks.
128 | #
129 | #
130 | # @yield [resource, passkey] The provided `resource` and the newly created passkey.
131 | # @see create_passkey
132 | def create_passkey_for_resource(resource:)
133 | return unless resource.persisted?
134 |
135 | passkey = create_passkey(resource: resource)
136 |
137 | yield [resource, passkey] if block_given?
138 | delete_registration_user_id!
139 | end
140 |
141 | # @!visibility public
142 | #
143 | # Generates a passkey for the given `resource`, using the `resource.passkeys.create!`
144 | # method with the following attributes:
145 | #
146 | # - `label`: The `passkey_params[:passkey_label]`
147 | # - `public_key`: The `@webauthn_credential.public_key`
148 | # - `external_id`: The credential ID, strictly encoded as a Base 64 string
149 | # - `sign_count`: The `@webauthn_credential.sign_count`
150 | # - `last_used_at`: The current time, since this is the first time the passkey is being used
151 | #
152 | def create_passkey(resource:)
153 | resource.passkeys.create!(
154 | label: passkey_params[:passkey_label],
155 | public_key: @webauthn_credential.public_key,
156 | external_id: Base64.strict_encode64(@webauthn_credential.raw_id),
157 | sign_count: @webauthn_credential.sign_count,
158 | last_used_at: Time.now.utc
159 | )
160 | end
161 |
162 | # @!visibility public
163 | #
164 | # Verifies that the given reauthentication token matches the
165 | # expected value stored in the session.
166 | #
167 | # If the reauthentication token is not valid,
168 | # a `400 Bad Request` JSON response is rendered.
169 | #
170 | # @example
171 | # {"error": "Please reauthenticate to continue."}
172 | #
173 | # @see reauthentication_params
174 | # @see Devise::Passkeys::Controllers::Concerns::Reauthentication#valid_reauthentication_token?
175 | def verify_reauthentication_token
176 | return if valid_reauthentication_token?(given_reauthentication_token: reauthentication_params[:reauthentication_token])
177 |
178 | render json: { error: find_message(:not_reauthenticated) }, status: :bad_request
179 | end
180 |
181 | # @!visibility public
182 | # The subset of parameters used when verifying a reauthentication_token
183 | def reauthentication_params
184 | params.require(resource_name).permit(:reauthentication_token)
185 | end
186 |
187 | # @!visibility public
188 | # An override of `DeviseController`'s implementation, to circumvent the
189 | # `update_with_password` method
190 | # @see DeviseController#update_resource
191 | def update_resource(resource, params)
192 | resource.update(params)
193 | end
194 |
195 | # @!visibility public
196 | # Override this method if you need to exclude certain WebAuthn credentials
197 | # from a registration request.
198 | # @see new_challenge
199 | # @see https://github.com/cedarcode/webauthn-ruby#initiation-phase
200 | def exclude_external_ids_for_registration
201 | []
202 | end
203 |
204 | # @!visibility public
205 | # The subset of parameters used when verifying the passkey
206 | def passkey_params
207 | params.require(resource_name).permit(:passkey_label, :passkey_credential)
208 | end
209 |
210 | # @!visibility public
211 | # Verifies that the `sign_up_params` has an `:email` and `:passkey_label`.
212 | #
213 | # If either is missing or blank, a `400 Bad Request` JSON response is rendered.
214 | #
215 | # @example
216 | # {"error": "Please enter your email address."}
217 | def require_email_and_passkey_label
218 | if sign_up_params[:email].blank?
219 | render json: { message: find_message(:email_missing) }, status: :bad_request
220 | return false
221 | end
222 |
223 | if passkey_params[:passkey_label].blank?
224 | render json: { message: find_message(:passkey_label_missing) }, status: :bad_request
225 | return false
226 | end
227 |
228 | true
229 | end
230 |
231 | # @!visibility public
232 | # Verifies the registration challenge is correct.
233 | #
234 | # If the challenge failed, a `400 Bad Request` JSON
235 | # response is rendered.
236 | #
237 | # @example
238 | # {"error": "Please try a different passkey."}
239 | #
240 | # @see Warden::WebAuthn::RegistrationHelpers#verify_registration
241 | # @see https://github.com/cedarcode/webauthn-ruby#verification-phase
242 | # @see Warden::WebAuthn::ErrorKeyFinder#webauthn_error_key
243 | def verify_passkey_registration_challenge
244 | @webauthn_credential = verify_registration(relying_party: relying_party)
245 | rescue ::WebAuthn::Error => e
246 | error_key = Warden::WebAuthn::ErrorKeyFinder.webauthn_error_key(exception: e)
247 | render json: { message: find_message(error_key) }, status: :bad_request
248 | end
249 |
250 | # @!visibility public
251 | # Adds the generated WebAuthn User ID to `devise_parameter_sanitizer`'s permitted keys
252 | def configure_sign_up_params
253 | params[resource_name][:webauthn_id] = registration_user_id
254 | devise_parameter_sanitizer.permit(:sign_up, keys: [:webauthn_id])
255 | end
256 |
257 | # @!visibility public
258 | # Prepares the user details for a WebAuthn registration request
259 | # @see new_challenge
260 | # @see https://github.com/cedarcode/webauthn-ruby#initiation-phase
261 | def user_details_for_registration
262 | store_registration_user_id
263 | { id: registration_user_id, name: sign_up_params[:email] }
264 | end
265 |
266 | def registration_user_id
267 | session[registration_user_id_key]
268 | end
269 |
270 | def delete_registration_user_id!
271 | session.delete(registration_user_id_key)
272 | end
273 |
274 | def store_registration_user_id
275 | session[registration_user_id_key] = WebAuthn.generate_user_id
276 | end
277 | end
278 | end
279 | end
280 | end
281 |
--------------------------------------------------------------------------------
/test/devise/passkeys/controllers/test_reauthentication_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../test_helper/webauthn_test_helpers"
5 | require_relative "../../../test_helper/extra_assertions"
6 |
7 | class Devise::Passkeys::Controllers::TestReauthenticationControllerConcern < ActionDispatch::IntegrationTest
8 | include WebAuthnTestHelpers
9 | include ExtraAssertions
10 | include Devise::Test::IntegrationHelpers
11 |
12 | class TestReauthenticationController < ActionController::Base
13 | include Devise::Passkeys::Controllers::ReauthenticationControllerConcern
14 |
15 | attr_accessor :resource
16 |
17 | def form
18 | render inline: <<~HTML
19 | <%= form_with(url: "/form", method: :post) do |f|%>
20 | <% end %>
21 | HTML
22 | end
23 |
24 | def relying_party
25 | WebAuthn::RelyingParty.new(origin: "https://www.example.com")
26 | end
27 |
28 | def resource_name
29 | :user
30 | end
31 |
32 | def root_path
33 | "/home"
34 | end
35 | end
36 |
37 | setup do
38 | ActionController::Base.allow_forgery_protection = true
39 | Rails.application.routes.draw do
40 | get "/reauthentication/form" => "devise/passkeys/controllers/test_reauthentication_controller_concern/test_reauthentication#form"
41 | post "/reauthentication/new_challenge" => "devise/passkeys/controllers/test_reauthentication_controller_concern/test_reauthentication#new_challenge"
42 | post "/reauthentication/reauthenticate" => "devise/passkeys/controllers/test_reauthentication_controller_concern/test_reauthentication#reauthenticate"
43 | end
44 | end
45 |
46 | teardown do
47 | Rails.application.reload_routes!
48 | ActionController::Base.allow_forgery_protection = false
49 | end
50 |
51 | test "#new_challenge: not signed in" do
52 | post "/reauthentication/new_challenge"
53 | assert_redirected_to "http://www.example.com/"
54 | end
55 |
56 | test "#new_challenge" do
57 | user = User.create!(email: "test@test.com")
58 |
59 | 3.times do |n|
60 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
61 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
62 | end
63 |
64 | allowed_passkey_ids = user.passkeys.pluck(:external_id).map do |id|
65 | { "type" => "public-key", "id" => id }
66 | end
67 |
68 | sign_in(user)
69 | post "/reauthentication/new_challenge"
70 |
71 | response_json = JSON.parse(response.body)
72 |
73 | assert_equal response_json["challenge"], session["user_current_reauthentication_challenge"]
74 | assert_equal 120_000, response_json["timeout"]
75 | assert_equal ({}), response_json["extensions"]
76 | assert_equal allowed_passkey_ids, response_json["allowCredentials"]
77 | assert_equal "required", response_json["userVerification"]
78 | end
79 |
80 | test "#reauthenticate: success, does not overwrite the CSRF token" do
81 | relying_party = example_relying_party(options: { origin: "www.example.com" })
82 | client = fake_client(origin: "https://www.example.com")
83 | credential = create_credential(client: client, relying_party: relying_party)
84 |
85 | user = User.create!(email: "test@test.com")
86 |
87 | passkey = user.passkeys.create!(
88 | label: "dummy",
89 | external_id: Base64.strict_encode64(credential.id),
90 | public_key: Base64.strict_encode64(credential.public_key)
91 | )
92 |
93 | sign_in(user)
94 |
95 | get "/reauthentication/form"
96 | existing_csrf_token = session["_csrf_token"]
97 | assert_not_nil existing_csrf_token
98 |
99 | post "/reauthentication/new_challenge"
100 |
101 | assert_equal JSON.parse(response.body)["challenge"], session["user_current_reauthentication_challenge"]
102 |
103 | assertion = assertion_from_client(client: client, challenge: JSON.parse(response.body)["challenge"],
104 | user_verified: true)
105 |
106 | post "/reauthentication/reauthenticate", params: { passkey_credential: assertion.to_json }, as: :json
107 |
108 | response_json = JSON.parse(response.body)
109 |
110 | assert_equal existing_csrf_token, session["_csrf_token"]
111 | assert_equal User.after_passkey_authentication_passkey, passkey.label
112 | assert_equal ({ "reauthentication_token" => session["user_current_reauthentication_token"] }), response_json
113 | assert_nil session["user_current_reauthentication_challenge"]
114 | end
115 |
116 | test "#reauthenticate: user not verified" do
117 | relying_party = example_relying_party(options: { origin: "www.example.com" })
118 | client = fake_client(origin: "https://www.example.com")
119 | credential = create_credential(client: client, relying_party: relying_party)
120 |
121 | user = User.create!(email: "test@test.com")
122 |
123 | passkey = user.passkeys.create!(
124 | label: "dummy",
125 | external_id: Base64.strict_encode64(credential.id),
126 | public_key: Base64.strict_encode64(credential.public_key)
127 | )
128 |
129 | sign_in(user)
130 |
131 | get "/reauthentication/form"
132 | existing_csrf_token = session["_csrf_token"]
133 | assert_not_nil existing_csrf_token
134 |
135 | post "/reauthentication/new_challenge"
136 |
137 | assert_equal JSON.parse(response.body)["challenge"], session["user_current_reauthentication_challenge"]
138 |
139 | assertion = assertion_from_client(client: client, challenge: JSON.parse(response.body)["challenge"],
140 | user_verified: false)
141 |
142 | post "/reauthentication/reauthenticate", params: { passkey_credential: assertion.to_json }, as: :json
143 |
144 | response_json = JSON.parse(response.body)
145 |
146 | assert_equal existing_csrf_token, session["_csrf_token"]
147 | assert_translation_missing_error(translation_key: "en.devise.failure.user.webauthn_user_verified_verification_error")
148 | assert_nil session["user_current_reauthentication_challenge"]
149 | assert_response :unauthorized
150 | end
151 |
152 | test "#reauthenticate: bad challenge" do
153 | relying_party = example_relying_party(options: { origin: "www.example.com" })
154 | client = fake_client(origin: "https://www.example.com")
155 | credential = create_credential(client: client, relying_party: relying_party)
156 |
157 | user = User.create!(email: "test@test.com")
158 |
159 | passkey = user.passkeys.create!(
160 | label: "dummy",
161 | external_id: Base64.strict_encode64(credential.id),
162 | public_key: Base64.strict_encode64(credential.public_key)
163 | )
164 |
165 | sign_in(user)
166 |
167 | get "/reauthentication/form"
168 | existing_csrf_token = session["_csrf_token"]
169 | assert_not_nil existing_csrf_token
170 |
171 | post "/reauthentication/new_challenge"
172 |
173 | assert_equal JSON.parse(response.body)["challenge"], session["user_current_reauthentication_challenge"]
174 |
175 | assertion = assertion_from_client(client: client, challenge: "blah", user_verified: true)
176 |
177 | post "/reauthentication/reauthenticate", params: { passkey_credential: assertion.to_json }, as: :json
178 |
179 | response_json = JSON.parse(response.body)
180 |
181 | assert_equal existing_csrf_token, session["_csrf_token"]
182 | assert_translation_missing_error(translation_key: "en.devise.failure.user.webauthn_challenge_verification_error")
183 | assert_nil session["user_current_reauthentication_challenge"]
184 | assert_response :unauthorized
185 | end
186 |
187 | test "#reauthenticate: credential removed" do
188 | relying_party = example_relying_party(options: { origin: "www.example.com" })
189 | client = fake_client(origin: "https://www.example.com")
190 | credential = create_credential(client: client, relying_party: relying_party)
191 |
192 | user = User.create!(email: "test@test.com")
193 |
194 | passkey = user.passkeys.create!(
195 | label: "dummy",
196 | external_id: Base64.strict_encode64(credential.id),
197 | public_key: Base64.strict_encode64(credential.public_key)
198 | )
199 |
200 | sign_in(user)
201 |
202 | get "/reauthentication/form"
203 | existing_csrf_token = session["_csrf_token"]
204 | assert_not_nil existing_csrf_token
205 |
206 | post "/reauthentication/new_challenge"
207 |
208 | assert_equal JSON.parse(response.body)["challenge"], session["user_current_reauthentication_challenge"]
209 |
210 | assertion = assertion_from_client(client: client, challenge: "blah", user_verified: true)
211 |
212 | passkey.destroy
213 |
214 | post "/reauthentication/reauthenticate", params: { passkey_credential: assertion.to_json }, as: :json
215 |
216 | response_json = JSON.parse(response.body)
217 |
218 | assert_equal existing_csrf_token, session["_csrf_token"]
219 | assert_translation_missing_error(translation_key: "en.devise.failure.user.stored_credential_not_found")
220 | assert_nil session["user_current_reauthentication_challenge"]
221 | assert_response :unauthorized
222 | end
223 |
224 | test "#reauthenticate: credential cannot be parsed" do
225 | relying_party = example_relying_party(options: { origin: "www.example.com" })
226 | client = fake_client(origin: "https://www.example.com")
227 | credential = create_credential(client: client, relying_party: relying_party)
228 |
229 | user = User.create!(email: "test@test.com")
230 |
231 | passkey = user.passkeys.create!(
232 | label: "dummy",
233 | external_id: Base64.strict_encode64(credential.id),
234 | public_key: Base64.strict_encode64(credential.public_key)
235 | )
236 |
237 | sign_in(user)
238 |
239 | get "/reauthentication/form"
240 | existing_csrf_token = session["_csrf_token"]
241 | assert_not_nil existing_csrf_token
242 |
243 | post "/reauthentication/new_challenge"
244 |
245 | assert_equal JSON.parse(response.body)["challenge"], session["user_current_reauthentication_challenge"]
246 |
247 | assertion = assertion_from_client(client: client, challenge: "blah", user_verified: true)
248 |
249 | passkey.destroy
250 |
251 | post "/reauthentication/reauthenticate", params: { passkey_credential: "blah" }, as: :json
252 |
253 | response_json = JSON.parse(response.body)
254 |
255 | assert_equal existing_csrf_token, session["_csrf_token"]
256 | assert_equal ({ "error" => "You need to sign in or sign up before continuing." }), response.parsed_body
257 | assert_nil session["user_current_reauthentication_challenge"]
258 | assert_response :unauthorized
259 | end
260 |
261 | test "#reauthenticate: credential missing" do
262 | relying_party = example_relying_party(options: { origin: "www.example.com" })
263 | client = fake_client(origin: "https://www.example.com")
264 | credential = create_credential(client: client, relying_party: relying_party)
265 |
266 | user = User.create!(email: "test@test.com")
267 |
268 | passkey = user.passkeys.create!(
269 | label: "dummy",
270 | external_id: Base64.strict_encode64(credential.id),
271 | public_key: Base64.strict_encode64(credential.public_key)
272 | )
273 |
274 | sign_in(user)
275 |
276 | get "/reauthentication/form"
277 | existing_csrf_token = session["_csrf_token"]
278 | assert_not_nil existing_csrf_token
279 |
280 | post "/reauthentication/new_challenge"
281 |
282 | assert_equal JSON.parse(response.body)["challenge"], session["user_current_reauthentication_challenge"]
283 |
284 | assertion = assertion_from_client(client: client, challenge: "blah", user_verified: true)
285 |
286 | passkey.destroy
287 |
288 | post "/reauthentication/reauthenticate", params: { other: 1234 }, as: :json
289 |
290 | response_json = JSON.parse(response.body)
291 |
292 | assert_equal existing_csrf_token, session["_csrf_token"]
293 | assert_equal ({ "error" => "You need to sign in or sign up before continuing." }), response.parsed_body
294 | assert_nil session["user_current_reauthentication_challenge"]
295 | assert_response :unauthorized
296 | end
297 |
298 | test "#reauthenticate: not signed in" do
299 | relying_party = example_relying_party(options: { origin: "test.host" })
300 | client = fake_client
301 | credential = create_credential(client: client, relying_party: relying_party)
302 |
303 | user = User.create!(email: "test@test.com")
304 |
305 | passkey = user.passkeys.create!(
306 | label: "dummy",
307 | external_id: Base64.strict_encode64(credential.id),
308 | public_key: Base64.strict_encode64(credential.public_key)
309 | )
310 |
311 | sign_in(user)
312 | post "/reauthentication/new_challenge"
313 |
314 | assert_equal JSON.parse(response.body)["challenge"], session["user_current_reauthentication_challenge"]
315 |
316 | assertion = assertion_from_client(client: client, challenge: JSON.parse(response.body)["challenge"],
317 | user_verified: true)
318 |
319 | sign_out(user)
320 |
321 | post "/reauthentication/reauthenticate", params: { passkey_credential: assertion.to_json }, as: :json
322 | assert_equal ({ "error" => "You need to sign in or sign up before continuing." }), response.parsed_body
323 | assert_response :unauthorized
324 | end
325 | end
326 |
327 | class Devise::Passkeys::Controllers::TestReauthenticationControllerConcernSetup < ActionDispatch::IntegrationTest
328 | include WebAuthnTestHelpers
329 | include Devise::Test::IntegrationHelpers
330 |
331 | class TestReauthenticationController < ActionController::Base
332 | include Devise::Passkeys::Controllers::ReauthenticationControllerConcern
333 |
334 | attr_accessor :resource
335 |
336 | def resource_name
337 | :user
338 | end
339 |
340 | def root_path
341 | "/home"
342 | end
343 | end
344 |
345 | setup do
346 | Rails.application.routes.draw do
347 | post "/reauthentication/new_challenge" => "devise/passkeys/controllers/test_reauthentication_controller_concern_setup/test_reauthentication#new_challenge"
348 | post "/reauthentication/reauthenticate" => "devise/passkeys/controllers/test_reauthentication_controller_concern_setup/test_reauthentication#reauthenticate"
349 | end
350 | end
351 |
352 | teardown do
353 | Rails.application.reload_routes!
354 | end
355 |
356 | test "#new_challenge: raises NoMethodError if relying_party has not been implemented" do
357 | user = User.create!(email: "test@test.com")
358 | sign_in(user)
359 | assert_raises NoMethodError do
360 | post "/reauthentication/new_challenge"
361 | end
362 | end
363 | end
364 |
--------------------------------------------------------------------------------
/test/devise/passkeys/controllers/test_registrations_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../test_helper/webauthn_test_helpers"
5 | require_relative "../../../test_helper/extra_assertions"
6 |
7 | class Devise::Passkeys::Controllers::TestRegistrationsControllerConcern < ActionDispatch::IntegrationTest
8 | include WebAuthnTestHelpers
9 | include ExtraAssertions
10 | include Devise::Test::IntegrationHelpers
11 |
12 | class TestRegistrationController < Devise::RegistrationsController
13 | include Devise::Passkeys::Controllers::RegistrationsControllerConcern
14 |
15 | def relying_party
16 | WebAuthn::RelyingParty.new(origin: "https://www.example.com")
17 | end
18 |
19 | # Dummy action to setup reauthentication token
20 | def reauthenticate
21 | store_reauthentication_token_in_session
22 | render json: { token: stored_reauthentication_token }
23 | end
24 |
25 | def resource_name
26 | :user
27 | end
28 | end
29 |
30 | setup do
31 | Rails.application.routes.draw do
32 | devise_scope :user do
33 | post "/registration/new_challenge" => "devise/passkeys/controllers/test_registrations_controller_concern/test_registration#new_challenge"
34 | post "/registration" => "devise/passkeys/controllers/test_registrations_controller_concern/test_registration#create"
35 | post "/registration/reauthenticate" => "devise/passkeys/controllers/test_registrations_controller_concern/test_registration#reauthenticate"
36 | patch "/registration" => "devise/passkeys/controllers/test_registrations_controller_concern/test_registration#update"
37 | delete "/registration" => "devise/passkeys/controllers/test_registrations_controller_concern/test_registration#destroy"
38 | end
39 | end
40 | end
41 |
42 | teardown do
43 | Rails.application.reload_routes!
44 | end
45 |
46 | test "#new_challenge: signed in" do
47 | user = User.create!(email: "test@test.com")
48 | sign_in(user)
49 |
50 | post "/registration/new_challenge"
51 | assert_redirected_to "http://www.example.com/"
52 | end
53 |
54 | test "#new_challenge: not signed in" do
55 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
56 |
57 | response_json = JSON.parse(response.body)
58 | assert_response :ok
59 |
60 | refute_nil session["user_current_webauthn_registration_challenge"]
61 | refute_nil session["user_current_webauthn_user_id"]
62 |
63 | assert_equal response_json["challenge"], session["user_current_webauthn_registration_challenge"]
64 |
65 | assert_equal ({
66 | "name" => "test@test.com",
67 | "id" => session["user_current_webauthn_user_id"],
68 | "displayName" => "test@test.com"
69 | }), response_json["user"]
70 |
71 | assert_equal 120_000, response_json["timeout"]
72 | assert_equal ({}), response_json["extensions"]
73 | assert_empty response_json["excludeCredentials"]
74 | assert_equal ({ "residentKey" => "required", "userVerification" => "required" }), response_json["authenticatorSelection"]
75 | end
76 |
77 | test "#create: success" do
78 | relying_party = example_relying_party(options: { origin: "www.example.com" })
79 | client = fake_client(origin: "https://www.example.com")
80 |
81 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
82 |
83 | webauthn_id = session["user_current_webauthn_user_id"]
84 |
85 | response_json = JSON.parse(response.body)
86 | assert_response :ok
87 |
88 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: true)
89 |
90 | attestation_object =
91 | if client.encoding
92 | relying_party.encoder.decode(raw_credential["response"]["attestationObject"])
93 | else
94 | raw_credential["response"]["attestationObject"]
95 | end
96 |
97 | client_data_json =
98 | if client.encoding
99 | relying_party.encoder.decode(raw_credential["response"]["clientDataJSON"])
100 | else
101 | raw_credential["response"]["clientDataJSON"]
102 | end
103 |
104 | response = WebAuthn::AuthenticatorAttestationResponse.new(
105 | attestation_object: attestation_object,
106 | client_data_json: client_data_json,
107 | relying_party: relying_party
108 | )
109 |
110 | assert_difference "User.count", +1 do
111 | assert_difference "UserPasskey.count", +1 do
112 | post "/registration",
113 | params: { user: { email: "test@test.com", passkey_label: "Test",
114 | passkey_credential: raw_credential.to_json } }
115 |
116 | assert_redirected_to "http://www.example.com/"
117 | end
118 | end
119 |
120 | user = User.last
121 | passkey = user.passkeys.first
122 |
123 | assert_equal webauthn_id, user.webauthn_id
124 | assert_equal "test@test.com", user.email
125 |
126 | assert_equal "Test", passkey.label
127 | assert_equal Base64.strict_encode64(response.credential.id), passkey.external_id
128 | refute_nil passkey.public_key
129 | refute_nil passkey.last_used_at
130 |
131 | assert_nil session["user_current_webauthn_registration_challenge"]
132 | assert_nil session["user_current_webauthn_user_id"]
133 | end
134 |
135 | test "#create: user not verified" do
136 | relying_party = example_relying_party(options: { origin: "www.example.com" })
137 | client = fake_client(origin: "https://www.example.com")
138 |
139 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
140 |
141 | webauthn_id = session["user_current_webauthn_user_id"]
142 |
143 | response_json = JSON.parse(response.body)
144 | assert_response :ok
145 |
146 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: false)
147 |
148 | assert_no_difference "User.count" do
149 | assert_no_difference "UserPasskey.count" do
150 | post "/registration",
151 | params: { user: { email: "test@test.com", passkey_label: "Test",
152 | passkey_credential: raw_credential.to_json } }
153 |
154 | assert_response :bad_request
155 | assert_translation_missing_message(translation_key: "en.devise.registrations.user.webauthn_user_verified_verification_error")
156 | end
157 | end
158 | end
159 |
160 | test "#create: bad challenge" do
161 | relying_party = example_relying_party(options: { origin: "www.example.com" })
162 | client = fake_client(origin: "https://www.example.com")
163 |
164 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
165 |
166 | webauthn_id = session["user_current_webauthn_user_id"]
167 |
168 | response_json = JSON.parse(response.body)
169 | assert_response :ok
170 |
171 | raw_credential = client.create(challenge: "blah", user_verified: true)
172 |
173 | assert_no_difference "User.count" do
174 | assert_no_difference "UserPasskey.count" do
175 | post "/registration",
176 | params: { user: { email: "test@test.com", passkey_label: "Test",
177 | passkey_credential: raw_credential.to_json } }
178 |
179 | assert_response :bad_request
180 | assert_translation_missing_message(translation_key: "en.devise.registrations.user.webauthn_challenge_verification_error")
181 | end
182 | end
183 | end
184 |
185 | test "#create: credential cannot be parsed" do
186 | relying_party = example_relying_party(options: { origin: "www.example.com" })
187 | client = fake_client(origin: "https://www.example.com")
188 |
189 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
190 |
191 | webauthn_id = session["user_current_webauthn_user_id"]
192 |
193 | response_json = JSON.parse(response.body)
194 | assert_response :ok
195 |
196 | assert_no_difference "User.count" do
197 | assert_no_difference "UserPasskey.count" do
198 | assert_raises JSON::ParserError do
199 | post "/registration",
200 | params: { user: { email: "test@test.com", passkey_label: "Test", passkey_credential: "blah" } }
201 | end
202 | end
203 | end
204 | end
205 |
206 | test "#create: credential missing" do
207 | relying_party = example_relying_party(options: { origin: "www.example.com" })
208 | client = fake_client(origin: "https://www.example.com")
209 |
210 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
211 |
212 | webauthn_id = session["user_current_webauthn_user_id"]
213 |
214 | response_json = JSON.parse(response.body)
215 | assert_response :ok
216 |
217 | assert_no_difference "User.count" do
218 | assert_no_difference "UserPasskey.count" do
219 | assert_raises TypeError do
220 | post "/registration", params: { user: { email: "test@test.com", passkey_label: "Test" } }
221 | end
222 | end
223 | end
224 | end
225 |
226 | test "#create: passkey label missing" do
227 | relying_party = example_relying_party(options: { origin: "www.example.com" })
228 | client = fake_client(origin: "https://www.example.com")
229 |
230 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
231 |
232 | webauthn_id = session["user_current_webauthn_user_id"]
233 |
234 | response_json = JSON.parse(response.body)
235 | assert_response :ok
236 |
237 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: false)
238 |
239 | assert_no_difference "User.count" do
240 | assert_no_difference "UserPasskey.count" do
241 | post "/registration", params: { user: { email: "test@test.com", passkey_credential: raw_credential.to_json } }
242 |
243 | assert_response :bad_request
244 | assert_translation_missing_message(translation_key: "en.devise.registrations.user.passkey_label_missing")
245 | end
246 | end
247 | end
248 |
249 | test "#create: non-passkey attribute missing" do
250 | relying_party = example_relying_party(options: { origin: "www.example.com" })
251 | client = fake_client(origin: "https://www.example.com")
252 |
253 | post "/registration/new_challenge", params: { user: { email: "test@test.com", passkey_label: "Test" } }
254 |
255 | webauthn_id = session["user_current_webauthn_user_id"]
256 |
257 | response_json = JSON.parse(response.body)
258 | assert_response :ok
259 |
260 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: false)
261 |
262 | assert_no_difference "User.count" do
263 | assert_no_difference "UserPasskey.count" do
264 | post "/registration", params: { user: { passkey_label: "Test", passkey_credential: raw_credential.to_json } }
265 |
266 | assert_response :bad_request
267 | assert_translation_missing_message(translation_key: "en.devise.registrations.user.email_missing")
268 | end
269 | end
270 | end
271 |
272 | test "#create: did not complete challenge" do
273 | relying_party = example_relying_party(options: { origin: "www.example.com" })
274 | client = fake_client(origin: "https://www.example.com")
275 |
276 | raw_credential = client.create(challenge: encode_challenge, user_verified: false)
277 |
278 | assert_no_difference "User.count" do
279 | assert_no_difference "UserPasskey.count" do
280 | assert_raises NoMethodError do
281 | post "/registration",
282 | params: { user: { email: "test@test.com", passkey_label: "Test",
283 | passkey_credential: raw_credential.to_json } }
284 | end
285 | end
286 | end
287 | end
288 |
289 | test "#update: success with reauthentication_token" do
290 | user = User.create!(email: "test@test.com")
291 | sign_in(user)
292 |
293 | post "/registration/reauthenticate"
294 | refute_nil session["user_current_reauthentication_token"]
295 | token = response.parsed_body["token"]
296 |
297 | assert_no_difference "User.count" do
298 | assert_no_difference "UserPasskey.count" do
299 | patch "/registration", params: { user: { email: "hello@example.com", reauthentication_token: token } }
300 | end
301 | end
302 |
303 | assert_redirected_to "http://www.example.com/"
304 |
305 | assert_equal "hello@example.com", user.reload.email
306 |
307 | assert_nil session["user_current_reauthentication_token"]
308 | end
309 |
310 | test "#update: never reauthenticated" do
311 | user = User.create!(email: "test@test.com")
312 | sign_in(user)
313 |
314 | assert_no_difference "User.count" do
315 | assert_no_difference "UserPasskey.count" do
316 | patch "/registration", params: { user: { email: "hello@example.com", reauthentication_token: "asdasdasdasd" } }
317 | end
318 | end
319 |
320 | assert_response :bad_request
321 | assert_translation_missing_error(translation_key: "en.devise.registrations.user.not_reauthenticated")
322 |
323 | assert_equal "test@test.com", user.reload.email
324 |
325 | assert_nil session["user_current_reauthentication_token"]
326 | end
327 |
328 | test "#update: failure without reauthentication_token" do
329 | user = User.create!(email: "test@test.com")
330 | sign_in(user)
331 |
332 | post "/registration/reauthenticate"
333 | refute_nil session["user_current_reauthentication_token"]
334 | token = response.parsed_body["token"]
335 |
336 | assert_no_difference "User.count" do
337 | assert_no_difference "UserPasskey.count" do
338 | patch "/registration", params: { user: { email: "hello@example.com" } }
339 | end
340 | end
341 |
342 | assert_response :bad_request
343 | assert_translation_missing_error(translation_key: "en.devise.registrations.user.not_reauthenticated")
344 |
345 | assert_equal "test@test.com", user.reload.email
346 |
347 | assert_nil session["user_current_reauthentication_token"]
348 | end
349 |
350 | test "#update: failure with bad reauthentication_token" do
351 | user = User.create!(email: "test@test.com")
352 | sign_in(user)
353 |
354 | post "/registration/reauthenticate"
355 | refute_nil session["user_current_reauthentication_token"]
356 | token = response.parsed_body["token"]
357 |
358 | assert_no_difference "User.count" do
359 | assert_no_difference "UserPasskey.count" do
360 | patch "/registration", params: { user: { email: "hello@example.com", reauthentication_token: "blah" } }
361 | end
362 | end
363 |
364 | assert_response :bad_request
365 | assert_translation_missing_error(translation_key: "en.devise.registrations.user.not_reauthenticated")
366 |
367 | assert_equal "test@test.com", user.reload.email
368 |
369 | assert_nil session["user_current_reauthentication_token"]
370 | end
371 |
372 | test "#destroy: success with reauthentication_token" do
373 | user = User.create!(email: "test@test.com")
374 | sign_in(user)
375 |
376 | user.passkeys.create!(label: "dummy", external_id: "dummy-passkey",
377 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
378 |
379 | post "/registration/reauthenticate"
380 | refute_nil session["user_current_reauthentication_token"]
381 | token = response.parsed_body["token"]
382 |
383 | assert_difference "User.count", -1 do
384 | assert_difference "UserPasskey.count", -1 do
385 | delete "/registration", params: { user: { reauthentication_token: token } }
386 | end
387 | end
388 |
389 | assert_redirected_to "http://www.example.com/"
390 |
391 | assert_nil User.find_by(id: user.id)
392 |
393 | assert_nil session["user_current_reauthentication_token"]
394 | end
395 |
396 | test "#destroy: never reauthenticated" do
397 | user = User.create!(email: "test@test.com")
398 | sign_in(user)
399 |
400 | assert_no_difference "User.count" do
401 | assert_no_difference "UserPasskey.count" do
402 | delete "/registration", params: { user: { reauthentication_token: "asdasdasdasd" } }
403 | end
404 | end
405 |
406 | assert_response :bad_request
407 | assert_translation_missing_error(translation_key: "en.devise.registrations.user.not_reauthenticated")
408 |
409 | assert_equal "test@test.com", user.reload.email
410 |
411 | assert_nil session["user_current_reauthentication_token"]
412 | end
413 |
414 | test "#destroy: failure without reauthentication_token" do
415 | user = User.create!(email: "test@test.com")
416 | sign_in(user)
417 |
418 | post "/registration/reauthenticate"
419 | refute_nil session["user_current_reauthentication_token"]
420 | token = response.parsed_body["token"]
421 |
422 | assert_no_difference "User.count" do
423 | assert_no_difference "UserPasskey.count" do
424 | delete "/registration", params: { user: { test: 123 } }
425 | end
426 | end
427 |
428 | assert_response :bad_request
429 | assert_translation_missing_error(translation_key: "en.devise.registrations.user.not_reauthenticated")
430 |
431 | assert_equal "test@test.com", user.reload.email
432 |
433 | assert_nil session["user_current_reauthentication_token"]
434 | end
435 |
436 | test "#destroy: failure with bad reauthentication_token" do
437 | user = User.create!(email: "test@test.com")
438 | sign_in(user)
439 |
440 | post "/registration/reauthenticate"
441 | refute_nil session["user_current_reauthentication_token"]
442 | token = response.parsed_body["token"]
443 |
444 | assert_no_difference "User.count" do
445 | assert_no_difference "UserPasskey.count" do
446 | delete "/registration", params: { user: { reauthentication_token: "blah" } }
447 | end
448 | end
449 |
450 | assert_response :bad_request
451 | assert_translation_missing_error(translation_key: "en.devise.registrations.user.not_reauthenticated")
452 |
453 | assert_equal "test@test.com", user.reload.email
454 |
455 | assert_nil session["user_current_reauthentication_token"]
456 | end
457 | end
458 |
--------------------------------------------------------------------------------
/test/devise/passkeys/controllers/test_passkeys_controller_concern.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "test_helper"
4 | require_relative "../../../test_helper/webauthn_test_helpers"
5 | require_relative "../../../test_helper/extra_assertions"
6 |
7 | class Devise::Passkeys::Controllers::TestPasskeysControllerConcern < ActionDispatch::IntegrationTest
8 | include WebAuthnTestHelpers
9 | include ExtraAssertions
10 | include Devise::Test::IntegrationHelpers
11 |
12 | class TestPasskeyController < DeviseController
13 | include Devise::Passkeys::Controllers::PasskeysControllerConcern
14 |
15 | attr_accessor :resource
16 |
17 | def relying_party
18 | WebAuthn::RelyingParty.new(origin: "https://www.example.com")
19 | end
20 |
21 | def resource_name
22 | :user
23 | end
24 |
25 | def root_path
26 | "/"
27 | end
28 |
29 | # Dummy action to setup reauthentication token
30 | def reauthenticate
31 | store_reauthentication_token_in_session
32 | render json: { token: stored_reauthentication_token }
33 | end
34 | end
35 |
36 | setup do
37 | Rails.application.routes.draw do
38 | devise_scope :user do
39 | post "/passkey/reauthenticate" => "devise/passkeys/controllers/test_passkeys_controller_concern/test_passkey#reauthenticate"
40 |
41 | post "/passkey/new_create_challenge" => "devise/passkeys/controllers/test_passkeys_controller_concern/test_passkey#new_create_challenge"
42 | post "/passkey/create" => "devise/passkeys/controllers/test_passkeys_controller_concern/test_passkey#create"
43 |
44 | post "/passkey/:id/new_destroy_challenge" => "devise/passkeys/controllers/test_passkeys_controller_concern/test_passkey#new_destroy_challenge"
45 |
46 | delete "/passkey/:id" => "devise/passkeys/controllers/test_passkeys_controller_concern/test_passkey#destroy"
47 | end
48 | end
49 | end
50 |
51 | teardown do
52 | Rails.application.reload_routes!
53 | end
54 |
55 | test "#new_create_challenge: not signed in" do
56 | post "/passkey/new_create_challenge"
57 | assert_redirected_to "http://www.example.com/"
58 | end
59 |
60 | test "#new_create_challenge: signed in" do
61 | relying_party = example_relying_party(options: { origin: "www.example.com" })
62 | client = fake_client(origin: "https://www.example.com")
63 | credential = create_credential(client: client, relying_party: relying_party)
64 |
65 | user = User.create!(email: "test@test.com")
66 |
67 | passkey = user.passkeys.create!(
68 | label: "dummy",
69 | external_id: Base64.strict_encode64(credential.id),
70 | public_key: Base64.strict_encode64(credential.public_key)
71 | )
72 |
73 | excluded_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
74 |
75 | sign_in(user)
76 |
77 | post "/passkey/new_create_challenge"
78 |
79 | response_json = JSON.parse(response.body)
80 |
81 | assert_equal response_json["challenge"], session["user_passkey_creation_challenge"]
82 | assert_equal 120_000, response_json["timeout"]
83 | assert_equal ({}), response_json["extensions"]
84 | assert_equal excluded_credentials, response_json["excludeCredentials"]
85 | assert_equal ({ "residentKey" => "required", "userVerification" => "required" }), response_json["authenticatorSelection"]
86 | end
87 |
88 | test "#create: creates a passkey for the user" do
89 | relying_party = example_relying_party(options: { origin: "www.example.com" })
90 | client = fake_client(origin: "https://www.example.com")
91 | credential = create_credential(client: client, relying_party: relying_party)
92 |
93 | user = User.create!(email: "test@test.com")
94 |
95 | 3.times do |n|
96 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
97 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
98 | end
99 |
100 | excluded_credentials = user.passkeys.pluck(:external_id).map do |id|
101 | { "type" => "public-key", "id" => id }
102 | end
103 |
104 | sign_in(user)
105 |
106 | post "/passkey/reauthenticate"
107 | refute_nil session["user_current_reauthentication_token"]
108 | token = response.parsed_body["token"]
109 |
110 | post "/passkey/new_create_challenge"
111 |
112 | response_json = JSON.parse(response.body)
113 |
114 | assert_equal response_json["challenge"], session["user_passkey_creation_challenge"]
115 | assert_equal 120_000, response_json["timeout"]
116 | assert_equal ({}), response_json["extensions"]
117 | assert_equal excluded_credentials, response_json["excludeCredentials"]
118 | assert_equal ({ "residentKey" => "required", "userVerification" => "required" }), response_json["authenticatorSelection"]
119 |
120 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: true)
121 |
122 | attestation_object =
123 | if client.encoding
124 | relying_party.encoder.decode(raw_credential["response"]["attestationObject"])
125 | else
126 | raw_credential["response"]["attestationObject"]
127 | end
128 |
129 | client_data_json =
130 | if client.encoding
131 | relying_party.encoder.decode(raw_credential["response"]["clientDataJSON"])
132 | else
133 | raw_credential["response"]["clientDataJSON"]
134 | end
135 |
136 | response = WebAuthn::AuthenticatorAttestationResponse.new(
137 | attestation_object: attestation_object,
138 | client_data_json: client_data_json,
139 | relying_party: relying_party
140 | )
141 |
142 | assert_no_difference "User.count" do
143 | assert_difference "user.passkeys.count", +1 do
144 | post "/passkey/create",
145 | params: { passkey: { label: "Test", credential: raw_credential.to_json, reauthentication_token: token } }
146 |
147 | assert_redirected_to "http://www.example.com/"
148 | end
149 | end
150 |
151 | passkey = user.passkeys.last
152 |
153 | assert_equal "Test", passkey.label
154 | assert_equal Base64.strict_encode64(response.credential.id), passkey.external_id
155 | refute_nil passkey.public_key
156 | assert_nil passkey.last_used_at
157 |
158 | assert_nil session["user_passkey_creation_challenge"]
159 | assert_nil session["user_current_reauthentication_token"]
160 | end
161 |
162 | test "#create: user not verified" do
163 | relying_party = example_relying_party(options: { origin: "www.example.com" })
164 | client = fake_client(origin: "https://www.example.com")
165 | credential = create_credential(client: client, relying_party: relying_party)
166 |
167 | user = User.create!(email: "test@test.com")
168 |
169 | 3.times do |n|
170 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
171 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
172 | end
173 |
174 | excluded_credentials = user.passkeys.pluck(:external_id).map do |id|
175 | { "type" => "public-key", "id" => id }
176 | end
177 |
178 | sign_in(user)
179 |
180 | post "/passkey/reauthenticate"
181 | refute_nil session["user_current_reauthentication_token"]
182 | token = response.parsed_body["token"]
183 |
184 | post "/passkey/new_create_challenge"
185 |
186 | response_json = JSON.parse(response.body)
187 |
188 | assert_equal response_json["challenge"], session["user_passkey_creation_challenge"]
189 | assert_equal 120_000, response_json["timeout"]
190 | assert_equal ({}), response_json["extensions"]
191 | assert_equal excluded_credentials, response_json["excludeCredentials"]
192 | assert_equal ({ "residentKey" => "required", "userVerification" => "required" }), response_json["authenticatorSelection"]
193 |
194 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: false)
195 |
196 | assert_no_difference "User.count" do
197 | assert_no_difference "user.passkeys.count" do
198 | post "/passkey/create",
199 | params: { passkey: { label: "Test", credential: raw_credential.to_json, reauthentication_token: token } }
200 |
201 | assert_response :bad_request
202 | assert_translation_missing_message(translation_key: "en.devise.test_passkey.user.webauthn_user_verified_verification_error")
203 | end
204 | end
205 |
206 | assert_nil session["user_passkey_creation_challenge"]
207 | refute_nil session["user_current_reauthentication_token"]
208 | end
209 |
210 | test "#create: bad challenge" do
211 | relying_party = example_relying_party(options: { origin: "www.example.com" })
212 | client = fake_client(origin: "https://www.example.com")
213 | credential = create_credential(client: client, relying_party: relying_party)
214 |
215 | user = User.create!(email: "test@test.com")
216 |
217 | 3.times do |n|
218 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
219 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
220 | end
221 |
222 | excluded_credentials = user.passkeys.pluck(:external_id).map do |id|
223 | { "type" => "public-key", "id" => id }
224 | end
225 |
226 | sign_in(user)
227 |
228 | post "/passkey/reauthenticate"
229 | refute_nil session["user_current_reauthentication_token"]
230 | token = response.parsed_body["token"]
231 |
232 | post "/passkey/new_create_challenge"
233 |
234 | response_json = JSON.parse(response.body)
235 |
236 | assert_equal response_json["challenge"], session["user_passkey_creation_challenge"]
237 | assert_equal 120_000, response_json["timeout"]
238 | assert_equal ({}), response_json["extensions"]
239 | assert_equal excluded_credentials, response_json["excludeCredentials"]
240 | assert_equal ({ "residentKey" => "required", "userVerification" => "required" }), response_json["authenticatorSelection"]
241 |
242 | raw_credential = client.create(challenge: "blah", user_verified: true)
243 |
244 | assert_no_difference "User.count" do
245 | assert_no_difference "user.passkeys.count" do
246 | post "/passkey/create",
247 | params: { passkey: { label: "Test", credential: raw_credential.to_json, reauthentication_token: token } }
248 |
249 | assert_response :bad_request
250 | assert_translation_missing_message(translation_key: "en.devise.test_passkey.user.webauthn_challenge_verification_error")
251 | end
252 | end
253 |
254 | assert_nil session["user_passkey_creation_challenge"]
255 | refute_nil session["user_current_reauthentication_token"]
256 | end
257 |
258 | test "#create: credential cannot be parsed" do
259 | relying_party = example_relying_party(options: { origin: "www.example.com" })
260 | client = fake_client(origin: "https://www.example.com")
261 | credential = create_credential(client: client, relying_party: relying_party)
262 |
263 | user = User.create!(email: "test@test.com")
264 |
265 | 3.times do |n|
266 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
267 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
268 | end
269 |
270 | excluded_credentials = user.passkeys.pluck(:external_id).map do |id|
271 | { "type" => "public-key", "id" => id }
272 | end
273 |
274 | sign_in(user)
275 |
276 | post "/passkey/reauthenticate"
277 | refute_nil session["user_current_reauthentication_token"]
278 | token = response.parsed_body["token"]
279 |
280 | post "/passkey/new_create_challenge"
281 |
282 | response_json = JSON.parse(response.body)
283 |
284 | assert_no_difference "User.count" do
285 | assert_no_difference "user.passkeys.count" do
286 | post "/passkey/create",
287 | params: { passkey: { label: "Test", credential: "blahj", reauthentication_token: token } }
288 |
289 | assert_response :bad_request
290 | assert_translation_missing_message(translation_key: "en.devise.test_passkey.user.credential_missing_or_could_not_be_parsed")
291 | end
292 | end
293 |
294 | assert_nil session["user_passkey_creation_challenge"]
295 | refute_nil session["user_current_reauthentication_token"]
296 | end
297 |
298 | test "#create: credential missing" do
299 | relying_party = example_relying_party(options: { origin: "www.example.com" })
300 | client = fake_client(origin: "https://www.example.com")
301 | credential = create_credential(client: client, relying_party: relying_party)
302 |
303 | user = User.create!(email: "test@test.com")
304 |
305 | 3.times do |n|
306 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
307 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
308 | end
309 |
310 | excluded_credentials = user.passkeys.pluck(:external_id).map do |id|
311 | { "type" => "public-key", "id" => id }
312 | end
313 |
314 | sign_in(user)
315 |
316 | post "/passkey/reauthenticate"
317 | refute_nil session["user_current_reauthentication_token"]
318 | token = response.parsed_body["token"]
319 |
320 | post "/passkey/new_create_challenge"
321 |
322 | response_json = JSON.parse(response.body)
323 |
324 | assert_no_difference "User.count" do
325 | assert_no_difference "user.passkeys.count" do
326 | post "/passkey/create", params: { passkey: { label: "Test", reauthentication_token: token } }
327 |
328 | assert_response :bad_request
329 | assert_translation_missing_message(translation_key: "en.devise.test_passkey.user.credential_missing_or_could_not_be_parsed")
330 | end
331 | end
332 |
333 | assert_nil session["user_passkey_creation_challenge"]
334 | refute_nil session["user_current_reauthentication_token"]
335 | end
336 |
337 | test "#create: never reauthenticated" do
338 | relying_party = example_relying_party(options: { origin: "www.example.com" })
339 | client = fake_client(origin: "https://www.example.com")
340 | credential = create_credential(client: client, relying_party: relying_party)
341 |
342 | user = User.create!(email: "test@test.com")
343 |
344 | 3.times do |n|
345 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
346 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
347 | end
348 |
349 | excluded_credentials = user.passkeys.pluck(:external_id).map do |id|
350 | { "type" => "public-key", "id" => id }
351 | end
352 |
353 | sign_in(user)
354 |
355 | post "/passkey/new_create_challenge"
356 |
357 | response_json = JSON.parse(response.body)
358 |
359 | assert_equal response_json["challenge"], session["user_passkey_creation_challenge"]
360 | assert_equal 120_000, response_json["timeout"]
361 | assert_equal ({}), response_json["extensions"]
362 | assert_equal excluded_credentials, response_json["excludeCredentials"]
363 | assert_equal ({ "residentKey" => "required", "userVerification" => "required" }), response_json["authenticatorSelection"]
364 |
365 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: true)
366 |
367 | assert_no_difference "User.count" do
368 | assert_no_difference "user.passkeys.count" do
369 | post "/passkey/create",
370 | params: { passkey: { label: "Test", credential: raw_credential.to_json, reauthentication_token: :blah } }
371 |
372 | assert_response :bad_request
373 | assert_translation_missing_error(translation_key: "en.devise.test_passkey.user.not_reauthenticated")
374 | end
375 | end
376 | end
377 |
378 | test "#create: passkey label missing" do
379 | relying_party = example_relying_party(options: { origin: "www.example.com" })
380 | client = fake_client(origin: "https://www.example.com")
381 | credential = create_credential(client: client, relying_party: relying_party)
382 |
383 | user = User.create!(email: "test@test.com")
384 |
385 | 3.times do |n|
386 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
387 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
388 | end
389 |
390 | excluded_credentials = user.passkeys.pluck(:external_id).map do |id|
391 | { "type" => "public-key", "id" => id }
392 | end
393 |
394 | sign_in(user)
395 |
396 | post "/passkey/reauthenticate"
397 | refute_nil session["user_current_reauthentication_token"]
398 | token = response.parsed_body["token"]
399 |
400 | post "/passkey/new_create_challenge"
401 |
402 | response_json = JSON.parse(response.body)
403 |
404 | assert_equal response_json["challenge"], session["user_passkey_creation_challenge"]
405 | assert_equal 120_000, response_json["timeout"]
406 | assert_equal ({}), response_json["extensions"]
407 | assert_equal excluded_credentials, response_json["excludeCredentials"]
408 | assert_equal ({ "residentKey" => "required", "userVerification" => "required" }), response_json["authenticatorSelection"]
409 |
410 | raw_credential = client.create(challenge: response_json["challenge"], user_verified: true)
411 |
412 | assert_no_difference "User.count" do
413 | assert_no_difference "user.passkeys.count" do
414 | assert_raises ActiveRecord::RecordInvalid do
415 | post "/passkey/create",
416 | params: { passkey: { label: "", credential: raw_credential.to_json, reauthentication_token: token } }
417 | end
418 | end
419 | end
420 |
421 | refute_nil session["user_passkey_creation_challenge"]
422 | refute_nil session["user_current_reauthentication_token"]
423 | end
424 |
425 | test "#new_destroy_challenge: not signed in" do
426 | post "/passkey/1234/new_destroy_challenge"
427 | assert_redirected_to "http://www.example.com/"
428 | end
429 |
430 | test "#new_destroy_challenge: only 1 passkey" do
431 | relying_party = example_relying_party(options: { origin: "www.example.com" })
432 | client = fake_client(origin: "https://www.example.com")
433 | credential = create_credential(client: client, relying_party: relying_party)
434 |
435 | user = User.create!(email: "test@test.com")
436 |
437 | passkey = user.passkeys.create!(
438 | label: "dummy",
439 | external_id: Base64.strict_encode64(credential.id),
440 | public_key: Base64.strict_encode64(credential.public_key)
441 | )
442 |
443 | excluded_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
444 |
445 | sign_in(user)
446 |
447 | post "/passkey/#{passkey.id}/new_destroy_challenge"
448 | assert_response :bad_request
449 | assert_translation_missing_error(translation_key: "en.devise.test_passkey.user.must_be_at_least_one_passkey")
450 | end
451 |
452 | test "#new_destroy_challenge: other user passkey" do
453 | relying_party = example_relying_party(options: { origin: "www.example.com" })
454 | client = fake_client(origin: "https://www.example.com")
455 | credential = create_credential(client: client, relying_party: relying_party)
456 |
457 | user = User.create!(email: "test@test.com")
458 |
459 | 3.times do |n|
460 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
461 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
462 | end
463 |
464 | other_user = User.create!(email: "example@example.com")
465 |
466 | passkey = other_user.passkeys.create!(
467 | label: "dummy",
468 | external_id: Base64.strict_encode64(credential.id),
469 | public_key: Base64.strict_encode64(credential.public_key)
470 | )
471 |
472 | excluded_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
473 |
474 | sign_in(user)
475 |
476 | post "/passkey/#{passkey.id}/new_destroy_challenge"
477 | assert_response :not_found
478 | end
479 |
480 | test "#new_destroy_challenge: signed in, multiple passkeys" do
481 | relying_party = example_relying_party(options: { origin: "www.example.com" })
482 | client = fake_client(origin: "https://www.example.com")
483 | credential = create_credential(client: client, relying_party: relying_party)
484 |
485 | user = User.create!(email: "test@test.com")
486 |
487 | old_passkey = user.passkeys.create!(label: "OLD", external_id: "dummy-passkey",
488 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
489 |
490 | passkey = user.passkeys.create!(
491 | label: "dummy",
492 | external_id: Base64.strict_encode64(credential.id),
493 | public_key: Base64.strict_encode64(credential.public_key)
494 | )
495 |
496 | allowed_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
497 |
498 | sign_in(user)
499 |
500 | post "/passkey/#{old_passkey.id}/new_destroy_challenge"
501 | assert_response :ok
502 |
503 | refute_nil session["user_current_reauthentication_challenge"]
504 |
505 | response_json = JSON.parse(response.body)
506 |
507 | assert_equal response_json["challenge"], session["user_current_reauthentication_challenge"]
508 | assert_equal 120_000, response_json["timeout"]
509 | assert_equal ({}), response_json["extensions"]
510 | assert_equal allowed_credentials, response_json["allowCredentials"]
511 | assert_equal "required", response_json["userVerification"]
512 | end
513 |
514 | test "#destroy: not signed in" do
515 | assert_no_difference "UserPasskey.count" do
516 | delete "/passkey/1234"
517 | end
518 | assert_redirected_to "http://www.example.com/"
519 | end
520 |
521 | test "#destroy: only 1 passkey" do
522 | relying_party = example_relying_party(options: { origin: "www.example.com" })
523 | client = fake_client(origin: "https://www.example.com")
524 | credential = create_credential(client: client, relying_party: relying_party)
525 |
526 | user = User.create!(email: "test@test.com")
527 |
528 | passkey = user.passkeys.create!(
529 | label: "dummy",
530 | external_id: Base64.strict_encode64(credential.id),
531 | public_key: Base64.strict_encode64(credential.public_key)
532 | )
533 |
534 | excluded_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
535 |
536 | sign_in(user)
537 |
538 | assert_no_difference "UserPasskey.count" do
539 | delete "/passkey/#{passkey.id}"
540 | end
541 | assert_response :bad_request
542 | assert_translation_missing_error(translation_key: "en.devise.test_passkey.user.must_be_at_least_one_passkey")
543 | end
544 |
545 | test "#destroy: other user passkey" do
546 | relying_party = example_relying_party(options: { origin: "www.example.com" })
547 | client = fake_client(origin: "https://www.example.com")
548 | credential = create_credential(client: client, relying_party: relying_party)
549 |
550 | user = User.create!(email: "test@test.com")
551 |
552 | 3.times do |n|
553 | user.passkeys.create!(label: n.to_s, external_id: "dummy-passkey-#{n}",
554 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
555 | end
556 |
557 | other_user = User.create!(email: "example@example.com")
558 |
559 | passkey = other_user.passkeys.create!(
560 | label: "dummy",
561 | external_id: Base64.strict_encode64(credential.id),
562 | public_key: Base64.strict_encode64(credential.public_key)
563 | )
564 |
565 | excluded_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
566 |
567 | sign_in(user)
568 |
569 | assert_no_difference "UserPasskey.count" do
570 | delete "/passkey/#{passkey.id}"
571 | end
572 | assert_response :not_found
573 | end
574 |
575 | test "#destroy: success with reauthentication_token" do
576 | relying_party = example_relying_party(options: { origin: "www.example.com" })
577 | client = fake_client(origin: "https://www.example.com")
578 | credential = create_credential(client: client, relying_party: relying_party)
579 |
580 | user = User.create!(email: "test@test.com")
581 |
582 | old_passkey = user.passkeys.create!(label: "OLD", external_id: "dummy-passkey",
583 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
584 |
585 | passkey = user.passkeys.create!(
586 | label: "dummy",
587 | external_id: Base64.strict_encode64(credential.id),
588 | public_key: Base64.strict_encode64(credential.public_key)
589 | )
590 |
591 | allowed_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
592 |
593 | sign_in(user)
594 |
595 | post "/passkey/reauthenticate"
596 | refute_nil session["user_current_reauthentication_token"]
597 | token = response.parsed_body["token"]
598 |
599 | assert_difference "UserPasskey.count", -1 do
600 | delete "/passkey/#{passkey.id}", params: { passkey: { reauthentication_token: token } }
601 |
602 | assert_redirected_to "http://www.example.com/"
603 | end
604 |
605 | assert_nil UserPasskey.find_by(id: passkey.id)
606 | end
607 |
608 | test "#destroy: never reauthenticated" do
609 | relying_party = example_relying_party(options: { origin: "www.example.com" })
610 | client = fake_client(origin: "https://www.example.com")
611 | credential = create_credential(client: client, relying_party: relying_party)
612 |
613 | user = User.create!(email: "test@test.com")
614 |
615 | old_passkey = user.passkeys.create!(label: "OLD", external_id: "dummy-passkey",
616 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
617 |
618 | passkey = user.passkeys.create!(
619 | label: "dummy",
620 | external_id: Base64.strict_encode64(credential.id),
621 | public_key: Base64.strict_encode64(credential.public_key)
622 | )
623 |
624 | allowed_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
625 |
626 | sign_in(user)
627 |
628 | assert_no_difference "UserPasskey.count" do
629 | delete "/passkey/#{passkey.id}", params: { passkey: { reauthentication_token: "blah" } }
630 |
631 | assert_response :bad_request
632 | assert_translation_missing_error(translation_key: "en.devise.test_passkey.user.not_reauthenticated")
633 | end
634 |
635 | assert_equal passkey, UserPasskey.find_by(id: passkey.id)
636 | end
637 |
638 | test "#destroy: failure without reauthentication_token" do
639 | relying_party = example_relying_party(options: { origin: "www.example.com" })
640 | client = fake_client(origin: "https://www.example.com")
641 | credential = create_credential(client: client, relying_party: relying_party)
642 |
643 | user = User.create!(email: "test@test.com")
644 |
645 | old_passkey = user.passkeys.create!(label: "OLD", external_id: "dummy-passkey",
646 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
647 |
648 | passkey = user.passkeys.create!(
649 | label: "dummy",
650 | external_id: Base64.strict_encode64(credential.id),
651 | public_key: Base64.strict_encode64(credential.public_key)
652 | )
653 |
654 | allowed_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
655 |
656 | sign_in(user)
657 |
658 | post "/passkey/reauthenticate"
659 | refute_nil session["user_current_reauthentication_token"]
660 | token = response.parsed_body["token"]
661 |
662 | assert_no_difference "UserPasskey.count" do
663 | delete "/passkey/#{passkey.id}", params: { passkey: { value: "blah" } }
664 |
665 | assert_response :bad_request
666 | assert_translation_missing_error(translation_key: "en.devise.test_passkey.user.not_reauthenticated")
667 | end
668 |
669 | assert_equal passkey, UserPasskey.find_by(id: passkey.id)
670 | end
671 |
672 | test "#destroy: failure with bad reauthentication_token" do
673 | relying_party = example_relying_party(options: { origin: "www.example.com" })
674 | client = fake_client(origin: "https://www.example.com")
675 | credential = create_credential(client: client, relying_party: relying_party)
676 |
677 | user = User.create!(email: "test@test.com")
678 |
679 | old_passkey = user.passkeys.create!(label: "OLD", external_id: "dummy-passkey",
680 | public_key: Base64.strict_encode64(SecureRandom.random_bytes(10)))
681 |
682 | passkey = user.passkeys.create!(
683 | label: "dummy",
684 | external_id: Base64.strict_encode64(credential.id),
685 | public_key: Base64.strict_encode64(credential.public_key)
686 | )
687 |
688 | allowed_credentials = [{ "type" => "public-key", "id" => passkey.external_id }]
689 |
690 | sign_in(user)
691 |
692 | post "/passkey/reauthenticate"
693 | refute_nil session["user_current_reauthentication_token"]
694 | token = response.parsed_body["token"]
695 |
696 | assert_no_difference "UserPasskey.count" do
697 | delete "/passkey/#{passkey.id}", params: { passkey: { reauthentication_token: "asdasdsadasd" } }
698 |
699 | assert_response :bad_request
700 | assert_translation_missing_error(translation_key: "en.devise.test_passkey.user.not_reauthenticated")
701 | end
702 |
703 | assert_equal passkey, UserPasskey.find_by(id: passkey.id)
704 | end
705 | end
706 |
--------------------------------------------------------------------------------