unmagic-passkeys 0.2.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6ddd2eb491d1dbf70760a8fa843d6e09284b44d0031178c9312afebed493a3f5
4
- data.tar.gz: a173df187a85d06e116321cb203966b6613c924771e294e1308504923d707b49
3
+ metadata.gz: 9d45bf4f51fee5d670d79d59a70518f2052417f00ecf549a263f1c23fa7d8aa3
4
+ data.tar.gz: a97fb1cf155def235ff658328850b0bafd5cf99b18c579501e8360839084e224
5
5
  SHA512:
6
- metadata.gz: 2091181c23ecdbc5a1e1d0a1e75e7c28faa030158a452ef77c8b4ac171200ec0964cfeed4ffa69346d60b613fdfd93155108ceb009616d8e941a1659869e87ab
7
- data.tar.gz: 49727646f8936293eaac8f24c26c0fc35e40c8879fb3ae5f19006d3edbf03f6017580071e8da6aa324873501c16c1108bbf91b93f154cab18cf4289026088dc3
6
+ metadata.gz: d00972cba516c64383dd7ab92c2301f276db4dbba1da08b7e9e6d35e878ab73df1ec47bfbfcedd51c92e58386f168317af1a1c8e131977310b2d2adc570a83aa
7
+ data.tar.gz: 74ecb8ef46d774e0f4ccf81ba0a33240d37bcf9b8fc4ccd0ea7fe5dbba76889f3c5b80864c4861ac196306f5c857ba253b29a9dd76345e514584a653593f531a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.3.0] - 2026-07-14
6
+
7
+ ### Added
8
+ - `params:` option on `passkey_registration_button` / `passkey_sign_in_button`,
9
+ rendering extra hidden fields inside the ceremony form (like `button_to`'s
10
+ `params:`). The signup example uses it to carry the email through the
11
+ two-phase ceremony.
12
+ - README "Host wiring" rewritten around copy-pasteable sign-in, signup, and
13
+ passkey-management controllers (Rails 8 `generate authentication`
14
+ vocabulary). The same controllers live in `spec/dummy` and are exercised end
15
+ to end by the request specs, including a two-phase signup ceremony where
16
+ `find_or_create_by!` makes signup and "existing user enrolling a new device"
17
+ the same flow.
18
+
19
+ ### Removed (breaking)
20
+ - The shipped flow controllers and everything that existed to customize them:
21
+ `Unmagic::Passkeys::SessionsController` / `CredentialsController` /
22
+ `ApplicationController`, their views, the `use_unmagic_passkeys` router
23
+ macro, and the `sign_in` / `sign_out` / `current_holder` / `base_controller`
24
+ configuration hooks (plus `Unmagic::Passkeys::ConfigurationError`). They were
25
+ glue around app policy — session handling, redirects, flash copy — behind
26
+ three layers of indirection (hooks, subclassing, view overrides). Copy the
27
+ README's controllers into your app instead; the gem keeps the protocol-shaped
28
+ pieces: the WebAuthn library, `Credential` + `has_passkeys`, the challenge
29
+ endpoint, the `Request` concern, form helpers, JavaScript, and test helpers.
30
+
31
+ ### Changed (breaking)
32
+ - Default `routes_prefix` (the challenge endpoint mount point) changed from
33
+ `/unmagic/passkeys` to `/auth/passkeys`. Set
34
+ `config.routes_prefix = "/unmagic/passkeys"` to keep the old URL.
35
+
36
+ ### Fixed
37
+ - `rails generate unmagic:passkeys:install` crashed on load in 0.2.0
38
+ (`uninitialized constant Unmagic::Passkeys::Rails::Generators`): the nested
39
+ `Unmagic::Passkeys::Rails` module shadowed the framework's `Rails` during the
40
+ generator's constant lookup. The generator now references `::Rails`
41
+ explicitly, with a regression spec covering the shadowed case.
42
+
5
43
  ## [0.2.0] - 2026-06-28
6
44
 
7
45
  ### Added
data/README.md CHANGED
@@ -59,11 +59,17 @@ options = Unmagic::Passkeys.authentication_options # -> pass to n
59
59
  passkey = Unmagic::Passkeys.authenticate(params[:passkey]) # verified credential, or nil
60
60
  ```
61
61
 
62
- The engine mounts a stateless challenge endpoint at `POST /unmagic/passkeys/challenge`
62
+ The engine mounts a stateless challenge endpoint at `POST /auth/passkeys/challenge`
63
63
  (`passkey_challenge_path`), which the JavaScript refreshes before each ceremony.
64
64
 
65
65
  ## Host wiring
66
66
 
67
+ The gem ships the WebAuthn machinery — the ceremonies, the model, the form
68
+ helpers, the JavaScript, and the challenge endpoint. The controllers are
69
+ yours: copy the examples below into your app and edit them to fit. (They use
70
+ the vocabulary of `bin/rails generate authentication`:
71
+ `start_new_session_for`, `terminate_session`, `Current.user`.)
72
+
67
73
  Include the JavaScript once — the form helpers render self-contained web components:
68
74
 
69
75
  ```js
