rails_authentication 0.4.0 → 0.6.0

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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +108 -9
  3. data/lib/generators/authentication/authentication_generator.rb +15 -0
  4. data/lib/generators/authentication/features/confirmable.rb +13 -12
  5. data/lib/generators/authentication/features/invitable.rb +14 -13
  6. data/lib/generators/authentication/features/lockable.rb +13 -12
  7. data/lib/generators/authentication/features/magic_link.rb +13 -12
  8. data/lib/generators/authentication/features/ott.rb +13 -12
  9. data/lib/generators/authentication/features/passkey.rb +36 -0
  10. data/lib/generators/authentication/features/recoverable.rb +13 -12
  11. data/lib/generators/authentication/features/registerable.rb +7 -6
  12. data/lib/generators/authentication/features/rememberable.rb +6 -5
  13. data/lib/generators/authentication/features/timeoutable.rb +7 -6
  14. data/lib/generators/authentication/features/trackable.rb +7 -6
  15. data/lib/generators/authentication/features/validatable.rb +5 -4
  16. data/lib/generators/authentication/templates/app/controllers/invitations_controller.rb.tt +6 -1
  17. data/lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt +68 -0
  18. data/lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt +4 -0
  19. data/lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt +43 -0
  20. data/lib/generators/authentication/templates/app/models/concerns/confirmable_concern.rb.tt +18 -17
  21. data/lib/generators/authentication/templates/app/models/concerns/invitable_concern.rb.tt +5 -4
  22. data/lib/generators/authentication/templates/app/models/concerns/passkey_concern.rb.tt +19 -0
  23. data/lib/generators/authentication/templates/app/models/webauthn_credential.rb.tt +6 -0
  24. data/lib/generators/authentication/templates/app/views/registrations/edit.html.erb.tt +4 -0
  25. data/lib/generators/authentication/templates/app/views/sessions/new.html.erb.tt +65 -0
  26. data/lib/generators/authentication/templates/app/views/webauthn_credentials/index.html.erb.tt +19 -0
  27. data/lib/generators/authentication/templates/app/views/webauthn_credentials/new.html.erb.tt +71 -0
  28. data/lib/generators/authentication/templates/config/initializers/webauthn.rb.tt +11 -0
  29. data/lib/generators/authentication/templates/db/migrate/add_webauthn_id_to_users.rb.tt +7 -0
  30. data/lib/generators/authentication/templates/db/migrate/create_webauthn_credentials.rb.tt +15 -0
  31. data/lib/rails_authentication/version.rb +1 -1
  32. metadata +12 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f5c0f93e4dc6c9eb2562c7804920c82cff3a5a7d23d15758aaee3d63cdb60d99
4
- data.tar.gz: 60ee05fafe8ffd4d9f56c10aa57c9ea5d347b801e94d3651ba2b04b258545a13
3
+ metadata.gz: fcff113ea0549fb291f38ebe6d9ef6d7306bb2c4a444cb01d7f2bd99a21626ef
4
+ data.tar.gz: 33fb15023005e91589fb07fe846748e7c7af9876fae8b517bef98dee41994ebf
5
5
  SHA512:
6
- metadata.gz: bfd4e4526f964ef0ec6f0004b85061b64b86e6885c4e383adae282396b769f50e300de8e5424bd9892b59d5bae499d428be4719c6d8cdab0ec07fd76b64f5f3a
7
- data.tar.gz: d4c2cb6365fd97356977944566aa332772c751061fcf8b1ad4b9e57b6d4eb7f0cd65ee497493d79286b1efb8bf85b897933e635b914e38a8994e419af61c1961
6
+ metadata.gz: 0c0849f4c52e57761a6d43a555b2c70712be764c96dc205ec10805ba983637777e53d1326e1b41b106c4ad7baeae258680e073aec19e47a42f08d9fce2b731f9
7
+ data.tar.gz: 8f1021d0c9e8c7a7ef71f791a83b24948af32b628154671b1dce31ad8cfe2299d5234a278412a45cff9b11fdc81201281ebbdd89ea391a49d7a41664bba14e03
data/README.md CHANGED
@@ -19,6 +19,7 @@ gem extends that same command to also install:
19
19
  | **Invitable** | Invite users by email; blocks sign-in until they accept by choosing their own password |
20
20
  | **MagicLink** (opt-in) | Passwordless magic link sign-in via email — DB-backed, single-use, expiring tokens |
21
21
  | **Ott** (opt-in) | Passwordless sign-in via an emailed 6-digit one-time code — replaces the password sign-in form |
22
+ | **Passkey** (opt-in) | WebAuthn passkey sign-in alongside the password form — usernameless, no email required |
22
23
 
23
24
  Everything is **generated into your app** as plain, readable code — controllers, views, mailers,
24
25
  migrations, and one model concern per feature. There is no runtime dependency on this gem: after
@@ -57,12 +58,13 @@ Available flags: `--skip-confirmable`, `--skip-recoverable`, `--skip-registerabl
57
58
  `--skip-lockable`, `--skip-invitable`, and `--reconfirmable` (Confirmable: postpone email address
58
59
  changes until the new address is confirmed, via an `unconfirmed_email` column).
59
60
 
60
- MagicLink and Ott are **opt-in** features — they change the sign-in UX, so you have to ask for
61
- them:
61
+ MagicLink, Ott, and Passkey are **opt-in** features — they change the sign-in UX, so you have to
62
+ ask for them:
62
63
 
63
64
  ```sh
64
65
  bin/rails generate authentication --magic-link
65
66
  bin/rails generate authentication --ott
67
+ bin/rails generate authentication --passkey
66
68
  ```
67
69
 
68
70
  MagicLink adds a "Sign in with magic link" link to the sign-in page, leading to an email-only
@@ -79,8 +81,16 @@ and are voided after 5 wrong entries (`OttConcern::OTT_MAX_ATTEMPTS`). The passw
79
81
  (SessionsController#create, Recoverable, Registerable) stays generated and functional — only the
80
82
  sign-in UI changes.
81
83
 
82
- Both flows honor the other enabled features: locked, unconfirmed, or invitation-pending accounts
83
- still can't sign in, and Trackable records the attempt.
84
+ Passkey adds a "Sign in with a passkey" button to the sign-in page (no email field — the
85
+ browser's own discoverable-credential picker takes its place) plus an authenticated
86
+ `/webauthn_credentials` section where a signed-in user can register and remove passkeys (linked
87
+ from the account page when Registerable is enabled). Unlike every other feature here, this one
88
+ brings a real runtime dependency: the generator adds `gem "webauthn"` to your Gemfile for you (with
89
+ a reminder to run `bundle install`), since ceremony verification is delegated to it. A user can
90
+ register any number of passkeys, each stored as its own row in `webauthn_credentials`.
91
+
92
+ All three flows honor the other enabled features: locked, unconfirmed, or invitation-pending
93
+ accounts still can't sign in, and Trackable records the attempt.
84
94
 
85
95
  Each feature adds a single `include <Feature>Concern` line to `app/models/user.rb`; all of its
86
96
  model behavior lives in `app/models/concerns/<feature>_concern.rb`. Tunables are plain constants in
@@ -89,15 +99,61 @@ the generated concerns — e.g. `TimeoutableConcern::TIMEOUT_IN`, `LockableConce
89
99
  `ValidatableConcern::PASSWORD_LENGTH`, `ValidatableConcern::PASSWORD_MINIMUM_COMPLEXITY`,
