├── .circleci └── config.yml ├── .github └── dependabot.yml ├── .gitignore ├── .rubocop.yml ├── .yardopts ├── Appraisals ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── THANKS.md ├── bin ├── console ├── setup ├── test └── yard ├── devise-passkeys.gemspec ├── gemfiles ├── .bundle │ └── config ├── rails_6.gemfile └── rails_7.gemfile ├── lib └── devise │ ├── passkeys.rb │ └── passkeys │ ├── controllers.rb │ ├── controllers │ ├── concerns │ │ ├── reauthentication.rb │ │ └── reauthentication_challenge.rb │ ├── passkeys_controller_concern.rb │ ├── reauthentication_controller_concern.rb │ ├── registrations_controller_concern.rb │ └── sessions_controller_concern.rb │ ├── model.rb │ ├── passkey_issuer.rb │ ├── rails.rb │ ├── reauthentication_strategy.rb │ ├── strategy.rb │ └── version.rb ├── sig └── devise │ └── passkeys.rbs └── test ├── devise ├── passkeys │ └── controllers │ │ ├── concerns │ │ ├── test_reauthentication.rb │ │ └── test_reauthentication_challenge.rb │ │ ├── test_passkeys_controller_concern.rb │ │ ├── test_reauthentication_controller_concern.rb │ │ ├── test_registrations_controller_concern.rb │ │ └── test_sessions_controller_concern.rb ├── test_passkey_issuer.rb └── test_passkeys.rb ├── rails_app ├── Rakefile ├── app │ ├── active_record │ │ ├── admin.rb │ │ ├── shim.rb │ │ ├── user.rb │ │ ├── user_on_engine.rb │ │ ├── user_on_main_app.rb │ │ ├── user_passkey.rb │ │ ├── user_with_validations.rb │ │ └── user_without_email.rb │ ├── controllers │ │ ├── admins │ │ │ └── sessions_controller.rb │ │ ├── admins_controller.rb │ │ ├── application_controller.rb │ │ ├── application_with_fake_engine.rb │ │ ├── custom │ │ │ └── registrations_controller.rb │ │ ├── home_controller.rb │ │ ├── publisher │ │ │ ├── registrations_controller.rb │ │ │ └── sessions_controller.rb │ │ ├── streaming_controller.rb │ │ └── users_controller.rb │ ├── helpers │ │ └── application_helper.rb │ ├── mailers │ │ └── users │ │ │ ├── from_proc_mailer.rb │ │ │ ├── mailer.rb │ │ │ └── reply_to_mailer.rb │ ├── mongoid │ │ ├── admin.rb │ │ ├── shim.rb │ │ ├── user.rb │ │ ├── user_on_engine.rb │ │ ├── user_on_main_app.rb │ │ ├── user_with_validations.rb │ │ └── user_without_email.rb │ └── views │ │ ├── admins │ │ ├── index.html.erb │ │ └── sessions │ │ │ └── new.html.erb │ │ ├── home │ │ ├── admin_dashboard.html.erb │ │ ├── index.html.erb │ │ ├── join.html.erb │ │ ├── private.html.erb │ │ └── user_dashboard.html.erb │ │ ├── layouts │ │ └── application.html.erb │ │ └── users │ │ ├── edit_form.html.erb │ │ ├── index.html.erb │ │ ├── mailer │ │ └── confirmation_instructions.erb │ │ └── sessions │ │ └── new.html.erb ├── bin │ ├── bundle │ ├── rails │ └── rake ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── backtrace_silencers.rb │ │ ├── devise.rb │ │ ├── inflections.rb │ │ ├── secret_token.rb │ │ └── session_store.rb │ └── routes.rb ├── db │ ├── migrate │ │ └── 20100401102949_create_tables.rb │ └── schema.rb ├── lib │ ├── lazy_load_test_module.rb │ ├── shared_admin.rb │ ├── shared_user.rb │ └── shared_user_without_email.rb └── public │ ├── 404.html │ ├── 422.html │ ├── 500.html │ └── favicon.ico ├── test_helper.rb └── test_helper ├── extra_assertions.rb ├── orm ├── active_record.rb └── mongoid.rb └── webauthn_test_helpers.rb /.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 -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup markdown 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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_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/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/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/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 | -------------------------------------------------------------------------------- /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/app/active_record/shim.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Shim 4 | end 5 | -------------------------------------------------------------------------------- /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/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/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_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/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 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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/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/publisher/registrations_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Publisher::RegistrationsController < ApplicationController 4 | end 5 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/views/admins/index.html.erb: -------------------------------------------------------------------------------- 1 | Welcome Admin! 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/admins/sessions/new.html.erb: -------------------------------------------------------------------------------- 1 | Welcome to "sessions/new" view! 2 | <%= render template: "devise/sessions/new" %> 3 | -------------------------------------------------------------------------------- /test/rails_app/app/views/home/admin_dashboard.html.erb: -------------------------------------------------------------------------------- 1 | Admin dashboard 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/home/index.html.erb: -------------------------------------------------------------------------------- 1 | Home! 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/home/join.html.erb: -------------------------------------------------------------------------------- 1 | Join 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/home/private.html.erb: -------------------------------------------------------------------------------- 1 | Private! 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/home/user_dashboard.html.erb: -------------------------------------------------------------------------------- 1 | User dashboard 2 | -------------------------------------------------------------------------------- /test/rails_app/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 3 | 4 |
5 |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 |You may have mistyped the address or the page may have moved.
24 |Maybe you tried to change something you didn't have access to.
24 |We've been notified about this issue and we'll take a look at it shortly.
24 |