@@ -71,96 +77,173 @@ Include the JavaScript once — the form helpers render self-contained web compo
71
77
  import "unmagic/passkeys"
72
78
  ```
73
79
 
74
- ### Batteries included: `use_unmagic_passkeys`
75
-
76
- This draws the multi-user passkey auth flows and points them at the engine's
77
- base controllers:
80
+ Draw the routes:
78
81
 
79
82
  ```ruby
80
83
  # config/routes.rb
81
- use_unmagic_passkeys
84
+ resource :session, only: %i[new create destroy]
85
+ resource :registration, only: %i[new create]
86
+ resources :passkeys, only: %i[index create destroy]
82
87
  ```
83
88
 
84
- | Flow | Routes | Controller |
85
- | ------------- | ----------------------------------------- | ----------------------------------------- |
86
- | `sessions` | `GET/POST/DELETE /session`, `/session/new`| `Unmagic::Passkeys::SessionsController` |
87
- | `credentials` | `/my/passkeys` (index/create/destroy) | `Unmagic::Passkeys::CredentialsController` |
89
+ ### Sign-in
88
90
 
89
- Sign-in is **usernameless** (discoverable credentials), so the one sign-in page
90
- authenticates any user multi-user out of the box. The controllers are
91
- policy-free; they call **hooks** you configure for the app-specific bits:
91
+ Usernameless (discoverable credentials) one page signs in any user, and
92
+ conditional mediation offers passkeys through autofill:
92
93
 
93
94
  ```ruby
94
- # config/initializers/passkeys.rb
95
- Unmagic::Passkeys.configure do |config|
96
- config.base_controller = "ApplicationController" # so hooks see your helpers
95
+ # app/controllers/sessions_controller.rb
96
+ class SessionsController < ApplicationController
97
+ include Unmagic::Passkeys::Request
97
98
 
98
- config.sign_in { |holder| start_new_session_for(holder) }
99
- config.sign_out { terminate_session }
100
- config.current_holder { Current.user } # for /my/passkeys
101
- end
102
- ```
99
+ allow_unauthenticated_access only: %i[new create]
100
+ rate_limit to: 10, within: 3.minutes, only: :create
103
101
 
104
- Customize by subclassing a base controller and re-pointing the route:
102
+ def new
103
+ @authentication_options = passkey_authentication_options
104
+ end
105
105
 
106
- ```ruby
107
- # config/routes.rb
108
- use_unmagic_passkeys do
109
- controllers sessions: "sessions", credentials: "my/passkeys"
110
- # skip_controllers :credentials
111
- # scope: "accounts" # nest everything under a path
112
- end
106
+ def create
107
+ if credential = Unmagic::Passkeys.authenticate(passkey_authentication_params)
108
+ start_new_session_for credential.holder
109
+ redirect_to after_authentication_url
110
+ else
111
+ redirect_to new_session_path, alert: "That passkey didn't work. Try again."
112
+ end
113
+ end
113
114
 
114
- # app/controllers/sessions_controller.rb
115
- class SessionsController < Unmagic::Passkeys::SessionsController
116
- rate_limit to: 10, within: 3.minutes
117
- private def after_passkey_sign_in_path = after_authentication_url
115
+ def destroy
116
+ terminate_session
117
+ redirect_to new_session_path, status: :see_other
118
+ end
118
119
  end
119
120
  ```
120
121
 
121
- Each base controller exposes overridable methods for redirects and copy
122
- (`after_passkey_sign_in_path`, `after_passkey_sign_in_failure_path`,
123
- `after_passkey_sign_out_path`, `passkey_sign_in_failure_alert`). The default
124
- `sessions/new` and `credentials/index` views are overridable — drop a file at the
125
- same view path.
122
+ ```erb
123
+ <%# app/views/sessions/new.html.erb %>
124
+ <p>Sign in with your passkey.</p>
125
+ <%= passkey_sign_in_button "Sign in with a passkey", session_path,
126
+ options: @authentication_options, mediation: "conditional" %>
127
+ ```
126
128
 
127
- ### Signup is yours
129
+ ### Signup
128
130
 
129
- Account creation is the app's job the engine authenticates holders, it doesn't
130
- own your user schema. Once you've created/identified a holder, register their
131
- first passkey with the same primitives the management flow uses, then sign them
132
- in:
131
+ The ceremony runs in two phases through one `create` action: submitted with an
132
+ email it renders the ceremony page (the email rides along via the form
133
+ helper's `params:` option); submitted with the attestation it verifies, saves
134
+ the passkey, and signs the user in. `find_or_create_by!` makes the two phases
135
+ idempotent — and makes signup and "existing user enrolling a new device" the
136
+ same flow:
133
137
 
134
138
  ```ruby
