maquina-generators 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +53 -0
  3. data/lib/generators/maquina/clave/templates/app/views/sessions/new.html.erb.tt +2 -0
  4. data/lib/generators/maquina/passkeys/USAGE +18 -0
  5. data/lib/generators/maquina/passkeys/passkeys_generator.rb +260 -0
  6. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_challenge.rb +42 -0
  7. data/lib/generators/maquina/passkeys/templates/app/controllers/concerns/passkey_reauthentication.rb +26 -0
  8. data/lib/generators/maquina/passkeys/templates/app/controllers/passkey_challenges_controller.rb +24 -0
  9. data/lib/generators/maquina/passkeys/templates/app/controllers/passkeys_controller.rb +68 -0
  10. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkey_challenges_controller.rb +18 -0
  11. data/lib/generators/maquina/passkeys/templates/app/controllers/session/passkeys_controller.rb +55 -0
  12. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_registration_controller.js +109 -0
  13. data/lib/generators/maquina/passkeys/templates/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
  14. data/lib/generators/maquina/passkeys/templates/app/models/passkey.rb +36 -0
  15. data/lib/generators/maquina/passkeys/templates/app/views/passkeys/index.html.erb +77 -0
  16. data/lib/generators/maquina/passkeys/templates/app/views/sessions/_passkey.html.erb +23 -0
  17. data/lib/generators/maquina/passkeys/templates/config/initializers/webauthn.rb +23 -0
  18. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.en.yml +46 -0
  19. data/lib/generators/maquina/passkeys/templates/config/locales/passkeys.es.yml +46 -0
  20. data/lib/generators/maquina/passkeys/templates/migration_add_webauthn_id_to_users.rb.tt +6 -0
  21. data/lib/generators/maquina/passkeys/templates/migration_create_passkeys.rb.tt +20 -0
  22. data/lib/generators/maquina/passkeys/templates/test/integration/passkeys_test.rb +99 -0
  23. data/lib/generators/maquina/passkeys/templates/test/test_helpers/webauthn_test_helper.rb +38 -0
  24. data/lib/maquina_generators/version.rb +1 -1
  25. data/test/generators/maquina/clave_generator_test.rb +13 -2
  26. data/test/generators/maquina/passkeys_generator_test.rb +267 -0
  27. data/test/tmp/Gemfile +1 -13
  28. data/test/tmp/app/controllers/concerns/authentication.rb +2 -0
  29. data/test/tmp/app/controllers/concerns/passkey_challenge.rb +42 -0
  30. data/test/tmp/app/controllers/concerns/passkey_reauthentication.rb +26 -0
  31. data/test/tmp/app/controllers/passkey_challenges_controller.rb +24 -0
  32. data/test/tmp/app/controllers/passkeys_controller.rb +68 -0
  33. data/test/tmp/app/controllers/session/passkey_challenges_controller.rb +18 -0
  34. data/test/tmp/app/controllers/session/passkeys_controller.rb +55 -0
  35. data/test/tmp/app/javascript/controllers/passkey_registration_controller.js +109 -0
  36. data/test/tmp/app/javascript/controllers/passkey_sign_in_controller.js +113 -0
  37. data/test/tmp/app/models/email_verification.rb +2 -0
  38. data/test/tmp/app/models/passkey.rb +36 -0
  39. data/test/tmp/app/models/user.rb +47 -0
  40. data/test/tmp/app/views/passkeys/index.html.erb +77 -0
  41. data/test/tmp/app/views/sessions/_passkey.html.erb +23 -0
  42. data/test/tmp/app/views/sessions/new.html.erb +37 -0
  43. data/test/tmp/config/initializers/rack_attack.rb +9 -0
  44. data/test/tmp/config/initializers/webauthn.rb +23 -0
  45. data/test/tmp/config/locales/passkeys.en.yml +46 -0
  46. data/test/tmp/config/locales/passkeys.es.yml +46 -0
  47. data/test/tmp/config/routes.rb +7 -1
  48. data/test/tmp/db/migrate/20261014213621_add_webauthn_id_to_users.rb +6 -0
  49. data/test/tmp/db/migrate/20261014213622_create_passkeys.rb +20 -0
  50. data/test/tmp/test/integration/passkeys_test.rb +99 -0
  51. data/test/tmp/test/test_helper.rb +4 -0
  52. data/test/tmp/test/test_helpers/webauthn_test_helper.rb +38 -0
  53. metadata +49 -19
  54. data/test/tmp/.gitignore +0 -5
  55. data/test/tmp/.rubocop.yml +0 -11
  56. data/test/tmp/.standard.yml +0 -1
  57. data/test/tmp/Procfile.dev +0 -3
  58. data/test/tmp/README.md +0 -89
  59. data/test/tmp/app/controllers/home_controller.rb +0 -6
  60. data/test/tmp/app/javascript/application.js +0 -1
  61. data/test/tmp/app/views/home/index.html.erb +0 -204
  62. data/test/tmp/app/views/layouts/application.html.erb +0 -10
  63. data/test/tmp/config/application.rb +0 -15
  64. data/test/tmp/config/database.yml +0 -90
  65. data/test/tmp/config/database.yml.example +0 -90
  66. data/test/tmp/config/environments/development.rb +0 -8
  67. data/test/tmp/config/environments/production.rb +0 -6
  68. data/test/tmp/config/initializers/generators.rb +0 -4
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3a31ee05a1ed941cc88119c5721629c3c5089081dde3e7833d1768cb9598b202
4
- data.tar.gz: 5bc0462a7009085c8a752791090347501a59886cab23eb74ba7bdd5ff0ff2f08
3
+ metadata.gz: 7e7ab2221409345c84231d6908e18f5f14b265d32a10229f288f23d4c5a03006
4
+ data.tar.gz: b4966e9de4099d0664da18fd6ba2818b560770cdb2dab121a9f06265e38b4769
5
5
  SHA512:
6
- metadata.gz: 85c73f484e3ed4e09e28ec2bebf42ca13ea1dd84693b9e7abbffa0746f14aa69930d95607c0c592085ad6acd88ba2a5d81afd90e602c8265cf9122e05d86a3ef
7
- data.tar.gz: bf4cec22c128729f8425b71d345a6ef166e6c9f4d18172860ef1eca3bbb894997397ce227297168f54218d6d26c87dc707dbedbd7b268594620543d22f1b6437
6
+ metadata.gz: f7b0d031778c2aa4b8cc03079e08f049a79d499500dfe0bb8b44b5e94e7743afdfc0ea6d0625d97b0128c937628980c9eb5317eef5bfe9ea62d68cd25f3e0487
7
+ data.tar.gz: 2ccfde4751e49534fb7c36149c623ea8fd93849a8e26e860fae0566556f2832173f8f488b708dca6e23f8890e8a6b4243571e61c6811723a325c1535f2a5dde5
data/README.md CHANGED
@@ -62,6 +62,59 @@ All generated code lives in your app -- edit it directly:
62
62
  - **Email sender:** Edit `app/mailers/verification_mailer.rb`
63
63
  - **Translations:** Edit `config/locales/clave.*.yml`
64
64
 
65
+ ### Passkeys -- Passkey Sign-In for Clave
66
+
67
+ **Passkeys** adds WebAuthn passkeys as a second way to sign in, on top of Clave. People add passkeys from a `/passkeys` page and sign in with Face ID, a fingerprint or a device PIN, either with a button or straight from the email field's autofill. The email code stays as the way in without a passkey, so removing every passkey never locks anyone out.
68
+
69
+ The [`webauthn`](https://github.com/cedarcode/webauthn-ruby) gem handles only the cryptography (attestation and assertion parsing, signature, origin, challenge and user-verification checks). The flow itself, including controllers, model, views and JavaScript, is generated into your app. There is no JavaScript dependency: the Stimulus controllers use the browser's own `PublicKeyCredential.parseCreationOptionsFromJSON` / `parseRequestOptionsFromJSON` / `toJSON()`. The names (`passkeys` table, `credential_id`, `Session::PasskeysController`) follow the passkeys work proposed for Rails itself (rails/rails#57100).
70
+
71
+ #### What it generates
72
+
73
+ - **Gem:** `webauthn ~> 3.4`
74
+ - **Initializer:** `config/initializers/webauthn.rb`. The relying party is `localhost` in development; production reads `WEBAUTHN_RP_ID` / `WEBAUTHN_ORIGINS` (or `webauthn:` in credentials) and refuses to boot without them, except during the asset build.
75
+ - **Models:** `Passkey` (credential id, public key, sign count, transports, AAGUID, backup flags, last used) and `User#webauthn_id`, a random user handle created on first use
76
+ - **Sign-in:** `Session::PasskeyChallengesController` and `Session::PasskeysController`. These need no email (discoverable credentials, empty allow-list), require user verification, use each challenge once and expire it after 5 minutes, and sign in through Clave's `start_new_session_for`. An unknown credential gets a 404 so the browser can forget it (`signalUnknownCredential`).
77
+ - **Management:** `PasskeysController` and `PasskeyChallengesController` to add, rename and remove passkeys. Adding or removing a passkey needs a sign-in from the last 30 minutes (`PasskeyReauthentication::REAUTHENTICATE_AFTER`).
78
+ - **Views and JavaScript:** `passkeys/index`, a `sessions/_passkey` partial rendered on the sign-in page (and `autocomplete="username webauthn"` on its email field), plus `passkey_sign_in_controller.js` and `passkey_registration_controller.js`
79
+ - **Migrations:** `add_webauthn_id_to_users`, `create_passkeys`
80
+ - **Locale files:** English and Spanish
81
+ - **Rack::Attack:** a `passkey/ip` throttle when `config/initializers/rack_attack.rb` exists
82
+ - **Tests:** a `register_passkey` and `sign_in_with_passkey(user)` helper on top of `WebAuthn::FakeClient`, loaded from `test/test_helper.rb`, and `test/integration/passkeys_test.rb` covering the flow. The test creates users the way Clave's `User` does, so adjust its `setup` if your model differs.
83
+
84
+ #### Usage
85
+
86
+ ```bash
87
+ rails g maquina:clave # first
88
+ rails g maquina:passkeys
89
+ rails db:migrate
90
+ ```
91
+
92
+ Then link to `passkeys_path` from your account or settings page. Anything the generator cannot place safely (a `User` model or sign-in page that no longer has Clave's shape) is listed after the install instead of being forced.
93
+
94
+ #### Options
95
+
96
+ ```bash
97
+ rails g maquina:passkeys # Full install
98
+ rails g maquina:passkeys --skip-views # Endpoints only; bring your own UI
99
+ ```
100
+
101
+ #### Production
102
+
103
+ Passkeys are bound to a domain, and changing `rp_id` later orphans every saved passkey. Set it before the first user adds one:
104
+
105
+ ```bash
106
+ WEBAUTHN_RP_ID=example.com
107
+ WEBAUTHN_ORIGINS=https://example.com,https://app.example.com
108
+ ```
109
+
110
+ `rp_id` can be a parent of the origins' host. To serve passkeys across different domains, publish `/.well-known/webauthn` (Related Origin Requests).
111
+
112
+ #### Testing
113
+
114
+ - **Integration tests:** `register_passkey` and `sign_in_with_passkey(user)` go through the real endpoints with `WebAuthn::FakeClient`.
115
+ - **In a browser:** Chrome's virtual authenticator works in DevTools (More tools > WebAuthn) or over CDP (`WebAuthn.addVirtualAuthenticator` with `ctap2`, `internal`, a resident key and user verification). It answers the sign-in page's autofill request on its own, so loading `/session/new` signs you in without a click. To test the button, make `PublicKeyCredential.isConditionalMediationAvailable` return `false`.
116
+ - **Dev server on another port:** set `WEBAUTHN_ORIGINS=http://localhost:<port>`.
117
+
65
118
  ### Registration -- Password-Based Authentication with Accounts
66
119
 
67
120
  **Registration** generates a password-based authentication system with multi-tenant account support. It builds on top of the Rails 8 authentication generator, adding an Account model (tenant), user roles, and a registration flow.
@@ -30,11 +30,13 @@
30
30
  <%%= form.submit t(".submit"), data: { component: "button", variant: "primary" }, class: "w-full px-4 py-3 text-base font-medium text-primary-foreground bg-primary hover:bg-primary/90 rounded-lg transition-colors cursor-pointer" %>
31
31
  </div>
32
32
  <%% end %>
33
+ <% unless options[:skip_registration] -%>
33
34
 
34
35
  <p class="mt-6 text-center text-sm text-muted-foreground">
35
36
  <%%= t(".no_account") %>
36
37
  <%%= link_to t(".sign_up"), new_registration_path, class: "font-medium text-primary hover:text-primary/80" %>
37
38
  </p>
39
+ <% end -%>
38
40
  </div>
39
41
  </div>
40
42
  </div>
@@ -0,0 +1,18 @@
1
+ Description:
2
+ Adds passkey (WebAuthn) sign-in on top of Clave's email-code login. Needs
3
+ maquina:clave; the email code stays as the way in without a passkey.
4
+
5
+ Adds the webauthn gem, which only verifies the ceremonies; the flow is
6
+ generated into the app. Installs the Passkey model and a webauthn_id on
7
+ users, sign-in endpoints (POST /session/passkey_challenge and
8
+ /session/passkey) that ask for no email, a /passkeys page to add, rename
9
+ and remove passkeys (adding or removing needs a sign-in from the last 30
10
+ minutes), two Stimulus controllers that use the browser's own WebAuthn JSON
11
+ helpers (no JavaScript library), a passkey button and autofill on the
12
+ sign-in page, config/initializers/webauthn.rb, EN/ES locales, a
13
+ Rack::Attack throttle and a FakeClient test helper. Anything it cannot
14
+ place safely is listed after the install instead of being forced.
15
+
16
+ Examples:
17
+ rails g maquina:passkeys
18
+ rails g maquina:passkeys --skip-views # Endpoints only; bring your own UI
@@ -0,0 +1,260 @@
1
+ require "rails/generators"
2
+ require "rails/generators/migration"
3
+
4
+ module Maquina
5
+ module Generators
6
+ class PasskeysGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
8
+
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ GEM_LINE = %(gem "webauthn", "~> 3.4")
12
+ USER_SESSIONS = /^ has_many :sessions, dependent: :destroy\n/
13
+ USER_PRIVATE = /^ private\n/
14
+ SIGN_IN_FORM_END = /^ <% end %>\n/
15
+ EMAIL_AUTOCOMPLETE = %(autocomplete: "email")
16
+ TEST_HELP = /^require "rails\/test_help"\n/
17
+ THROTTLE_LIMIT = 20
18
+ THROTTLE_PERIOD = "1.minute"
19
+
20
+ class_option :skip_views, type: :boolean, default: false,
21
+ desc: "Skip view templates and Stimulus controllers"
22
+ class_option :quiet, type: :boolean, default: false,
23
+ desc: "Suppress post-install instructions"
24
+
25
+ def self.next_migration_number(dirname)
26
+ current = Time.now.utc.strftime("%Y%m%d%H%M%S")
27
+ existing = Dir.glob("#{dirname}/[0-9]*_*.rb").map { |f| File.basename(f).split("_", 2).first }.max
28
+ (existing && existing >= current) ? existing.succ : current
29
+ end
30
+
31
+ # 1. Passkeys sign people in through clave's sessions; the email code
32
+ # stays as the way to get in without one.
33
+ def require_clave
34
+ return if %w[app/models/email_verification.rb app/controllers/concerns/authentication.rb]
35
+ .all? { |path| File.exist?(File.join(destination_root, path)) }
36
+
37
+ raise Thor::Error, "maquina:passkeys adds passkeys to Clave's email-code sign-in. Run `rails g maquina:clave` first."
38
+ end
39
+
40
+ # 2. The webauthn gem verifies the ceremonies; the flow stays in the app.
41
+ def add_gem
42
+ gemfile = File.join(destination_root, "Gemfile")
43
+ return unless File.exist?(gemfile)
44
+ return if File.read(gemfile).match?(/^\s*gem ["']webauthn["']/)
45
+
46
+ append_to_file "Gemfile", "\n#{GEM_LINE}\n"
47
+ end
48
+
49
+ def run_bundle_install
50
+ return unless rails_app?
51
+
52
+ Bundler.with_unbundled_env do
53
+ system("bundle install", chdir: destination_root)
54
+ end
55
+ end
56
+
57
+ # 3. Relying party
58
+ def create_initializer
59
+ copy_file "config/initializers/webauthn.rb", "config/initializers/webauthn.rb"
60
+ end
61
+
62
+ # 4. Models
63
+ def create_models
64
+ copy_file "app/models/passkey.rb", "app/models/passkey.rb"
65
+
66
+ path = "app/models/user.rb"
67
+ content = File.read(File.join(destination_root, path))
68
+ return if content.include?("has_many :passkeys")
69
+
70
+ if content.match?(USER_SESSIONS) && content.scan(USER_PRIVATE).one?
71
+ inject_into_file path, user_association, after: USER_SESSIONS
72
+ inject_into_file path, user_webauthn_id, before: USER_PRIVATE
73
+ else
74
+ @user_pending = true
75
+ end
76
+ end
77
+
78
+ # 5. Controllers
79
+ def create_controllers
80
+ copy_file "app/controllers/concerns/passkey_challenge.rb", "app/controllers/concerns/passkey_challenge.rb"
81
+ copy_file "app/controllers/concerns/passkey_reauthentication.rb", "app/controllers/concerns/passkey_reauthentication.rb"
82
+ copy_file "app/controllers/session/passkey_challenges_controller.rb", "app/controllers/session/passkey_challenges_controller.rb"
83
+ copy_file "app/controllers/session/passkeys_controller.rb", "app/controllers/session/passkeys_controller.rb"
84
+ copy_file "app/controllers/passkey_challenges_controller.rb", "app/controllers/passkey_challenges_controller.rb"
85
+ copy_file "app/controllers/passkeys_controller.rb", "app/controllers/passkeys_controller.rb"
86
+ end
87
+
88
+ # 6. Routes
89
+ def add_routes
90
+ return if File.read(File.join(destination_root, "config/routes.rb")).include?("generated by maquina:passkeys")
91
+
92
+ route <<~RUBY
93
+ # Passkeys (generated by maquina:passkeys)
94
+ resource :session, only: [] do
95
+ resource :passkey_challenge, only: :create, controller: "session/passkey_challenges"
96
+ resource :passkey, only: :create, controller: "session/passkeys"
97
+ end
98
+ resource :passkey_challenge, only: :create
99
+ resources :passkeys, only: [:index, :create, :update, :destroy]
100
+ RUBY
101
+ end
102
+
103
+ # 7. Migrations
104
+ def add_migrations
105
+ migration_template "migration_add_webauthn_id_to_users.rb.tt", "db/migrate/add_webauthn_id_to_users.rb"
106
+ migration_template "migration_create_passkeys.rb.tt", "db/migrate/create_passkeys.rb"
107
+ end
108
+
109
+ # 8. Views and Stimulus controllers
110
+ def create_views
111
+ return if options[:skip_views]
112
+
113
+ copy_file "app/views/passkeys/index.html.erb", "app/views/passkeys/index.html.erb"
114
+ copy_file "app/views/sessions/_passkey.html.erb", "app/views/sessions/_passkey.html.erb"
115
+ copy_file "app/javascript/controllers/passkey_sign_in_controller.js",
116
+ "app/javascript/controllers/passkey_sign_in_controller.js"
117
+ copy_file "app/javascript/controllers/passkey_registration_controller.js",
118
+ "app/javascript/controllers/passkey_registration_controller.js"
119
+
120
+ add_passkey_to_sign_in_page
121
+ end
122
+
123
+ # 9. Locale files
124
+ def create_locale_files
125
+ copy_file "config/locales/passkeys.en.yml", "config/locales/passkeys.en.yml"
126
+ copy_file "config/locales/passkeys.es.yml", "config/locales/passkeys.es.yml"
127
+ end
128
+
129
+ # 10. Test helper and the flow's integration test
130
+ def create_test_helper
131
+ copy_file "test/test_helpers/webauthn_test_helper.rb", "test/test_helpers/webauthn_test_helper.rb"
132
+ copy_file "test/integration/passkeys_test.rb", "test/integration/passkeys_test.rb"
133
+
134
+ path = File.join(destination_root, "test/test_helper.rb")
135
+ return unless File.exist?(path)
136
+
137
+ content = File.read(path)
138
+ return if content.include?(test_helper_require)
139
+ return @test_helper_pending = true unless content.match?(TEST_HELP)
140
+
141
+ inject_into_file "test/test_helper.rb", "#{test_helper_require}\n", after: TEST_HELP
142
+ end
143
+
144
+ # 11. Rack::Attack: the sign-in ceremony gets its own login-style throttle.
145
+ def configure_rack_attack
146
+ path = "config/initializers/rack_attack.rb"
147
+ full_path = File.join(destination_root, path)
148
+ return @rack_attack_pending = true unless File.exist?(full_path)
149
+ return if File.read(full_path).include?(%("passkey/ip"))
150
+
151
+ append_to_file path, passkey_throttle
152
+ end
153
+
154
+ # 12. Post-install message
155
+ def show_post_install
156
+ return if options[:quiet]
157
+
158
+ say ""
159
+ say "Passkeys have been installed!", :green
160
+ say ""
161
+ say "Next steps:", :yellow
162
+ say " 1. bin/rails db:migrate"
163
+ say " 2. Link to passkeys_path from your account or settings page"
164
+ say " 3. Visit /passkeys signed in, add a passkey, and sign in with it at /session/new"
165
+ say ""
166
+ say "Production: passkeys are bound to your domain. Set them before deploying:", :yellow
167
+ say " WEBAUTHN_RP_ID=example.com"
168
+ say " WEBAUTHN_ORIGINS=https://example.com"
169
+ say " (or webauthn: {rp_id:, origins:} in credentials; see config/initializers/webauthn.rb)"
170
+ say ""
171
+ if @user_pending
172
+ say "Add to app/models/user.rb:", :yellow
173
+ say user_association
174
+ say user_webauthn_id
175
+ end
176
+ if @sign_in_pending
177
+ say "Offer passkeys on the sign-in page (app/views/sessions/new.html.erb):", :yellow
178
+ say " - render the button after the email form: <%= render \"sessions/passkey\" %>"
179
+ say %( - let the email field suggest passkeys: autocomplete: "username webauthn")
180
+ say ""
181
+ end
182
+ if @test_helper_pending
183
+ say "Load the passkey test helper in test/test_helper.rb:", :yellow
184
+ say " #{test_helper_require}"
185
+ say ""
186
+ end
187
+ if @rack_attack_pending
188
+ say "Throttle passkey sign-in in config/initializers/rack_attack.rb:", :yellow
189
+ say passkey_throttle
190
+ say ""
191
+ end
192
+ say "Optional:", :yellow
193
+ say " - Serving more than one domain? Publish /.well-known/webauthn (Related Origin Requests)"
194
+ say " - Edit config/locales/passkeys.*.yml to customize messages"
195
+ say " - Integration tests: register_passkey and sign_in_with_passkey(user) (test/test_helpers/webauthn_test_helper.rb)"
196
+ say ""
197
+ end
198
+
199
+ private
200
+
201
+ def rails_app?
202
+ File.exist?(File.join(destination_root, "bin/rails"))
203
+ end
204
+
205
+ def migration_version
206
+ "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
207
+ end
208
+
209
+ # The button goes after clave's email form, and the email field offers
210
+ # saved passkeys in its autofill. A page that no longer has that shape is
211
+ # left alone and listed after the install.
212
+ def add_passkey_to_sign_in_page
213
+ path = "app/views/sessions/new.html.erb"
214
+ full_path = File.join(destination_root, path)
215
+ return @sign_in_pending = true unless File.exist?(full_path)
216
+
217
+ content = File.read(full_path)
218
+ return if content.include?(%(render "sessions/passkey"))
219
+ return @sign_in_pending = true unless content.scan(SIGN_IN_FORM_END).one? && content.include?(EMAIL_AUTOCOMPLETE)
220
+
221
+ inject_into_file path, %(\n <%= render "sessions/passkey" %>\n), after: SIGN_IN_FORM_END
222
+ gsub_file path, EMAIL_AUTOCOMPLETE, %(autocomplete: "username webauthn")
223
+ end
224
+
225
+ def test_helper_require
226
+ %(require_relative "test_helpers/webauthn_test_helper")
227
+ end
228
+
229
+ def user_association
230
+ " has_many :passkeys, dependent: :destroy\n"
231
+ end
232
+
233
+ def user_webauthn_id
234
+ <<~RUBY.gsub(/^(?=.)/, " ")
235
+ # The WebAuthn user handle: random and stable, never the id or the email,
236
+ # since authenticators store it and hand it back on every sign-in.
237
+ def webauthn_id
238
+ super || begin
239
+ update_column(:webauthn_id, WebAuthn.generate_user_id)
240
+ super
241
+ end
242
+ end
243
+
244
+ RUBY
245
+ end
246
+
247
+ def passkey_throttle
248
+ <<~RUBY
249
+
250
+ # Passkey sign-in (maquina:passkeys): the challenge and the assertion,
251
+ # counted like the email-code login above. Keyed on the address Rails
252
+ # resolves through trusted proxies, not the proxy hop in req.ip.
253
+ Rack::Attack.throttle("passkey/ip", limit: #{THROTTLE_LIMIT}, period: #{THROTTLE_PERIOD}) do |req|
254
+ (req.env["action_dispatch.remote_ip"]&.to_s || req.ip) if req.path.start_with?("/session/passkey") && req.post?
255
+ end
256
+ RUBY
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,42 @@
1
+ # One WebAuthn challenge per ceremony, kept in the session (generated by
2
+ # maquina:passkeys).
3
+ #
4
+ # The browser signs the challenge, so a stored one that could be used twice
5
+ # would let a captured response be replayed. take_passkey_challenge removes it
6
+ # before anything is verified, and a challenge older than CHALLENGE_TTL is
7
+ # refused. Registration and sign-in keep theirs under separate keys, so one
8
+ # ceremony never consumes the other's.
9
+ module PasskeyChallenge
10
+ extend ActiveSupport::Concern
11
+
12
+ CHALLENGE_TTL = 5.minutes
13
+
14
+ private
15
+
16
+ def store_passkey_challenge(purpose, challenge)
17
+ session[passkey_challenge_key(purpose)] = {
18
+ "challenge" => challenge,
19
+ "expires_at" => CHALLENGE_TTL.from_now.to_i
20
+ }
21
+ end
22
+
23
+ def take_passkey_challenge(purpose)
24
+ stored = session.delete(passkey_challenge_key(purpose))
25
+ stored["challenge"] if stored && stored["expires_at"].to_i > Time.current.to_i
26
+ end
27
+
28
+ def passkey_challenge_key(purpose)
29
+ :"passkey_#{purpose}_challenge"
30
+ end
31
+
32
+ # The browser's PublicKeyCredential#toJSON(). It is checked by signature, not
33
+ # assigned to a record, so only the shape is permitted here.
34
+ def passkey_credential_params
35
+ params.require(:credential).permit(
36
+ :id, :rawId, :type, :authenticatorAttachment,
37
+ response: [:clientDataJSON, :attestationObject, :authenticatorData, :signature, :userHandle,
38
+ :publicKey, :publicKeyAlgorithm, transports: []],
39
+ clientExtensionResults: {}
40
+ ).to_h
41
+ end
42
+ end
@@ -0,0 +1,26 @@
1
+ # Adding or removing a passkey asks for a recent sign-in (generated by
2
+ # maquina:passkeys).
3
+ #
4
+ # A passkey added from a stolen session cookie would outlive the cookie, so
5
+ # a session older than REAUTHENTICATE_AFTER is ended and the person signs in
6
+ # again (email code or passkey), coming back to the passkeys page after.
7
+ module PasskeyReauthentication
8
+ extend ActiveSupport::Concern
9
+
10
+ REAUTHENTICATE_AFTER = 30.minutes
11
+
12
+ private
13
+
14
+ def require_recent_sign_in
15
+ return if Current.session.created_at.after?(REAUTHENTICATE_AFTER.ago)
16
+
17
+ terminate_session
18
+ session[:return_to_after_authenticating] = passkeys_url
19
+ flash[:alert] = t("flash.passkeys.reauthenticate")
20
+
21
+ respond_to do |format|
22
+ format.html { redirect_to new_session_path }
23
+ format.json { render json: {redirect_url: new_session_url}, status: :unauthorized }
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,24 @@
1
+ class PasskeyChallengesController < ApplicationController
2
+ include PasskeyChallenge
3
+ include PasskeyReauthentication
4
+
5
+ before_action :require_recent_sign_in
6
+
7
+ # POST /passkey_challenge
8
+ #
9
+ # Options for navigator.credentials.create(). A discoverable credential
10
+ # (resident key) is what makes it a passkey: the device keeps the account,
11
+ # so sign-in needs no email. The user's existing passkeys are excluded so the
12
+ # same authenticator is not registered twice.
13
+ def create
14
+ user = Current.user
15
+ options = WebAuthn::Credential.options_for_create(
16
+ user: {id: user.webauthn_id, name: user.email_address, display_name: user.name.presence || user.email_address},
17
+ exclude: user.passkeys.pluck(:credential_id),
18
+ authenticator_selection: {resident_key: "required", user_verification: "required"}
19
+ )
20
+ store_passkey_challenge(:registration, options.challenge)
21
+
22
+ render json: options
23
+ end
24
+ end
@@ -0,0 +1,68 @@
1
+ class PasskeysController < ApplicationController
2
+ include PasskeyChallenge
3
+ include PasskeyReauthentication
4
+
5
+ before_action :require_recent_sign_in, only: %i[create destroy]
6
+ before_action :set_passkey, only: %i[update destroy]
7
+
8
+ # GET /passkeys
9
+ def index
10
+ @passkeys = Current.user.passkeys.order(:created_at)
11
+ end
12
+
13
+ # POST /passkeys
14
+ def create
15
+ challenge = take_passkey_challenge(:registration)
16
+ return render_failure(t("flash.passkeys.challenge_expired")) unless challenge
17
+
18
+ credential = WebAuthn::Credential.from_create(passkey_credential_params)
19
+ # Checks the challenge, the origin, the relying party and that the person
20
+ # was verified on the device, not just present.
21
+ credential.verify(challenge, user_verification: true)
22
+
23
+ aaguid = credential.response.authenticator_data.aaguid
24
+ Current.user.passkeys.create!(
25
+ credential_id: credential.id,
26
+ public_key: credential.public_key,
27
+ sign_count: credential.sign_count,
28
+ transports: credential.response.transports,
29
+ aaguid: aaguid,
30
+ backup_eligible: credential.backup_eligible?,
31
+ backed_up: credential.backed_up?,
32
+ name: Passkey.default_name(aaguid)
33
+ )
34
+
35
+ Rails.logger.info "[AUTH] Passkey added for #{Current.user.email_address} from #{request.remote_ip}"
36
+ flash[:notice] = t("flash.passkeys.created")
37
+ render json: {redirect_url: passkeys_url}, status: :created
38
+ rescue WebAuthn::Error, ActiveRecord::RecordInvalid => error
39
+ Rails.logger.info "[AUTH] Passkey registration failed (#{error.class.name.demodulize}) for #{Current.user.email_address}"
40
+ render_failure(t("flash.passkeys.registration_failed"))
41
+ end
42
+
43
+ # PATCH /passkeys/:id
44
+ def update
45
+ if @passkey.update(params.expect(passkey: [:name]))
46
+ redirect_to passkeys_path, notice: t("flash.passkeys.updated"), status: :see_other
47
+ else
48
+ redirect_to passkeys_path, alert: @passkey.errors.full_messages.to_sentence, status: :see_other
49
+ end
50
+ end
51
+
52
+ # DELETE /passkeys/:id
53
+ def destroy
54
+ @passkey.destroy
55
+ Rails.logger.info "[AUTH] Passkey removed for #{Current.user.email_address} from #{request.remote_ip}"
56
+ redirect_to passkeys_path, notice: t("flash.passkeys.destroyed"), status: :see_other
57
+ end
58
+
59
+ private
60
+
61
+ def set_passkey
62
+ @passkey = Current.user.passkeys.find(params[:id])
63
+ end
64
+
65
+ def render_failure(message)
66
+ render json: {error: message}, status: :unprocessable_entity
67
+ end
68
+ end
@@ -0,0 +1,18 @@
1
+ class Session::PasskeyChallengesController < ApplicationController
2
+ include PasskeyChallenge
3
+
4
+ allow_unauthenticated_access
5
+
6
+ # POST /session/passkey_challenge
7
+ #
8
+ # Options for navigator.credentials.get(). No allowCredentials: the browser
9
+ # offers every passkey it holds for this site, so nobody types an email and
10
+ # the response says nothing about which accounts exist. It is a POST so a
11
+ # Turbo-cached page never reuses a challenge.
12
+ def create
13
+ options = WebAuthn::Credential.options_for_get(user_verification: "required")
14
+ store_passkey_challenge(:authentication, options.challenge)
15
+
16
+ render json: options
17
+ end
18
+ end
@@ -0,0 +1,55 @@
1
+ class Session::PasskeysController < ApplicationController
2
+ include PasskeyChallenge
3
+
4
+ allow_unauthenticated_access
5
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render json: {error: t("flash.general.rate_limited")}, status: :too_many_requests }
6
+
7
+ # POST /session/passkey
8
+ def create
9
+ challenge = take_passkey_challenge(:authentication)
10
+ return render_failure(t("flash.passkeys.challenge_expired")) unless challenge
11
+
12
+ credential = WebAuthn::Credential.from_get(passkey_credential_params)
13
+ passkey = Passkey.includes(:user).find_by(credential_id: credential.id)
14
+
15
+ # Deleted here but still saved on the device: the browser can be told to
16
+ # forget it (PublicKeyCredential.signalUnknownCredential).
17
+ unless passkey
18
+ Rails.logger.info "[AUTH] Passkey sign-in with an unknown credential from #{request.remote_ip}"
19
+ render json: {error: t("flash.passkeys.unknown"), credential_id: credential.id, rp_id: WebAuthn.configuration.rp_id},
20
+ status: :not_found
21
+ return
22
+ end
23
+
24
+ user = passkey.user
25
+ if user.blocked? || credential.user_handle != user.webauthn_id
26
+ Rails.logger.info "[AUTH] Passkey sign-in refused for #{user.email_address} from #{request.remote_ip}"
27
+ return render_failure(t("flash.passkeys.failed"))
28
+ end
29
+
30
+ # Checks the challenge, the origin, the signature with the stored public
31
+ # key, user verification (Face ID, fingerprint, device PIN) and that the
32
+ # signature counter moved forward.
33
+ credential.verify(challenge, public_key: passkey.public_key, sign_count: passkey.sign_count, user_verification: true)
34
+ passkey.record_use!(credential.sign_count)
35
+
36
+ Rails.logger.info "[AUTH] Successful passkey login for #{user.email_address} from #{request.remote_ip}"
37
+ start_new_session_for(user)
38
+ flash[:notice] = t("flash.sessions.create.success")
39
+ render json: {redirect_url: after_authentication_url}
40
+ rescue WebAuthn::SignCountVerificationError
41
+ # A valid signature with a counter that went backwards: possibly a cloned
42
+ # authenticator. Synced passkeys always report 0 and never land here.
43
+ Rails.logger.warn "[AUTH] Passkey sign count went backwards for #{passkey.user.email_address} from #{request.remote_ip}"
44
+ render_failure(t("flash.passkeys.failed"))
45
+ rescue WebAuthn::Error => error
46
+ Rails.logger.info "[AUTH] Passkey sign-in failed (#{error.class.name.demodulize}) from #{request.remote_ip}"
47
+ render_failure(t("flash.passkeys.failed"))
48
+ end
49
+
50
+ private
51
+
52
+ def render_failure(message)
53
+ render json: {error: message}, status: :unprocessable_entity
54
+ end
55
+ end