90
100
  `OttConcern::OTT_CODE_LENGTH` — edit them there. Concerns keep the model clean.
91
101
 
92
- ### Generated routes
102
+ ### Inviting users
103
+
104
+ Invitable generates a `User.invite!` class method. It takes a **hash of attributes** for the new
105
+ user (not just an email address) plus an optional `invited_by:`:
106
+
107
+ ```ruby
108
+ User.invite!({ email_address: "new@example.com" }, invited_by: Current.user)
109
+
110
+ # Any other attributes your User requires go in the same hash
111
+ User.invite!({ email_address: "new@example.com", name: "Ada Lovelace" }, invited_by: Current.user)
112
+ ```
113
+
114
+ `invite!` accepts symbol or string keys, or permitted `ActionController::Parameters`. It
115
+ returns the user either way, so check `persisted?` and `errors` to see whether it worked.
116
+
117
+ - **New email address**: the user is built from the attributes and gets a random temporary
118
+ password, a fresh invitation token, and `invited_by`, then is saved. On success the invitation
119
+ email is sent and the inviter's `invitations_count` goes up. On a validation failure it returns
120
+ the unsaved user with `errors` and sends nothing.
121
+ - **Existing pending invitee**: the token is refreshed and the invitation re-sent. The other
122
+ attributes are ignored.
123
+ - **Existing user who isn't a pending invitee**: the user is returned untouched, the other
124
+ attributes are ignored, and no email is sent.
93
125
 
126
+ Any `password` in the hash is overwritten with the temporary one, because invitees choose their
127
+ own password when they accept. Everything else is mass-assigned, so pass only attributes you've
128
+ permitted. Never pass raw `params`.
129
+
130
+ **Upgrading from the email-only signature.** Earlier versions took the email address as a string,
131
+ `User.invite!(email_address, invited_by: ...)`. That form no longer works, so wrap the address in
132
+ a hash: `User.invite!({ email_address: email_address }, invited_by: ...)`.
133
+
134
+ **Requiring more than an email address.** The generated `InvitationsController` passes
135
+ `invitation_params` (`params.permit(:email_address)`) to `invite!`. To collect more fields, add
136
+ them in three places: the `permit` list, the inputs in `app/views/invitations/new.html.erb`, and
137
+ the validations on your model:
138
+
139
+ ```ruby
140
+ # app/controllers/invitations_controller.rb
141
+ def invitation_params
142
+ params.permit(:email_address, :name)
143
+ end
94
144
  ```
145
+
146
+ ### Generated routes
147
+
148
+ ```ruby
95
149
  resource :registration, only: %i[ new create edit update destroy ]
96
150
  resources :confirmations, only: %i[ new create show ], param: :token
97
151
  resources :unlocks, only: %i[ new create show ], param: :token
98
152
  resources :invitations, only: %i[ new create edit update ], param: :token
99
153
  resources :magic_links, only: %i[ new create show ], param: :token # with --magic-link
100
154
  resource :ott, only: %i[ create edit update ] # with --ott
155
+ resources :webauthn_credentials, only: %i[ index new create destroy ] # with --passkey
156
+ resource :passkey_session, only: %i[ create ] # with --passkey
101
157
  resource :session # from the base generator
102
158
  resources :passwords, param: :token # from the base generator
103
159
  ```
@@ -114,10 +170,6 @@ resources :passwords, param: :token # from the base generator
114
170
  sessions view with versions tailored to the features you selected. Run the generator once, up
115
171
  front — re-running it after you've customized those files will prompt to overwrite them.
116
172
 
117
- ## Future Plans
118
-
119
- - Passkey
120
-
121
173
  ## Development
122
174
 