135
- # Already-known holder (invite, email-first, OAuth, single-user bootstrap, …):
136
- @registration_options = Unmagic::Passkeys.registration_options(holder: user) # -> render for the ceremony
137
- user.passkeys.register(passkey_registration_params) # verify + persist
138
- start_new_session_for(user)
139
+ # app/controllers/registrations_controller.rb
140
+ class RegistrationsController < ApplicationController
141
+ include Unmagic::Passkeys::Request
142
+
143
+ allow_unauthenticated_access
144
+ rate_limit to: 10, within: 3.minutes, only: :create
145
+
146
+ def new
147
+ end
148
+
149
+ # Called twice: with an email it renders the ceremony page; with the
150
+ # attestation it verifies, saves the passkey, and signs the user in.
151
+ def create
152
+ @email = params.expect(registration: [ :email ])[:email]
153
+ user = User.find_or_create_by!(email_address: @email)
154
+ # TODO: verify the visitor owns @email before handing back an existing
155
+ # account (emailed code, invite token, …) — otherwise a stranger can add
156
+ # a passkey to it.
157
+
158
+ if params[:passkey].present?
159
+ user.passkeys.register(passkey_registration_params)
160
+ start_new_session_for user
161
+ redirect_to root_path
162
+ else
163
+ @registration_options = passkey_registration_options(holder: user)
164
+ render :create
165
+ end
166
+ rescue ActiveRecord::RecordInvalid
167
+ redirect_to new_registration_path, alert: "Enter a valid email address."
168
+ rescue Unmagic::Passkeys::WebAuthn::InvalidResponseError
169
+ redirect_to new_registration_path, alert: "That didn't work. Try again."
170
+ end
171
+ end
139
172
  ```
140
173
 
141
- ### À la carte primitives
174
+ ```erb
175
+ <%# app/views/registrations/new.html.erb %>
176
+ <p>Create your account.</p>
177
+ <%= form_with scope: :registration, url: registration_path do |form| %>
178
+ <%= form.email_field :email, required: true, autocomplete: "email" %>
179
+ <%= form.submit "Continue" %>
180
+ <% end %>
181
+ ```
182
+
183
+ ```erb
184
+ <%# app/views/registrations/create.html.erb %>
185
+ <p>Secure your account with a passkey.</p>
186
+ <%= passkey_registration_button "Create a passkey", registration_path,
187
+ options: @registration_options, params: { registration: { email: @email } } %>
188
+ ```
142
189
 
143
- Prefer to own the controllers? Skip the macro and use the building blocks
144
- directly. `Unmagic::Passkeys::Request` provides `passkey_registration_params`,
145
- `passkey_authentication_params`, `passkey_registration_options`,
146
- `passkey_authentication_options`, and sets `Unmagic::Passkeys::WebAuthn::Current`
147
- (host/origin) per request:
190
+ Two things the example leaves to you: **identifier ownership** (nothing above
191
+ proves the visitor owns the email they typed) and **abandoned signups** (the
192
+ user row is created in the email phase; retries reuse it, sweep the
193
+ passkey-less ones if you care).
194
+
195
+ ### Managing passkeys
148
196
 
149
197
  ```ruby
150
- class Sessions::PasskeysController < ApplicationController
198
+ # app/controllers/passkeys_controller.rb
199
+ class PasskeysController < ApplicationController
151
200
  include Unmagic::Passkeys::Request
152
201
 
202
+ def index
203
+ @passkeys = Current.user.passkeys.order(created_at: :desc)
204
+ @registration_options = passkey_registration_options(holder: Current.user)
205
+ end
206
+
153
207
  def create
154
- if credential = Unmagic::Passkeys.authenticate(passkey_authentication_params)
155
- start_new_session_for credential.holder
156
- redirect_to after_authentication_url
157
- else
158
- redirect_to new_session_path, alert: "That passkey didn't work."
159
- end
208
+ Current.user.passkeys.register(passkey_registration_params)
209
+ redirect_to passkeys_path, notice: "Passkey added."
210
+ end
211
+
212
+ def destroy
213
+ Current.user.passkeys.find(params[:id]).destroy
214
+ redirect_to passkeys_path, notice: "Passkey removed."
160
215
  end
161
216
  end
162
217
  ```
163
218
 
219
+ ```erb
220
+ <%# app/views/passkeys/index.html.erb %>
221
+ <ul>
222
+ <% @passkeys.each do |passkey| %>
223
+ <li id="<%= dom_id(passkey) %>">
224
+ <%= passkey.name.presence || "Passkey" %> — added <%= passkey.created_at.to_date.to_fs(:long) %>
225
+ <%= button_to "Remove", passkey_path(passkey), method: :delete %>
226
+ </li>
227
+ <% end %>
228
+ </ul>
229
+ <%= passkey_registration_button "Add a passkey", passkeys_path,
230
+ options: @registration_options %>
231
+ ```
232
+
233
+ These controllers live in `spec/dummy`, where the request specs exercise them
234
+ end to end.
235
+
236
+ ### The building blocks
237
+
238
+ `Unmagic::Passkeys::Request` provides the ceremony strong parameters
239
+ (`passkey_registration_params`, `passkey_authentication_params`), the option
240
+ builders (`passkey_registration_options`, `passkey_authentication_options`),
241
+ and sets `Unmagic::Passkeys::WebAuthn::Current` (host/origin) per request.
242
+ `passkey_sign_in_button` / `passkey_registration_button` render web components
243
+ that refresh the challenge, run the browser ceremony, and submit the result;
244
+ their `params:` option renders extra hidden fields into the ceremony form
245
+ (like `button_to`'s).
246
+
164
247
  ## Configuration
165
248
 
166
249
  Configure the engine in a single block:
@@ -178,7 +261,7 @@ Unmagic::Passkeys.configure do |config|
178
261
  # config.relying_party_name = "Example"
179
262
 
180
263
  # config.parent_class_name = "ApplicationRecord"
181
- # config.routes_prefix = "/unmagic/passkeys" # set in config/application.rb if overriding
264
+ # config.routes_prefix = "/auth/passkeys" # set in config/application.rb if overriding
182
265
  # config.draw_routes = true
183
266
  end
184
267
  ```
@@ -192,12 +275,12 @@ auto-includes it into Rails integration tests:
192
275
  # test/test_helper.rb
193
276
  require "unmagic/passkeys/test/helpers"
194
277
 
195
- # test/controllers/sessions/passkeys_controller_test.rb
278
+ # test/integration/passkey_sign_in_test.rb
196
279
  credential = register_passkey_for(@user)
197
280
  assertion = in_webauthn_context do
198
281
  build_assertion_params(challenge: webauthn_challenge(purpose: "authentication"), credential: credential)
199
282
  end