123
175
  ```sh
@@ -131,6 +183,53 @@ bundle exec rspec spec/generators/lockable_spec.rb:12 # a single example
131
183
  `spec/dummy` is generated (and gitignored), never hand-maintained, so the request specs always
132
184
  exercise exactly what the templates produce.
133
185
 
186
+ To run the app using the dummy app generated in `spec/dummy`, run `bin/dev`
187
+
188
+ ## Appraisal
189
+
190
+ Install Appraisal dependencies or when a new Appraisal is added, generate the .gemfile
191
+
192
+ ```sh
193
+ bundle exec appraisal generate-install
194
+ ```
195
+
196
+ Run tests with Appraisal
197
+
198
+ ```sh
199
+ bin/appraisal
200
+ ```
201
+
202
+ Run a specific Appraisal test
203
+
204
+ ```sh
205
+ # rails-8.0-json-2
206
+ bundle exec appraisal rails-8.0-json-2 rake spec:generators
207
+ bundle exec appraisal rails-8.0-json-2 rake dummy:prepare
208
+ bundle exec appraisal rails-8.0-json-2 rake spec:requests
209
+ # rails-8.0-json-3
210
+ bundle exec appraisal rails-8.0-json-3 rake spec:generators
211
+ bundle exec appraisal rails-8.0-json-3 rake dummy:prepare
212
+ bundle exec appraisal rails-8.0-json-3 rake spec:requests
213
+ # rails-8.1-json-2
214
+ bundle exec appraisal rails-8.1-json-2 rake spec:generators
215
+ bundle exec appraisal rails-8.1-json-2 rake dummy:prepare
216
+ bundle exec appraisal rails-8.1-json-2 rake spec:requests
217
+ # rails-8.1-json-3
218
+ bundle exec appraisal rails-8.1-json-3 rake spec:generators
219
+ bundle exec appraisal rails-8.1-json-3 rake dummy:prepare
220
+ bundle exec appraisal rails-8.1-json-3 rake spec:requests
221
+ ```
222
+
223
+ ## json 3.0 gem compatibility
224
+
225
+ `json` 3.0 gem removed the `quirks_mode:` keyword and made `JSON.generate`/`JSON.parse`'s second argument keyword-only. Rails' `ActiveSupport::JSON` still calls both with `quirks_mode: true` positionally, so every
226
+ cookie/session read or write throws `ArgumentError` under json 3. `bin/prepare_dummy`
227
+ writes `spec/dummy/config/initializers/json_quirks_mode_compat.rb`, which prepends a shim onto
228
+ `JSON.generate`/`.parse` that strips `quirks_mode` and re-passes remaining options via `**opts`
229
+ (so it works whether the resolved `JSON.parse` still takes a positional options hash, json 2.x,
230
+ or keywords only, json 3.x). It no-ops under json 2.x (`Gem::Version.new(::JSON::VERSION) >=
231
+ Gem::Version.new("3.0")` gates it) and is safe to delete once Rails or json fixes this upstream.
232
+
134
233
  ## License
135
234
 
136
235
  MIT
@@ -13,6 +13,7 @@ require_relative "features/lockable"
13
13
  require_relative "features/invitable"
14
14
  require_relative "features/magic_link"
15
15
  require_relative "features/ott"
16
+ require_relative "features/passkey"
16
17
 
17
18
  module RailsAuthentication
18
19
  module Generators
@@ -46,6 +47,7 @@ module RailsAuthentication
46
47
  include Features::Invitable
47
48
  include Features::MagicLink
48
49
  include Features::Ott
50
+ include Features::Passkey
49
51
 
50
52
  source_root File.expand_path("templates", __dir__)
51
53
 
@@ -68,6 +70,11 @@ module RailsAuthentication
68
70
  class_option :ott, type: :boolean, default: false,
69
71
  desc: "Add one-time token (emailed 6-digit code) sign-in (opt-in)"
70
72
 
73
+ # Also opt-in: WebAuthn passkey sign-in alongside password sign-in.
74
+ # Requires the host app to add the `webauthn` gem itself.
75
+ class_option :passkey, type: :boolean, default: false,
76
+ desc: "Add WebAuthn passkey sign-in (opt-in; requires the webauthn gem)"
77
+
71
78
  def install_base_authentication
72
79
  say "Running Rails' built-in authentication generator", :green
73
80
  Rails::Generators.invoke("rails:authentication", [], behavior: behavior, destination_root: destination_root)
@@ -117,6 +124,10 @@ module RailsAuthentication
117
124
  generate_ott if ott?
118
125
  end
119
126
 
127
+ def install_passkey
128
+ generate_passkey if passkey?
129
+ end
130
+
120
131
  # Runs after every feature install so the blank line separates the concern
121
132
  # includes (if any) from the rest of the class body, no matter which features
122
133
  # are enabled.
@@ -151,6 +162,10 @@ module RailsAuthentication
151
162
  options[:ott]
152
163
  end
153
164
 
165
+ def passkey?
166
+ options[:passkey]
167
+ end
168
+
154
169
  def include_concern_in_user(concern)
155
170
  inject_into_class "app/models/user.rb", "User", " include #{concern}\n"
156
171
  end
@@ -5,20 +5,21 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Confirmable
7
7
  private
8
- def generate_confirmable
9
- template "app/models/concerns/confirmable_concern.rb"
10
- include_concern_in_user "ConfirmableConcern"
11
- migration_template "db/migrate/add_confirmable_to_users.rb", "db/migrate/add_confirmable_to_users.rb"
12
- template "app/controllers/confirmations_controller.rb"
13
- template "app/views/confirmations/new.html.erb"
14
- route "resources :confirmations, only: %i[ new create show ], param: :token"
15
8
 
16
- if defined?(ActionMailer::Railtie)
17
- template "app/mailers/confirmations_mailer.rb"
18
- template "app/views/confirmations_mailer/confirmation_instructions.html.erb"
19
- template "app/views/confirmations_mailer/confirmation_instructions.text.erb"
20
- end
9
+ def generate_confirmable
10
+ template "app/models/concerns/confirmable_concern.rb"
11
+ include_concern_in_user "ConfirmableConcern"
12
+ migration_template "db/migrate/add_confirmable_to_users.rb", "db/migrate/add_confirmable_to_users.rb"
13
+ template "app/controllers/confirmations_controller.rb"
14
+ template "app/views/confirmations/new.html.erb"
15
+ route "resources :confirmations, only: %i[ new create show ], param: :token"
16
+
17
+ if defined?(ActionMailer::Railtie)
18
+ template "app/mailers/confirmations_mailer.rb"
19
+ template "app/views/confirmations_mailer/confirmation_instructions.html.erb"
20
+ template "app/views/confirmations_mailer/confirmation_instructions.text.erb"
21
21
  end
22
+ end
22
23
  end
23
24
  end
24
25
  end
@@ -5,21 +5,22 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Invitable
7
7
  private
8
- def generate_invitable
9
- template "app/models/concerns/invitable_concern.rb"
10
- include_concern_in_user "InvitableConcern"
11
- migration_template "db/migrate/add_invitable_to_users.rb", "db/migrate/add_invitable_to_users.rb"
12
- template "app/controllers/invitations_controller.rb"
13
- template "app/views/invitations/new.html.erb"
14
- template "app/views/invitations/edit.html.erb"
15
- route "resources :invitations, only: %i[ new create edit update ], param: :token"
16
8
 
17
- if defined?(ActionMailer::Railtie)
18
- template "app/mailers/invitations_mailer.rb"
19
- template "app/views/invitations_mailer/invitation_instructions.html.erb"
20
- template "app/views/invitations_mailer/invitation_instructions.text.erb"
21
- end
9
+ def generate_invitable
10
+ template "app/models/concerns/invitable_concern.rb"
11
+ include_concern_in_user "InvitableConcern"
12
+ migration_template "db/migrate/add_invitable_to_users.rb", "db/migrate/add_invitable_to_users.rb"
13
+ template "app/controllers/invitations_controller.rb"
14
+ template "app/views/invitations/new.html.erb"
15
+ template "app/views/invitations/edit.html.erb"
16
+ route "resources :invitations, only: %i[ new create edit update ], param: :token"
17
+
18
+ if defined?(ActionMailer::Railtie)
19
+ template "app/mailers/invitations_mailer.rb"
20
+ template "app/views/invitations_mailer/invitation_instructions.html.erb"
21
+ template "app/views/invitations_mailer/invitation_instructions.text.erb"
22
22
  end
23
+ end
23
24
  end
24
25
  end
25
26
  end
@@ -5,20 +5,21 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Lockable
7
7
  private
8
- def generate_lockable
9
- template "app/models/concerns/lockable_concern.rb"
10
- include_concern_in_user "LockableConcern"
11
- migration_template "db/migrate/add_lockable_to_users.rb", "db/migrate/add_lockable_to_users.rb"
12
- template "app/controllers/unlocks_controller.rb"
13
- template "app/views/unlocks/new.html.erb"
14
- route "resources :unlocks, only: %i[ new create show ], param: :token"
15
8
 
16
- if defined?(ActionMailer::Railtie)
17
- template "app/mailers/unlocks_mailer.rb"
18
- template "app/views/unlocks_mailer/unlock_instructions.html.erb"
19
- template "app/views/unlocks_mailer/unlock_instructions.text.erb"
20
- end
9
+ def generate_lockable
10
+ template "app/models/concerns/lockable_concern.rb"
11
+ include_concern_in_user "LockableConcern"
12
+ migration_template "db/migrate/add_lockable_to_users.rb", "db/migrate/add_lockable_to_users.rb"
13
+ template "app/controllers/unlocks_controller.rb"
14
+ template "app/views/unlocks/new.html.erb"
15
+ route "resources :unlocks, only: %i[ new create show ], param: :token"
16
+
17
+ if defined?(ActionMailer::Railtie)
18
+ template "app/mailers/unlocks_mailer.rb"
19
+ template "app/views/unlocks_mailer/unlock_instructions.html.erb"
20
+ template "app/views/unlocks_mailer/unlock_instructions.text.erb"
21
21
  end
22
+ end
22
23
  end
23
24
  end
24
25
  end
@@ -8,20 +8,21 @@ module RailsAuthentication
8
8
  # Recoverable rework.
9
9
  module MagicLink
10
10
  private
11
- def generate_magic_link
12
- template "app/models/concerns/magic_link_concern.rb"
13
- include_concern_in_user "MagicLinkConcern"
14
- migration_template "db/migrate/add_magic_link_to_users.rb", "db/migrate/add_magic_link_to_users.rb"
15
- template "app/controllers/magic_links_controller.rb"
16
- template "app/views/magic_links/new.html.erb"
17
- route "resources :magic_links, only: %i[ new create show ], param: :token"
18
11
 
19
- if defined?(ActionMailer::Railtie)
20
- template "app/mailers/magic_links_mailer.rb"
21
- template "app/views/magic_links_mailer/magic_link.html.erb"
22
- template "app/views/magic_links_mailer/magic_link.text.erb"
23
- end
12
+ def generate_magic_link
13
+ template "app/models/concerns/magic_link_concern.rb"
14
+ include_concern_in_user "MagicLinkConcern"
15
+ migration_template "db/migrate/add_magic_link_to_users.rb", "db/migrate/add_magic_link_to_users.rb"
16
+ template "app/controllers/magic_links_controller.rb"
17
+ template "app/views/magic_links/new.html.erb"
18
+ route "resources :magic_links, only: %i[ new create show ], param: :token"
19
+
20
+ if defined?(ActionMailer::Railtie)
21
+ template "app/mailers/magic_links_mailer.rb"
22
+ template "app/views/magic_links_mailer/magic_link.html.erb"
23
+ template "app/views/magic_links_mailer/magic_link.text.erb"
24
24
  end
25
+ end
25
26
  end
26
27
  end
27
28
  end
@@ -10,20 +10,21 @@ module RailsAuthentication
10
10
  # Registerable, SessionsController#create) is left intact.
11
11
  module Ott
12
12
  private
13
- def generate_ott
14
- template "app/models/concerns/ott_concern.rb"
15
- include_concern_in_user "OttConcern"
16
- migration_template "db/migrate/add_ott_to_users.rb", "db/migrate/add_ott_to_users.rb"
17
- template "app/controllers/otts_controller.rb"
18
- template "app/views/otts/edit.html.erb"
19
- route "resource :ott, only: %i[ create edit update ]"
20
13
 
21
- if defined?(ActionMailer::Railtie)
22
- template "app/mailers/otts_mailer.rb"
23
- template "app/views/otts_mailer/ott.html.erb"
24
- template "app/views/otts_mailer/ott.text.erb"
25
- end
14
+ def generate_ott
15
+ template "app/models/concerns/ott_concern.rb"
16
+ include_concern_in_user "OttConcern"
17
+ migration_template "db/migrate/add_ott_to_users.rb", "db/migrate/add_ott_to_users.rb"
18
+ template "app/controllers/otts_controller.rb"
19
+ template "app/views/otts/edit.html.erb"
20
+ route "resource :ott, only: %i[ create edit update ]"
21
+
22
+ if defined?(ActionMailer::Railtie)
23
+ template "app/mailers/otts_mailer.rb"
24
+ template "app/views/otts_mailer/ott.html.erb"
25
+ template "app/views/otts_mailer/ott.text.erb"
26
26
  end
27
+ end
27
28
  end
28
29
  end
29
30
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAuthentication
4
+ module Generators
5
+ module Features
6
+ # Opt-in (--passkey): WebAuthn-based passkey sign-in, in addition to
7
+ # password sign-in. A user may register any number of passkeys
8
+ # (platform authenticators, security keys); sign-in is usernameless —
9
+ # the browser's discoverable-credential picker stands in for an email
10
+ # field. Ceremony verification is delegated to the `webauthn` gem —
11
+ # unlike everything else this gem generates, that's a real runtime
12
+ # dependency, so it's added straight to the host app's Gemfile.
13
+ module Passkey
14
+ private
15
+
16
+ def generate_passkey
17
+ gem "webauthn", comment: "Verifies WebAuthn passkey registration/authentication ceremonies"
18
+ say "Added the `webauthn` gem to your Gemfile — run `bundle install`.", :yellow
19
+
20
+ template "config/initializers/webauthn.rb"
21
+ template "app/models/concerns/passkey_concern.rb"
22
+ include_concern_in_user "PasskeyConcern"
23
+ template "app/models/webauthn_credential.rb"
24
+ migration_template "db/migrate/add_webauthn_id_to_users.rb", "db/migrate/add_webauthn_id_to_users.rb"
25
+ migration_template "db/migrate/create_webauthn_credentials.rb", "db/migrate/create_webauthn_credentials.rb"
26
+ template "app/controllers/webauthn_credentials_controller.rb"
27
+ template "app/controllers/passkey_sessions_controller.rb"
28
+ template "app/views/webauthn_credentials/index.html.erb"
29
+ template "app/views/webauthn_credentials/new.html.erb"
30
+ route "resources :webauthn_credentials, only: %i[ index new create destroy ]"
31
+ route "resource :passkey_session, only: %i[ create ], controller: \"passkey_sessions\""
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -5,20 +5,21 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Recoverable
7
7
  private
8
- # Replaces the base generator's stateless generates_token_for password reset
9
- # with DB-backed, revocable tokens.
10
- def generate_recoverable
11
- template "app/models/concerns/recoverable_concern.rb"
12
- include_concern_in_user "RecoverableConcern"
13
- migration_template "db/migrate/add_recoverable_to_users.rb", "db/migrate/add_recoverable_to_users.rb"
14
- template "app/controllers/passwords_controller.rb", force: true
15
8
 
16
- if defined?(ActionMailer::Railtie)
17
- template "app/mailers/passwords_mailer.rb", force: true
18
- template "app/views/passwords_mailer/reset.html.erb", force: true
19
- template "app/views/passwords_mailer/reset.text.erb", force: true
20
- end
9
+ # Replaces the base generator's stateless generates_token_for password reset
10
+ # with DB-backed, revocable tokens.
11
+ def generate_recoverable
12
+ template "app/models/concerns/recoverable_concern.rb"
13
+ include_concern_in_user "RecoverableConcern"
14
+ migration_template "db/migrate/add_recoverable_to_users.rb", "db/migrate/add_recoverable_to_users.rb"
15
+ template "app/controllers/passwords_controller.rb", force: true
16
+
17
+ if defined?(ActionMailer::Railtie)
18
+ template "app/mailers/passwords_mailer.rb", force: true
19
+ template "app/views/passwords_mailer/reset.html.erb", force: true
20
+ template "app/views/passwords_mailer/reset.text.erb", force: true
21
21
  end
22
+ end
22
23
  end
23
24
  end
24
25
  end
@@ -5,12 +5,13 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Registerable
7
7
  private
8
- def generate_registerable
9
- template "app/controllers/registrations_controller.rb"
10
- template "app/views/registrations/new.html.erb"
11
- template "app/views/registrations/edit.html.erb"
12
- route "resource :registration, only: %i[ new create edit update destroy ]"
13
- end
8
+
9
+ def generate_registerable
10
+ template "app/controllers/registrations_controller.rb"
11
+ template "app/views/registrations/new.html.erb"
12
+ template "app/views/registrations/edit.html.erb"
13
+ route "resource :registration, only: %i[ new create edit update destroy ]"
14
+ end
14
15
  end
15
16
  end
16
17
  end
@@ -5,11 +5,12 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Rememberable
7
7
  private
8
- def generate_rememberable
9
- template "app/models/concerns/rememberable_concern.rb"
10
- include_concern_in_user "RememberableConcern"
11
- migration_template "db/migrate/add_rememberable_to_users.rb", "db/migrate/add_rememberable_to_users.rb"
12
- end
8
+
9
+ def generate_rememberable
10
+ template "app/models/concerns/rememberable_concern.rb"
11
+ include_concern_in_user "RememberableConcern"
12
+ migration_template "db/migrate/add_rememberable_to_users.rb", "db/migrate/add_rememberable_to_users.rb"
13
+ end
13
14
  end
14
15
  end
15
16
  end
@@ -5,12 +5,13 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Timeoutable
7
7
  private
8
- # No migration: inactivity is measured against sessions.updated_at, which the
9
- # base generator's sessions table already has.
10
- def generate_timeoutable
11
- template "app/models/concerns/timeoutable_concern.rb"
12
- include_concern_in_user "TimeoutableConcern"
13
- end
8
+
9
+ # No migration: inactivity is measured against sessions.updated_at, which the
10
+ # base generator's sessions table already has.
11
+ def generate_timeoutable
12
+ template "app/models/concerns/timeoutable_concern.rb"
13
+ include_concern_in_user "TimeoutableConcern"
14
+ end
14
15
  end
15
16
  end
16
17
  end
@@ -5,12 +5,13 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Trackable
7
7
  private
8
- def generate_trackable
9
- template "app/models/concerns/trackable_concern.rb"
10
- include_concern_in_user "TrackableConcern"
11
- template "app/models/user_auth.rb"
12
- migration_template "db/migrate/create_user_auths.rb", "db/migrate/create_user_auths.rb"
13
- end
8
+
9
+ def generate_trackable
10
+ template "app/models/concerns/trackable_concern.rb"
11
+ include_concern_in_user "TrackableConcern"
12
+ template "app/models/user_auth.rb"
13
+ migration_template "db/migrate/create_user_auths.rb", "db/migrate/create_user_auths.rb"
14
+ end
14
15
  end
15
16
  end
16
17
  end
@@ -5,10 +5,11 @@ module RailsAuthentication
5
5
  module Features
6
6
  module Validatable
7
7
  private
8
- def generate_validatable
9
- template "app/models/concerns/validatable_concern.rb"
10
- include_concern_in_user "ValidatableConcern"
11
- end
8
+
9
+ def generate_validatable
10
+ template "app/models/concerns/validatable_concern.rb"
11
+ include_concern_in_user "ValidatableConcern"
12
+ end
12
13
  end
13
14
  end
14
15
  end
@@ -6,7 +6,7 @@ class InvitationsController < ApplicationController
6
6
  end
7
7
 
8
8
  def create
9
- user = User.invite!(params[:email_address], invited_by: Current.user)
9
+ user = User.invite!(invitation_params, invited_by: Current.user)
10
10
 
11
11
  if user.persisted?
12
12
  redirect_to new_invitation_path, notice: "Invitation sent to #{user.email_address}."
@@ -29,6 +29,11 @@ class InvitationsController < ApplicationController
29
29
 
30
30
  private
31
31
 
32
+ # Add any other attributes your User requires (e.g. :name) here and to the invitation form.
33
+ def invitation_params
34
+ params.permit(:email_address)
35
+ end
36
+
32
37
  def set_user_by_token
33
38
  @user = User.find_by_valid_invitation_token(params[:token])
34
39
  redirect_to new_session_path, alert: "Invitation link is invalid or has expired." unless @user
@@ -0,0 +1,68 @@
1
+ class PasskeySessionsController < ApplicationController
2
+ allow_unauthenticated_access
3
+ rate_limit to: 10, within: 3.minutes, only: :create, with: -> { render json: { error: "Try again later." }, status: :too_many_requests }
4
+
5
+ def create
6
+ webauthn_credential = WebAuthn::Credential.from_get(params)
7
+ stored_credential = WebauthnCredential.find_by(external_id: webauthn_credential.id)
8
+
9
+ if stored_credential.nil?
10
+ return render json: { error: "Passkey not recognized." }, status: :unprocessable_content
11
+ end
12
+
13
+ begin
14
+ webauthn_credential.verify(
15
+ session[:webauthn_authentication_challenge],
16
+ public_key: stored_credential.public_key,
17
+ sign_count: stored_credential.sign_count
18
+ )
19
+ rescue WebAuthn::Error
20
+ return render json: { error: "Passkey verification failed." }, status: :unprocessable_content
21
+ end
22
+
23
+ stored_credential.update!(sign_count: webauthn_credential.sign_count)
24
+ user = stored_credential.user
25
+ session.delete(:webauthn_authentication_challenge)
26
+
27
+ <% if lockable? -%>
28
+ if user.locked?
29
+ <% if trackable? -%>
30
+ record_authentication_attempt(user, success: false, failure_reason: "locked")
31
+ <% end -%>
32
+ return render json: { error: "Your account is locked. Check your email for unlock instructions." }, status: :unprocessable_content
33
+ end
34
+ <% end -%>
35
+ <% if confirmable? -%>
36
+ unless user.confirmed?
37
+ <% if trackable? -%>
38
+ record_authentication_attempt(user, success: false, failure_reason: "unconfirmed")
39
+ <% end -%>
40
+ return render json: { error: "You must confirm your email address before signing in." }, status: :unprocessable_content
41
+ end
42
+ <% end -%>
43
+ <% if invitable? -%>
44
+ if user.invitation_pending?
45
+ <% if trackable? -%>
46
+ record_authentication_attempt(user, success: false, failure_reason: "invitation_pending")
47
+ <% end -%>
48
+ return render json: { error: "You must accept your invitation before signing in." }, status: :unprocessable_content
49
+ end
50
+ <% end -%>
51
+ <% if lockable? -%>
52
+ user.reset_failed_attempts!
53
+ <% end -%>
54
+ <% if trackable? -%>
55
+ record_authentication_attempt(user, success: true)
56
+ <% end -%>
57
+ start_new_session_for user
58
+ render json: { redirect_to: after_authentication_url }
59
+ end
60
+ <% if trackable? -%>
61
+
62
+ private
63
+
64
+ def record_authentication_attempt(user, success:, failure_reason: nil)
65
+ UserAuth.record(user, request, success: success, failure_reason: failure_reason)
66
+ end
67
+ <% end -%>
68
+ end
@@ -3,6 +3,10 @@ class SessionsController < ApplicationController
3
3
  rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_session_path, alert: "Try again later." }
4
4
 
5
5
  def new
6
+ <% if passkey? -%>
7
+ @webauthn_credential_options = WebAuthn::Credential.options_for_get(user_verification: "required")
8
+ session[:webauthn_authentication_challenge] = @webauthn_credential_options.challenge
9
+ <% end -%>
6
10
  end
7
11
 
8
12
  def create
@@ -0,0 +1,43 @@
1
+ class WebauthnCredentialsController < ApplicationController
2
+ before_action :set_webauthn_credential, only: :destroy
3
+
4
+ def index
5
+ @webauthn_credentials = current_user.webauthn_credentials.order(:created_at)
6
+ end
7
+
8
+ def new
9
+ @webauthn_credential_options = WebAuthn::Credential.options_for_create(
10
+ user: { id: current_user.webauthn_id, name: current_user.email_address },
11
+ exclude: current_user.webauthn_credentials.pluck(:external_id)
12
+ )
13
+ session[:webauthn_registration_challenge] = @webauthn_credential_options.challenge
14
+ end
15
+
16
+ def create
17
+ webauthn_credential = WebAuthn::Credential.from_create(params)
18
+ webauthn_credential.verify(session[:webauthn_registration_challenge])
19
+
20
+ current_user.webauthn_credentials.create!(
21
+ external_id: webauthn_credential.id,
22
+ public_key: webauthn_credential.public_key,
23
+ sign_count: webauthn_credential.sign_count,
24
+ nickname: params[:nickname].presence || "Passkey"
25
+ )
26
+ session.delete(:webauthn_registration_challenge)
27
+
28
+ render json: { redirect_to: webauthn_credentials_path }
29
+ rescue WebAuthn::Error => e
30
+ render json: { error: e.message }, status: :unprocessable_content
31
+ end
32
+
33
+ def destroy
34
+ @webauthn_credential.destroy
35
+ redirect_to webauthn_credentials_path, notice: "Passkey removed."
36
+ end
37
+
38
+ private
39
+
40
+ def set_webauthn_credential
41
+ @webauthn_credential = current_user.webauthn_credentials.find(params[:id])
42
+ end
43
+ end
@@ -47,28 +47,29 @@ module ConfirmableConcern
47
47
  def generate_confirmation_token!
48
48
  update!(confirmation_token: generate_confirmable_token, confirmation_sent_at: Time.current)
49
49
  end
50
+
51
+ protected
52
+
53
+ def generate_confirmable_token
54
+ SecureRandom.urlsafe_base64(32)
55
+ end
50
56
  <% if reconfirmable? -%>
51
57
 
52
58
  private
53
- def postponing_email_address_change?
54
- !@confirming && email_address_changed? && confirmed?
55
- end
56
-
57
- def postpone_email_address_change
58
- self.unconfirmed_email = email_address
59
- self.email_address = email_address_in_database
60
- self.confirmation_token = generate_confirmable_token
61
- self.confirmation_sent_at = Time.current
62
- end
63
59
 
64
- def deliver_reconfirmation_instructions
65
- ConfirmationsMailer.confirmation_instructions(self).deliver_later
66
- end
67
- <% end -%>
60
+ def postponing_email_address_change?
61
+ !@confirming && email_address_changed? && confirmed?
62
+ end
68
63
 
69
- protected
64
+ def postpone_email_address_change
65
+ self.unconfirmed_email = email_address
66
+ self.email_address = email_address_in_database
67
+ self.confirmation_token = generate_confirmable_token
68
+ self.confirmation_sent_at = Time.current
69
+ end
70
70
 
71
- def generate_confirmable_token
72
- SecureRandom.urlsafe_base64(32)
71
+ def deliver_reconfirmation_instructions
72
+ ConfirmationsMailer.confirmation_instructions(self).deliver_later
73
73
  end
74
+ <% end -%>
74
75
  end
@@ -8,8 +8,9 @@ module InvitableConcern
8
8
  end
9
9
 
10
10
  class_methods do
11
- def invite!(email_address, invited_by: nil)
12
- user = find_by(email_address: email_address)
11
+ def invite!(attributes, invited_by: nil)
12
+ attributes = attributes.to_h.symbolize_keys
13
+ user = find_by(email_address: attributes[:email_address])
13
14
 
14
15
  if user
15
16
  # Re-inviting an existing pending invitee refreshes their token; anyone else
@@ -23,8 +24,8 @@ module InvitableConcern
23
24
  return user
24
25
  end
25
26
 
26
- user = new(
27
- email_address: email_address,
27
+ user = new(attributes)
28
+ user.assign_attributes(
28
29
  password: generate_temporary_password,
29
30
  invited_by: invited_by,
30
31
  invitation_token: generate_invitable_token,
@@ -0,0 +1,19 @@
1
+ module PasskeyConcern
2
+ extend ActiveSupport::Concern
3
+
4
+ included do
5
+ has_many :webauthn_credentials, dependent: :destroy
6
+
7
+ before_validation :ensure_webauthn_id, on: :create
8
+ end
9
+
10
+ def passkeys?
11
+ webauthn_credentials.exists?
12
+ end
13
+
14
+ private
15
+
16
+ def ensure_webauthn_id
17
+ self.webauthn_id ||= WebAuthn.generate_user_id
18
+ end
19
+ end
@@ -0,0 +1,6 @@
1
+ class WebauthnCredential < ApplicationRecord
2
+ belongs_to :user
3
+
4
+ validates :external_id, presence: true, uniqueness: true
5
+ validates :public_key, presence: true
6
+ end
@@ -19,4 +19,8 @@
19
19
  <%% end %>
20
20
  <br>
21
21
 
22
+ <% if passkey? -%>
23
+ <%%= link_to "Manage your passkeys", webauthn_credentials_path %>
24
+ <br><br>
25
+ <% end -%>
22
26
  <%%= button_to "Delete my account", registration_path, method: :delete, data: { turbo_confirm: "Are you sure?" } %>
@@ -30,6 +30,71 @@
30
30
  <% if magic_link? -%>
31
31
  <br><%%= link_to "Sign in with magic link", new_magic_link_path %>
32
32
  <% end -%>
33
+ <% if passkey? -%>
34
+ <br><%%= button_tag "Sign in with a passkey", type: "button", id: "passkey-sign-in-button" %>
35
+ <%%= tag.span "", id: "passkey-sign-in-error", style: "color:red" %>
36
+
37
+ <%%= javascript_tag do %>
38
+ (function () {
39
+ function bufferEncode(value) {
40
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
41
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
42
+ }
43
+
44
+ function bufferDecode(value) {
45
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
46
+ const raw = window.atob(padded);
47
+ const buffer = new Uint8Array(raw.length);
48
+ for (let i = 0; i < raw.length; i++) buffer[i] = raw.charCodeAt(i);
49
+ return buffer.buffer;
50
+ }
51
+
52
+ document.getElementById("passkey-sign-in-button").addEventListener("click", async function () {
53
+ const options = <%%= raw @webauthn_credential_options.to_json %>;
54
+ options.challenge = bufferDecode(options.challenge);
55
+ if (options.allowCredentials) {
56
+ options.allowCredentials = options.allowCredentials.map(function (cred) {
57
+ return Object.assign({}, cred, { id: bufferDecode(cred.id) });
58
+ });
59
+ }
60
+
61
+ let credential;
62
+ try {
63
+ credential = await navigator.credentials.get({ publicKey: options });
64
+ } catch (error) {
65
+ document.getElementById("passkey-sign-in-error").textContent = error.message;
66
+ return;
67
+ }
68
+
69
+ const response = await fetch("<%%= passkey_session_path %>", {
70
+ method: "POST",
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
74
+ },
75
+ body: JSON.stringify({
76
+ id: credential.id,
77
+ rawId: bufferEncode(credential.rawId),
78
+ type: credential.type,
79
+ response: {
80
+ clientDataJSON: bufferEncode(credential.response.clientDataJSON),
81
+ authenticatorData: bufferEncode(credential.response.authenticatorData),
82
+ signature: bufferEncode(credential.response.signature),
83
+ userHandle: credential.response.userHandle ? bufferEncode(credential.response.userHandle) : null
84
+ }
85
+ })
86
+ });
87
+
88
+ const result = await response.json();
89
+ if (response.ok) {
90
+ window.location.href = result.redirect_to;
91
+ } else {
92
+ document.getElementById("passkey-sign-in-error").textContent = result.error || "Could not sign in with passkey.";
93
+ }
94
+ });
95
+ })();
96
+ <%% end %>
97
+ <% end -%>
33
98
  <br><%%= link_to "Forgot password?", new_password_path %>
34
99
  <% if registerable? -%>
35
100
  <br><%%= link_to "Sign up", new_registration_path %>
@@ -0,0 +1,19 @@
1
+ <h1>Your passkeys</h1>
2
+
3
+ <%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
4
+ <%%= tag.div(flash[:notice], style: "color:green") if flash[:notice] %>
5
+
6
+ <%% if @webauthn_credentials.empty? %>
7
+ <p>You haven't added a passkey yet.</p>
8
+ <%% else %>
9
+ <ul>
10
+ <%% @webauthn_credentials.each do |credential| %>
11
+ <li>
12
+ <%%= credential.nickname %> &mdash; added <%%= credential.created_at.to_date %>
13
+ <%%= button_to "Remove", webauthn_credential_path(credential), method: :delete, data: { turbo_confirm: "Remove this passkey?" } %>
14
+ </li>
15
+ <%% end %>
16
+ </ul>
17
+ <%% end %>
18
+
19
+ <br><%%= link_to "Add a passkey", new_webauthn_credential_path %>
@@ -0,0 +1,71 @@
1
+ <h1>Add a passkey</h1>
2
+
3
+ <%%= tag.div(flash[:alert], style: "color:red") if flash[:alert] %>
4
+ <%%= tag.span "", id: "passkey-error", style: "color:red" %>
5
+
6
+ <%%= label_tag :passkey_nickname, "Name this passkey (e.g. \"MacBook\" or \"iPhone\")" %>
7
+ <%%= text_field_tag :passkey_nickname, nil, placeholder: "Passkey" %><br><br>
8
+
9
+ <%%= button_tag "Create passkey", type: "button", id: "create-passkey-button" %>
10
+
11
+ <%%= javascript_tag do %>
12
+ (function () {
13
+ function bufferEncode(value) {
14
+ return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
15
+ .replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
16
+ }
17
+
18
+ function bufferDecode(value) {
19
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/");
20
+ const raw = window.atob(padded);
21
+ const buffer = new Uint8Array(raw.length);
22
+ for (let i = 0; i < raw.length; i++) buffer[i] = raw.charCodeAt(i);
23
+ return buffer.buffer;
24
+ }
25
+
26
+ document.getElementById("create-passkey-button").addEventListener("click", async function () {
27
+ const options = <%%= raw @webauthn_credential_options.to_json %>;
28
+
29
+ options.challenge = bufferDecode(options.challenge);
30
+ options.user.id = bufferDecode(options.user.id);
31
+ if (options.excludeCredentials) {
32
+ options.excludeCredentials = options.excludeCredentials.map(function (cred) {
33
+ return Object.assign({}, cred, { id: bufferDecode(cred.id) });
34
+ });
35
+ }
36
+
37
+ let credential;
38
+ try {
39
+ credential = await navigator.credentials.create({ publicKey: options });
40
+ } catch (error) {
41
+ document.getElementById("passkey-error").textContent = error.message;
42
+ return;
43
+ }
44
+
45
+ const response = await fetch("<%%= webauthn_credentials_path %>", {
46
+ method: "POST",
47
+ headers: {
48
+ "Content-Type": "application/json",
49
+ "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content
50
+ },
51
+ body: JSON.stringify({
52
+ id: credential.id,
53
+ rawId: bufferEncode(credential.rawId),
54
+ type: credential.type,
55
+ nickname: document.getElementById("passkey_nickname").value,
56
+ response: {
57
+ clientDataJSON: bufferEncode(credential.response.clientDataJSON),
58
+ attestationObject: bufferEncode(credential.response.attestationObject)
59
+ }
60
+ })
61
+ });
62
+
63
+ const result = await response.json();
64
+ if (response.ok) {
65
+ window.location.href = result.redirect_to;
66
+ } else {
67
+ document.getElementById("passkey-error").textContent = result.error || "Could not save passkey.";
68
+ }
69
+ });
70
+ })();
71
+ <%% end %>
@@ -0,0 +1,11 @@
1
+ WebAuthn.configure do |config|
2
+ # This value needs to match `window.location.hostname` where the API is called from in
3
+ # your client-side (front-end) code.
4
+ # config.rp_id = "example.com"
5
+
6
+ # This value needs to match `window.location.origin` where the API is called from in
7
+ # your client-side (front-end) code.
8
+ # config.allowed_origins = ["https://example.com"]
9
+
10
+ config.rp_name = Rails.application.class.module_parent_name
11
+ end
@@ -0,0 +1,7 @@
1
+ class AddWebauthnIdToUsers < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ add_column :users, :webauthn_id, :string
4
+
5
+ add_index :users, :webauthn_id, unique: true
6
+ end
7
+ end
@@ -0,0 +1,15 @@
1
+ class CreateWebauthnCredentials < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :webauthn_credentials do |t|
4
+ t.references :user, null: false, foreign_key: true
5
+ t.string :external_id, null: false
6
+ t.string :public_key, null: false
7
+ t.string :nickname
8
+ t.bigint :sign_count, null: false, default: 0
9
+
10
+ t.timestamps
11
+ end
12
+
13
+ add_index :webauthn_credentials, :external_id, unique: true
14
+ end
15
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsAuthentication
4
- VERSION = "0.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_authentication
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Freerksen
@@ -39,6 +39,7 @@ files:
39
39
  - lib/generators/authentication/features/lockable.rb
40
40
  - lib/generators/authentication/features/magic_link.rb
41
41
  - lib/generators/authentication/features/ott.rb
42
+ - lib/generators/authentication/features/passkey.rb
42
43
  - lib/generators/authentication/features/recoverable.rb
43
44
  - lib/generators/authentication/features/registerable.rb
44
45
  - lib/generators/authentication/features/rememberable.rb
@@ -50,10 +51,12 @@ files:
50
51
  - lib/generators/authentication/templates/app/controllers/invitations_controller.rb.tt
51
52
  - lib/generators/authentication/templates/app/controllers/magic_links_controller.rb.tt
52
53
  - lib/generators/authentication/templates/app/controllers/otts_controller.rb.tt
54
+ - lib/generators/authentication/templates/app/controllers/passkey_sessions_controller.rb.tt
53
55
  - lib/generators/authentication/templates/app/controllers/passwords_controller.rb.tt
54
56
  - lib/generators/authentication/templates/app/controllers/registrations_controller.rb.tt
55
57
  - lib/generators/authentication/templates/app/controllers/sessions_controller.rb.tt
56
58
  - lib/generators/authentication/templates/app/controllers/unlocks_controller.rb.tt
59
+ - lib/generators/authentication/templates/app/controllers/webauthn_credentials_controller.rb.tt
57
60
  - lib/generators/authentication/templates/app/mailers/confirmations_mailer.rb.tt
58
61
  - lib/generators/authentication/templates/app/mailers/invitations_mailer.rb.tt
59
62
  - lib/generators/authentication/templates/app/mailers/magic_links_mailer.rb.tt
@@ -65,12 +68,14 @@ files:
65
68
  - lib/generators/authentication/templates/app/models/concerns/lockable_concern.rb.tt
66
69
  - lib/generators/authentication/templates/app/models/concerns/magic_link_concern.rb.tt
67
70
  - lib/generators/authentication/templates/app/models/concerns/ott_concern.rb.tt
71
+ - lib/generators/authentication/templates/app/models/concerns/passkey_concern.rb.tt
68
72
  - lib/generators/authentication/templates/app/models/concerns/recoverable_concern.rb.tt
69
73
  - lib/generators/authentication/templates/app/models/concerns/rememberable_concern.rb.tt
70
74
  - lib/generators/authentication/templates/app/models/concerns/timeoutable_concern.rb.tt
71
75
  - lib/generators/authentication/templates/app/models/concerns/trackable_concern.rb.tt
72
76
  - lib/generators/authentication/templates/app/models/concerns/validatable_concern.rb.tt
73
77
  - lib/generators/authentication/templates/app/models/user_auth.rb.tt
78
+ - lib/generators/authentication/templates/app/models/webauthn_credential.rb.tt
74
79
  - lib/generators/authentication/templates/app/views/confirmations/new.html.erb.tt
75
80
  - lib/generators/authentication/templates/app/views/confirmations_mailer/confirmation_instructions.html.erb.tt
76
81
  - lib/generators/authentication/templates/app/views/confirmations_mailer/confirmation_instructions.text.erb.tt
@@ -92,6 +97,9 @@ files:
92
97
  - lib/generators/authentication/templates/app/views/unlocks/new.html.erb.tt
93
98
  - lib/generators/authentication/templates/app/views/unlocks_mailer/unlock_instructions.html.erb.tt
94
99
  - lib/generators/authentication/templates/app/views/unlocks_mailer/unlock_instructions.text.erb.tt
100
+ - lib/generators/authentication/templates/app/views/webauthn_credentials/index.html.erb.tt
101
+ - lib/generators/authentication/templates/app/views/webauthn_credentials/new.html.erb.tt
102
+ - lib/generators/authentication/templates/config/initializers/webauthn.rb.tt
95
103
  - lib/generators/authentication/templates/db/migrate/add_confirmable_to_users.rb.tt
96
104
  - lib/generators/authentication/templates/db/migrate/add_invitable_to_users.rb.tt
97
105
  - lib/generators/authentication/templates/db/migrate/add_lockable_to_users.rb.tt
@@ -99,7 +107,9 @@ files:
99
107
  - lib/generators/authentication/templates/db/migrate/add_ott_to_users.rb.tt
100
108
  - lib/generators/authentication/templates/db/migrate/add_recoverable_to_users.rb.tt
101
109
  - lib/generators/authentication/templates/db/migrate/add_rememberable_to_users.rb.tt
110
+ - lib/generators/authentication/templates/db/migrate/add_webauthn_id_to_users.rb.tt
102
111
  - lib/generators/authentication/templates/db/migrate/create_user_auths.rb.tt
112
+ - lib/generators/authentication/templates/db/migrate/create_webauthn_credentials.rb.tt
103
113
  - lib/rails_authentication.rb
104
114
  - lib/rails_authentication/version.rb
105
115
  homepage: https://github.com/dfreerksen/rails_authentication
@@ -122,7 +132,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
122
132
  - !ruby/object:Gem::Version
123
133
  version: '0'
124
134
  requirements: []
125
- rubygems_version: 4.0.16
135
+ rubygems_version: 4.0.20
126
136
  specification_version: 4
127
137
  summary: Devise-style features on top of Rails 8's built-in authentication generator
128
138
  test_files: []