200
- post session_passkey_path, params: { passkey: assertion }
283
+ post session_path, params: { passkey: assertion }
201
284
  ```
202
285
 
203
286
  For RSpec (or any non-integration test), include it yourself:
@@ -12,7 +12,7 @@
12
12
  #
13
13
  # == Route
14
14
  #
15
- # By default mounted at +/unmagic/passkeys/challenge+ (configurable via
15
+ # By default mounted at +/auth/passkeys/challenge+ (configurable via
16
16
  # +Unmagic::Passkeys.configuration.routes_prefix+).
17
17
  #
18
18
  class Unmagic::Passkeys::ChallengesController < ActionController::Base
@@ -9,7 +9,11 @@ module Unmagic
9
9
  # Installs unmagic-passkeys into a host app: copies the credentials migration
10
10
  # (matching the host's primary-key type), imports the JavaScript, and prints the
11
11
  # remaining controller/route/view wiring to do.
12
- class InstallGenerator < Rails::Generators::Base
12
+ #
13
+ # +Rails+ is referenced with a leading +::+ throughout: inside the
14
+ # Unmagic::Passkeys namespace a nested constant named Rails (0.2.0 had one
15
+ # for the routes macro) shadows the framework and breaks the generator.
16
+ class InstallGenerator < ::Rails::Generators::Base
13
17
  include ActiveRecord::Generators::Migration
14
18
 
15
19
  source_root File.expand_path("templates", __dir__)
@@ -35,7 +39,7 @@ module Unmagic
35
39
 
36
40
  private
37
41
  def key_type
38
- Rails.configuration.generators.options.dig(:active_record, :primary_key_type)
42
+ ::Rails.configuration.generators.options.dig(:active_record, :primary_key_type)
39
43
  end
40
44
 
41
45
  def table_id_option
@@ -14,6 +14,6 @@
14
14
  3. Wire up sign-in and registration controllers, routes and views.
15
15
  See the README "Host wiring" section for copy-paste examples.
16
16
 
17
- The challenge endpoint (POST /unmagic/passkeys/challenge) and the JavaScript
17
+ The challenge endpoint (POST /auth/passkeys/challenge) and the JavaScript
18
18
  web components are already available.
19
19
 
@@ -37,14 +37,9 @@ module Unmagic
37
37
  # +Rails.application.name+ respectively.
38
38
  attr_accessor :relying_party_id, :relying_party_name
39
39
 
40
- # The controller the engine's base controllers inherit from. Inherit from
41
- # the host's +ApplicationController+ so the hook blocks below can call your
42
- # app's session helpers (e.g. +start_new_session_for+).
43
- attr_accessor :base_controller
44
-
45
40
  def initialize
46
41
  @parent_class_name = "ApplicationRecord"
47
- @routes_prefix = "/unmagic/passkeys"
42
+ @routes_prefix = "/auth/passkeys"
48
43
  @draw_routes = true
49
44
  @challenge_url = nil
50
45
  @default_creation_options = {}
@@ -53,41 +48,6 @@ module Unmagic
53
48
  @request_challenge_expiration = 5.minutes
54
49
  @relying_party_id = nil
55
50
  @relying_party_name = nil
56
- @base_controller = "ApplicationController"
57
- @sign_in = nil
58
- @sign_out = nil
59
- @current_holder = nil
60
- end
61
-
62
- # == Controller hooks
63
- #
64
- # Each is a block +instance_exec+'d in the engine's base controllers, so it
65
- # has full access to the request, session, and any helpers your
66
- # +base_controller+ provides. Call with a block to set, without to read.
67
- #
68
- # +sign_in+ turns an authenticated holder into an app session (required to
69
- # use the sign-in flow). +sign_out+ tears it down. +current_holder+ returns
70
- # the signed-in holder (used by the credentials management controller).
71
- #
72
- # Account creation (signup) is the host app's responsibility — once you've
73
- # created/identified a holder, register a passkey for it with
74
- # +holder.passkeys.register(params)+ and call +sign_in+.
75
- #
76
- # Unmagic::Passkeys.configure do |config|
77
- # config.sign_in { |holder| start_new_session_for(holder) }
78
- # config.sign_out { terminate_session }
79
- # config.current_holder { Current.user }
80
- # end
81
- def sign_in(&block)
82
- block ? @sign_in = block : @sign_in
83
- end
84
-
85
- def sign_out(&block)
86
- block ? @sign_out = block : @sign_out
87
- end
88
-
89
- def current_holder(&block)
90
- block ? @current_holder = block : @current_holder
91
51
  end
92
52
  end
93
53
  end
@@ -12,14 +12,9 @@ module Unmagic
12
12
  # Unmagic::Passkeys.configure do |config|
13
13
  # config.default_creation_options = { attestation: :none }
14
14
  # config.default_request_options = { user_verification: :required }
15
- # config.routes_prefix = "/unmagic/passkeys"
15
+ # config.routes_prefix = "/auth/passkeys"
16
16
  # end
17
17
  class Engine < ::Rails::Engine
18
- initializer "unmagic_passkeys.routes_macro" do
19
- require "unmagic/passkeys/rails/routes"
20
- ActionDispatch::Routing::Mapper.include Unmagic::Passkeys::Rails::Routes::Mapper
21
- end
22
-
23
18
  initializer "unmagic_passkeys.routes" do |app|
24
19
  passkey_config = Unmagic::Passkeys.configuration
25
20
 
@@ -12,6 +12,8 @@ module Unmagic::Passkeys::FormHelper
12
12
  # Options:
13
13
  # - +options+: WebAuthn creation options (JSON-serializable hash)
14
14
  # - +challenge_url+: endpoint to refresh the challenge nonce
15
+ # - +params+: extra params submitted with the ceremony (rendered as hidden
16
+ # fields, nested hashes/arrays supported — like +button_to+'s +params+)
15
17
  # - +wrapper+: HTML attributes for the outer web component element
16
18
  # - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
17
19
  # to set the form parameter namespace (default: +:passkey+)
@@ -22,7 +24,7 @@ module Unmagic::Passkeys::FormHelper
22
24
  # - All other options are passed to the +<button>+ tag
23
25
  def passkey_registration_button(name = nil, url = nil, **options, &block)
24
26
  url, name = name, block ? capture(&block) : nil if block_given?
25
- component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
27
+ component_options, form_options, button_options, error_options, extra_params = partition_passkey_options(url, options)
26
28
  error_options[:error][:message] ||= REGISTRATION_ERROR_MESSAGE
27
29
  error_options[:cancellation][:message] ||= REGISTRATION_CANCELLED_MESSAGE
28
30
  error_options[:duplicate][:message] ||= REGISTRATION_DUPLICATE_MESSAGE
@@ -31,6 +33,7 @@ module Unmagic::Passkeys::FormHelper
31
33
  content_tag("unmagic-passkey-registration-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
32
34
  tag.form(**form_options) do
33
35
  hidden_field_tag(:authenticity_token, form_authenticity_token) +
36
+ passkey_extra_param_fields(extra_params) +
34
37
  hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
35
38
  hidden_field_tag("#{param}[attestation_object]", nil, id: nil, data: { passkey_field: "attestation_object" }) +
36
39
  hidden_field_tag("#{param}[transports][]", nil, id: nil, data: { passkey_field: "transports" }) +
@@ -45,6 +48,8 @@ module Unmagic::Passkeys::FormHelper
45
48
  # Options:
46
49
  # - +options+: WebAuthn request options (JSON-serializable hash)
47
50
  # - +challenge_url+: endpoint to refresh the challenge nonce
51
+ # - +params+: extra params submitted with the ceremony (rendered as hidden
52
+ # fields, nested hashes/arrays supported — like +button_to+'s +params+)
48
53
  # - +mediation+: WebAuthn mediation hint (e.g. +"conditional"+ for autofill-assisted sign in)
49
54
  # - +wrapper+: HTML attributes for the outer web component element
50
55
  # - +form+: additional HTML attributes for the +<form>+ tag. Supports a +:param+ key
@@ -56,7 +61,7 @@ module Unmagic::Passkeys::FormHelper
56
61
  # - All other options are passed to the +<button>+ tag
57
62
  def passkey_sign_in_button(name = nil, url = nil, **options, &block)
58
63
  url, name = name, block ? capture(&block) : nil if block_given?
59
- component_options, form_options, button_options, error_options = partition_passkey_options(url, options)
64
+ component_options, form_options, button_options, error_options, extra_params = partition_passkey_options(url, options)
60
65
  error_options[:error][:message] ||= SIGN_IN_ERROR_MESSAGE
61
66
  error_options[:cancellation][:message] ||= SIGN_IN_CANCELLED_MESSAGE
62
67
  param = form_options.delete(:param)
@@ -64,6 +69,7 @@ module Unmagic::Passkeys::FormHelper
64
69
  content_tag("unmagic-passkey-sign-in-button", **component_options.transform_keys { |key| key.to_s.dasherize }) do
65
70
  tag.form(**form_options) do
66
71
  hidden_field_tag(:authenticity_token, form_authenticity_token) +
72
+ passkey_extra_param_fields(extra_params) +
67
73
  hidden_field_tag("#{param}[id]", nil, id: nil, data: { passkey_field: "id" }) +
68
74
  hidden_field_tag("#{param}[client_data_json]", nil, id: nil, data: { passkey_field: "client_data_json" }) +
69
75
  hidden_field_tag("#{param}[authenticator_data]", nil, id: nil, data: { passkey_field: "authenticator_data" }) +
@@ -77,6 +83,7 @@ module Unmagic::Passkeys::FormHelper
77
83
  def partition_passkey_options(url, options)
78
84
  passkey_options = options.fetch(:options, {})
79
85
  wrapper_options = options.fetch(:wrapper, {})
86
+ extra_params = options.fetch(:params, {})
80
87
 
81
88
  component_options = options
82
89
  .slice(:challenge_url, :mediation)
@@ -88,9 +95,23 @@ module Unmagic::Passkeys::FormHelper
88
95
 
89
96
  error_options = options.slice(:error, :cancellation, :duplicate).reverse_merge(error: {}, cancellation: {}, duplicate: {})
90
97
 
91
- button_options = options.except(:options, :form, :wrapper, *component_options.keys, *error_options.keys)
98
+ button_options = options.except(:options, :form, :wrapper, :params, *component_options.keys, *error_options.keys)
92
99
 
93
- [ wrapper_options.merge(component_options), form_options, button_options, error_options ]
100
+ [ wrapper_options.merge(component_options), form_options, button_options, error_options, extra_params ]
101
+ end
102
+
103
+ # Renders the +params+ option as hidden fields, flattening nested hashes
104
+ # and arrays into standard Rails parameter names.
105
+ def passkey_extra_param_fields(params, namespace = nil)
106
+ safe_join(params.map do |key, value|
107
+ name = namespace ? "#{namespace}[#{key}]" : key.to_s
108
+
109
+ case value
110
+ when Hash then passkey_extra_param_fields(value, name)
111
+ when Array then safe_join(value.map { |item| hidden_field_tag("#{name}[]", item, id: nil) })
112
+ else hidden_field_tag(name, value, id: nil)
113
+ end
114
+ end)
94
115
  end
95
116
 
96
117
  def default_passkey_challenge_url
@@ -1,5 +1,5 @@
1
1
  module Unmagic
2
2
  module Passkeys
3
- VERSION = "0.2.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
@@ -21,9 +21,6 @@ module Unmagic
21
21
  # +Unmagic::Passkeys::Credential+ Active Record model so host code reads as
22
22
  # +Unmagic::Passkeys.authenticate(params)+.
23
23
  module Passkeys
24
- # Raised when a controller flow needs a hook that has not been configured.
25
- class ConfigurationError < StandardError; end
26
-
27
24
  class << self
28
25
  # The singleton Configuration. Memoized with defaults; mutate via +configure+.
29
26
  def configuration
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: unmagic-passkeys
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Keith Pitt
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-28 00:00:00.000000000 Z
11
+ date: 2026-07-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -95,13 +95,8 @@ files:
95
95
  - README.md
96
96
  - app/assets/javascripts/unmagic/passkeys/passkey.js
97
97
  - app/assets/javascripts/unmagic/passkeys/webauthn.js
98
- - app/controllers/unmagic/passkeys/application_controller.rb
99
98
  - app/controllers/unmagic/passkeys/challenges_controller.rb
100
- - app/controllers/unmagic/passkeys/credentials_controller.rb
101
- - app/controllers/unmagic/passkeys/sessions_controller.rb
102
99
  - app/models/unmagic/passkeys/credential.rb
103
- - app/views/unmagic/passkeys/credentials/index.html.erb
104
- - app/views/unmagic/passkeys/sessions/new.html.erb
105
100
  - config/importmap.rb
106
101
  - config/routes.rb
107
102
  - lib/generators/unmagic/passkeys/install_generator.rb
@@ -112,7 +107,6 @@ files:
112
107
  - lib/unmagic/passkeys/engine.rb
113
108
  - lib/unmagic/passkeys/form_helper.rb
114
109
  - lib/unmagic/passkeys/holder.rb
115
- - lib/unmagic/passkeys/rails/routes.rb
116
110
  - lib/unmagic/passkeys/request.rb
117
111
  - lib/unmagic/passkeys/test/helpers.rb
118
112
  - lib/unmagic/passkeys/version.rb
@@ -1,35 +0,0 @@
1
- # Base controller for the passkey auth flows. Inherits from the host's
2
- # +ApplicationController+ by default (configurable via
3
- # +Unmagic::Passkeys.configuration.base_controller+) so the configured hooks can
4
- # call your app's session helpers, and so the flows pick up your layout.
5
- #
6
- # Your app customizes behaviour by subclassing the concrete controllers
7
- # (SessionsController / CredentialsController) and re-pointing the routes with
8
- # +use_unmagic_passkeys { controllers ... }+.
9
- class Unmagic::Passkeys::ApplicationController < Unmagic::Passkeys.configuration.base_controller.constantize
10
- include Unmagic::Passkeys::Request
11
-
12
- private
13
- # Runs the configured +sign_in+ hook in this controller's context.
14
- def sign_in_holder(holder)
15
- hook = Unmagic::Passkeys.configuration.sign_in
16
- unless hook
17
- raise Unmagic::Passkeys::ConfigurationError,
18
- "Unmagic::Passkeys.configure { |c| c.sign_in { |holder| ... } } must be set to use the passkey sign-in flows"
19
- end
20
-
21
- instance_exec(holder, &hook)
22
- end
23
-
24
- # Runs the configured +sign_out+ hook, if any.
25
- def sign_out_holder
26
- hook = Unmagic::Passkeys.configuration.sign_out
27
- instance_exec(&hook) if hook
28
- end
29
-
30
- # The signed-in holder, via the configured +current_holder+ hook.
31
- def current_passkey_holder
32
- hook = Unmagic::Passkeys.configuration.current_holder
33
- hook ? instance_exec(&hook) : nil
34
- end
35
- end
@@ -1,25 +0,0 @@
1
- # Passkey management for the signed-in holder: list (+index+), add (+create+),
2
- # and remove (+destroy+). Requires authentication via the host's base
3
- # controller. The holder comes from the configured +current_holder+ hook.
4
- class Unmagic::Passkeys::CredentialsController < Unmagic::Passkeys::ApplicationController
5
- def index
6
- @passkeys = passkey_holder.passkeys.order(created_at: :desc)
7
- @registration_options = passkey_registration_options(holder: passkey_holder)
8
- end
9
-
10
- def create
11
- passkey_holder.passkeys.register(passkey_registration_params)
12
- redirect_to url_for(action: :index), notice: "Passkey added."
13
- end
14
-
15
- def destroy
16
- passkey_holder.passkeys.find(params[:id]).destroy
17
- redirect_to url_for(action: :index), notice: "Passkey removed."
18
- end
19
-
20
- private
21
- def passkey_holder
22
- current_passkey_holder or raise Unmagic::Passkeys::ConfigurationError,
23
- "Unmagic::Passkeys.configure { |c| c.current_holder { ... } } must be set to manage passkeys"
24
- end
25
- end
@@ -1,40 +0,0 @@
1
- # Passkey sign-in: the sign-in page (+new+), the authentication ceremony
2
- # (+create+), and sign-out (+destroy+).
3
- #
4
- # Sign-in is usernameless — it relies on discoverable credentials, so the same
5
- # page signs in any user. Subclass to add rate limiting or to change the
6
- # redirect targets, then re-point the route:
7
- #
8
- # class SessionsController < Unmagic::Passkeys::SessionsController
9
- # rate_limit to: 10, within: 3.minutes
10
- # private def after_passkey_sign_in_path = after_authentication_url
11
- # end
12
- class Unmagic::Passkeys::SessionsController < Unmagic::Passkeys::ApplicationController
13
- # Sign-in must be reachable while signed out. No-op when the base controller
14
- # has no such callback.
15
- skip_before_action :require_authentication, raise: false
16
-
17
- def new
18
- @authentication_options = passkey_authentication_options
19
- end
20
-
21
- def create
22
- if credential = Unmagic::Passkeys.authenticate(passkey_authentication_params)
23
- sign_in_holder(credential.holder)
24
- redirect_to after_passkey_sign_in_path
25
- else
26
- redirect_to after_passkey_sign_in_failure_path, alert: passkey_sign_in_failure_alert
27
- end
28
- end
29
-
30
- def destroy
31
- sign_out_holder
32
- redirect_to after_passkey_sign_out_path, status: :see_other
33
- end
34
-
35
- private
36
- def after_passkey_sign_in_path = "/"
37
- def after_passkey_sign_in_failure_path = new_session_path
38
- def after_passkey_sign_out_path = new_session_path
39
- def passkey_sign_in_failure_alert = "That passkey didn't work. Try again."
40
- end
@@ -1,22 +0,0 @@
1
- <%# Default passkey management page. Override by creating a view at the same path
2
- (or at your subclass controller's view path). %>
3
- <section class="unmagic-passkeys">
4
- <h1>Passkeys</h1>
5
-
6
- <% if @passkeys.any? %>
7
- <ul>
8
- <% @passkeys.each do |passkey| %>
9
- <li id="<%= dom_id(passkey) %>">
10
- <span><%= passkey.name.presence || "Passkey" %></span>
11
- <span>added <%= passkey.created_at.to_date.to_fs(:long) %></span>
12
- <%= button_to "Remove", url_for(action: :destroy, id: passkey), method: :delete %>
13
- </li>
14
- <% end %>
15
- </ul>
16
- <% else %>
17
- <p>No passkeys yet.</p>
18
- <% end %>
19
-
20
- <%= passkey_registration_button "Add a passkey", url_for(action: :create),
21
- options: @registration_options %>
22
- </section>
@@ -1,7 +0,0 @@
1
- <%# Default sign-in page (usernameless / discoverable credentials). Override by
2
- creating a view at the same path (or at your subclass controller's view path). %>
3
- <main class="unmagic-passkeys">
4
- <p>Sign in with your passkey.</p>
5
- <%= passkey_sign_in_button "Sign in with a passkey", session_path,
6
- options: @authentication_options, mediation: "conditional" %>
7
- </main>
@@ -1,106 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Unmagic
4
- module Passkeys
5
- module Rails
6
- # Adds +use_unmagic_passkeys+ to the router. Draws the multi-user passkey
7
- # auth flows pointing at the engine's base controllers (which your app may
8
- # subclass and re-point):
9
- #
10
- # resource :session, only: [:new, :create, :destroy] # sign-in page + ceremony + sign-out
11
- # resources :passkeys, only: [:index, :create, :destroy] # management, mounted at /my/passkeys
12
- #
13
- # Sign-in is usernameless (discoverable credentials), so it works for any
14
- # number of users out of the box. Account creation (signup) is the app's
15
- # job — see the README.
16
- #
17
- # == Usage
18
- #
19
- # # config/routes.rb
20
- # use_unmagic_passkeys
21
- #
22
- # # Customize: re-point a flow at your own controller, skip flows, or
23
- # # nest everything under a path scope.
24
- # use_unmagic_passkeys scope: "accounts" do
25
- # controllers sessions: "sessions", credentials: "my/passkeys"
26
- # skip_controllers :credentials
27
- # end
28
- module Routes
29
- DEFAULT_CONTROLLERS = {
30
- sessions: "unmagic/passkeys/sessions",
31
- credentials: "unmagic/passkeys/credentials"
32
- }.freeze
33
-
34
- # Collects customization from the +use_unmagic_passkeys+ block.
35
- class Mapping
36
- attr_reader :credentials_path, :credentials_as
37
-
38
- def initialize
39
- @controller_overrides = {}
40
- @skipped = []
41
- @credentials_path = "my/passkeys"
42
- @credentials_as = "my_passkeys"
43
- end
44
-
45
- # Re-point one or more flows at your own controllers (which should
46
- # inherit from the matching engine base controller):
47
- #
48
- # controllers sessions: "sessions", credentials: "my/passkeys"
49
- def controllers(map = nil)
50
- return resolved_controllers if map.nil?
51
-
52
- map.each { |flow, controller| @controller_overrides[flow.to_sym] = controller.to_s }
53
- end
54
-
55
- # Skip drawing one or more flows: +skip_controllers :credentials+.
56
- def skip_controllers(*names)
57
- @skipped.concat(names.map(&:to_sym))
58
- end
59
-
60
- # Where the management resource is mounted and how its route helpers
61
- # are named (defaults to +/my/passkeys+ and +my_passkeys_path+).
62
- def credentials_at(path:, as:)
63
- @credentials_path = path
64
- @credentials_as = as
65
- end
66
-
67
- def draw?(flow)
68
- !@skipped.include?(flow)
69
- end
70
-
71
- def controller_for(flow)
72
- resolved_controllers.fetch(flow)
73
- end
74
-
75
- private
76
- def resolved_controllers
77
- DEFAULT_CONTROLLERS.merge(@controller_overrides)
78
- end
79
- end
80
-
81
- # Mixed into ActionDispatch::Routing::Mapper.
82
- module Mapper
83
- def use_unmagic_passkeys(scope: nil, &block)
84
- mapping = Mapping.new
85
- mapping.instance_exec(&block) if block
86
-
87
- draw = lambda do
88
- if mapping.draw?(:sessions)
89
- resource :session, only: %i[new create destroy], controller: mapping.controller_for(:sessions)
90
- end
91
-
92
- if mapping.draw?(:credentials)
93
- resources :passkeys, only: %i[index create destroy],
94
- controller: mapping.controller_for(:credentials),
95
- path: mapping.credentials_path,
96
- as: mapping.credentials_as
97
- end
98
- end
99
-
100
- scope ? self.scope(scope, &draw) : instance_exec(&draw)
101
- end
102
- end
103
- end
104
- end
105
- end
106
- end