unmagic-passkeys 0.1.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: 645c820a4995965db7ac6d5a146b1c26fa058670ee99159e6b69c2fc4d8c09b9
4
- data.tar.gz: f91753169ab1fad8ef5c789319babb20ee59806b3bc0bb6a56fb43075070091c
3
+ metadata.gz: 9d45bf4f51fee5d670d79d59a70518f2052417f00ecf549a263f1c23fa7d8aa3
4
+ data.tar.gz: a97fb1cf155def235ff658328850b0bafd5cf99b18c579501e8360839084e224
5
5
  SHA512:
6
- metadata.gz: 40df919dbc7b4c5e1496bcba443898bb332af4dbe62fe87624f105ce5aa91dd2dd2e9774196a5bebddb5b3713f333343daafd02f928d575dacda5561cef69134
7
- data.tar.gz: e3be802090ee4736ba752d702972857224440a95b65b1c3654ab628e33d6e5667f5298d8301968ffb8e2f399a3aa7cfba89237d28b9ab44ccd29ab33e73e43dd
6
+ metadata.gz: d00972cba516c64383dd7ab92c2301f276db4dbba1da08b7e9e6d35e878ab73df1ec47bfbfcedd51c92e58386f168317af1a1c8e131977310b2d2adc570a83aa
7
+ data.tar.gz: 74ecb8ef46d774e0f4ccf81ba0a33240d37bcf9b8fc4ccd0ea7fe5dbba76889f3c5b80864c4861ac196306f5c857ba253b29a9dd76345e514584a653593f531a
data/CHANGELOG.md CHANGED
@@ -2,8 +2,73 @@
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
+
43
+ ## [0.2.0] - 2026-06-28
44
+
5
45
  ### Added
6
46
  - Initial extraction: passkey (WebAuthn) registration and authentication for Rails
7
47
  as a self-contained engine — pure-Ruby CBOR/COSE/attestation/assertion, stateless
8
48
  signed challenges, a `has_passkeys` model macro, form helpers, a challenge
9
49
  endpoint, and JavaScript web components. No external dependencies.
50
+ - `Unmagic::Passkeys.configure { |config| ... }` block as the single
51
+ configuration entry point, with a memoized `Unmagic::Passkeys.configuration`.
52
+ Adds `relying_party_id` / `relying_party_name` overrides and `base_controller`.
53
+ - `use_unmagic_passkeys` router macro that
54
+ draws the multi-user sign-in (`/session`) and management (`/my/passkeys`) flows,
55
+ pointing at subclassable base controllers (`Unmagic::Passkeys::SessionsController`
56
+ / `CredentialsController`) with overridable ERB views. Supports `controllers`,
57
+ `skip_controllers`, and `scope`. App-specific behaviour is injected through
58
+ configuration hooks: `sign_in`, `sign_out`, `current_holder`. Sign-in is
59
+ usernameless (discoverable credentials), so it serves any number of users.
60
+ Account creation (signup) is left to the host app, which registers a passkey
61
+ for a holder it has created with the existing primitives.
62
+ - `Unmagic::Passkeys::Test::Helpers` — test helper that mints valid WebAuthn
63
+ ceremony payloads from a fixed key pair (`register_passkey_for`,
64
+ `build_attestation_params`, `build_assertion_params`, `webauthn_challenge`,
65
+ `with_webauthn_request_context`). Requiring `unmagic/passkeys/test/helpers`
66
+ auto-includes it into Rails integration tests; RP id/origin are overridable.
67
+
68
+ ### Changed (breaking)
69
+ - Configuration moved off the Rails engine options. The
70
+ `config.unmagic_passkeys.*` (and `config.unmagic_passkeys.web_authn.*`) settings
71
+ are removed entirely — there is no backward-compatible bridge. Migrate to the
72
+ `Unmagic::Passkeys.configure` block (e.g.
73
+ `config.unmagic_passkeys.web_authn.request_challenge_expiration` becomes
74
+ `config.request_challenge_expiration`).
data/README.md CHANGED
@@ -59,53 +59,148 @@ 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 engine ships the primitives; your app owns the login/registration controllers and
68
- views. The form helpers render self-contained web components include the JS once:
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
+
73
+ Include the JavaScript once — the form helpers render self-contained web components:
69
74
 
70
75
  ```js
71
76
  // app/javascript/application.js
72
77
  import "unmagic/passkeys"
73
78
  ```
74
79
 
75
- **Sign in** (`app/views/sessions/new.html.erb`):
80
+ Draw the routes:
76
81
 
77
- ```erb
78
- <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path,
79
- options: @authentication_options, mediation: "conditional" %>
82
+ ```ruby
83
+ # config/routes.rb
84
+ resource :session, only: %i[new create destroy]
85
+ resource :registration, only: %i[new create]
86
+ resources :passkeys, only: %i[index create destroy]
80
87
  ```
81
88
 
89
+ ### Sign-in
90
+
91
+ Usernameless (discoverable credentials) — one page signs in any user, and
92
+ conditional mediation offers passkeys through autofill:
93
+
82
94
  ```ruby
83
- class Sessions::PasskeysController < ApplicationController
95
+ # app/controllers/sessions_controller.rb
96
+ class SessionsController < ApplicationController
84
97
  include Unmagic::Passkeys::Request
85
98
 
99
+ allow_unauthenticated_access only: %i[new create]
100
+ rate_limit to: 10, within: 3.minutes, only: :create
101
+
102
+ def new
103
+ @authentication_options = passkey_authentication_options
104
+ end
105
+
86
106
  def create
87
107
  if credential = Unmagic::Passkeys.authenticate(passkey_authentication_params)
88
108
  start_new_session_for credential.holder
89
109
  redirect_to after_authentication_url
90
110
  else
91
- redirect_to new_session_path, alert: "That passkey didn't work."
111
+ redirect_to new_session_path, alert: "That passkey didn't work. Try again."
92
112
  end
93
113
  end
114
+
115
+ def destroy
116
+ terminate_session
117
+ redirect_to new_session_path, status: :see_other
118
+ end
94
119
  end
95
120
  ```
96
121
 
97
- **Register** (signed-in):
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
+ ```
128
+
129
+ ### Signup
130
+
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:
137
+
138
+ ```ruby
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
172
+ ```
98
173
 
99
174
  ```erb
100
- <%= passkey_registration_button "Register a passkey", passkeys_path,
101
- options: @registration_options %>
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 } } %>
102
188
  ```
103
189
 
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
196
+
104
197
  ```ruby
198
+ # app/controllers/passkeys_controller.rb
105
199
  class PasskeysController < ApplicationController
106
- include Unmagic::Passkeys::Request # sets the WebAuthn request context + param helpers
200
+ include Unmagic::Passkeys::Request
107
201
 
108
202
  def index
203
+ @passkeys = Current.user.passkeys.order(created_at: :desc)
109
204
  @registration_options = passkey_registration_options(holder: Current.user)
110
205
  end
111
206
 
@@ -113,27 +208,91 @@ class PasskeysController < ApplicationController
113
208
  Current.user.passkeys.register(passkey_registration_params)
114
209
  redirect_to passkeys_path, notice: "Passkey added."
115
210
  end
211
+
212
+ def destroy
213
+ Current.user.passkeys.find(params[:id]).destroy
214
+ redirect_to passkeys_path, notice: "Passkey removed."
215
+ end
116
216
  end
117
217
  ```
118
218
 
119
- `Unmagic::Passkeys::Request` provides `passkey_registration_params`,
120
- `passkey_authentication_params`, `passkey_registration_options`,
121
- `passkey_authentication_options`, and sets `Unmagic::Passkeys::WebAuthn::Current`
122
- (host/origin) per request.
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).
123
246
 
124
247
  ## Configuration
125
248
 
249
+ Configure the engine in a single block:
250
+
126
251
  ```ruby
127
252
  # config/initializers/passkeys.rb
128
- Rails.application.configure do
129
- config.unmagic_passkeys.web_authn.default_creation_options = { attestation: :none }
130
- config.unmagic_passkeys.web_authn.default_request_options = { user_verification: :required }
131
- config.unmagic_passkeys.web_authn.creation_challenge_expiration = 10.minutes
132
- config.unmagic_passkeys.web_authn.request_challenge_expiration = 5.minutes
133
- # config.unmagic_passkeys.parent_class_name = "ApplicationRecord"
253
+ Unmagic::Passkeys.configure do |config|
254
+ config.default_creation_options = { attestation: :none }
255
+ config.default_request_options = { user_verification: :required }
256
+ config.creation_challenge_expiration = 10.minutes
257
+ config.request_challenge_expiration = 5.minutes
258
+
259
+ # Relying party identity (default: request host / Rails.application.name)
260
+ # config.relying_party_id = "example.com"
261
+ # config.relying_party_name = "Example"
262
+
263
+ # config.parent_class_name = "ApplicationRecord"
264
+ # config.routes_prefix = "/auth/passkeys" # set in config/application.rb if overriding
265
+ # config.draw_routes = true
266
+ end
267
+ ```
268
+
269
+ ## Testing
270
+
271
+ Mint valid WebAuthn ceremony payloads without a browser. Requiring the helper
272
+ auto-includes it into Rails integration tests:
273
+
274
+ ```ruby
275
+ # test/test_helper.rb
276
+ require "unmagic/passkeys/test/helpers"
277
+
278
+ # test/integration/passkey_sign_in_test.rb
279
+ credential = register_passkey_for(@user)
280
+ assertion = in_webauthn_context do
281
+ build_assertion_params(challenge: webauthn_challenge(purpose: "authentication"), credential: credential)
134
282
  end
283
+ post session_path, params: { passkey: assertion }
135
284
  ```
136
285
 
286
+ For RSpec (or any non-integration test), include it yourself:
287
+
288
+ ```ruby
289
+ require "unmagic/passkeys/test/helpers"
290
+ RSpec.configure { |c| c.include Unmagic::Passkeys::Test::Helpers }
291
+ ```
292
+
293
+ It defaults the relying party to `www.example.com` (the integration-test host).
294
+ Override `webauthn_rp_id` / `webauthn_origin` in your test class to use another.
295
+
137
296
  ## Development
138
297
 
139
298
  ```sh
@@ -12,8 +12,8 @@
12
12
  #
13
13
  # == Route
14
14
  #
15
- # By default mounted at +/rails/action_pack/passkey/challenge+ (configurable
16
- # via +config.unmagic_passkeys.routes_prefix+).
15
+ # By default mounted at +/auth/passkeys/challenge+ (configurable via
16
+ # +Unmagic::Passkeys.configuration.routes_prefix+).
17
17
  #
18
18
  class Unmagic::Passkeys::ChallengesController < ActionController::Base
19
19
  include Unmagic::Passkeys::Request
@@ -38,7 +38,7 @@ class Unmagic::Passkeys::ChallengesController < ActionController::Base
38
38
  end
39
39
 
40
40
  def challenge_expiration
41
- config = Rails.configuration.unmagic_passkeys.web_authn
41
+ config = Unmagic::Passkeys.configuration
42
42
 
43
43
  if challenge_purpose == "registration"
44
44
  config.creation_challenge_expiration
@@ -27,19 +27,19 @@
27
27
  #
28
28
  # Call +has_passkeys+ in your model to set up the association and configure ceremony options
29
29
  # per-holder. See Unmagic::Passkeys::Holder for details.
30
- class Unmagic::Passkeys::Credential < Rails.configuration.unmagic_passkeys.parent_class_name.constantize
30
+ class Unmagic::Passkeys::Credential < Unmagic::Passkeys.configuration.parent_class_name.constantize
31
31
  self.table_name = "unmagic_passkeys_credentials"
32
32
  belongs_to :holder, polymorphic: true
33
33
  serialize :transports, coder: JSON, type: Array, default: []
34
34
 
35
35
  class << self
36
36
  # Returns a CreationOptions object for the given +holder+, suitable for passing to the
37
- # browser's +navigator.credentials.create()+ call. Merges global defaults from the Rails
38
- # configuration, holder-specific options from +holder.passkey_registration_options+, and any
39
- # additional +options+ overrides.
37
+ # browser's +navigator.credentials.create()+ call. Merges global defaults from
38
+ # +Unmagic::Passkeys.configuration+, holder-specific options from
39
+ # +holder.passkey_registration_options+, and any additional +options+ overrides.
40
40
  def registration_options(holder:, **options)
41
41
  Unmagic::Passkeys::WebAuthn::PublicKeyCredential.creation_options(
42
- **Rails.configuration.unmagic_passkeys.web_authn.default_creation_options.to_h,
42
+ **Unmagic::Passkeys.configuration.default_creation_options.to_h,
43
43
  **holder.passkey_registration_options.to_h,
44
44
  **options
45
45
  )
@@ -64,7 +64,7 @@ class Unmagic::Passkeys::Credential < Rails.configuration.unmagic_passkeys.paren
64
64
  # options, and any additional +options+ overrides.
65
65
  def authentication_options(holder: nil, **options)
66
66
  Unmagic::Passkeys::WebAuthn::PublicKeyCredential.request_options(
67
- **Rails.configuration.unmagic_passkeys.web_authn.default_request_options.to_h,
67
+ **Unmagic::Passkeys.configuration.default_request_options.to_h,
68
68
  **holder&.passkey_authentication_options.to_h,
69
69
  **options
70
70
  )
@@ -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
 
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Unmagic
4
+ module Passkeys
5
+ # Central configuration for the passkeys engine, set through a single
6
+ # +Unmagic::Passkeys.configure+ block. The defaults here are the single
7
+ # source of truth — there is no separate Rails engine config.
8
+ #
9
+ # # config/initializers/passkeys.rb
10
+ # Unmagic::Passkeys.configure do |config|
11
+ # config.relying_party_name = "Shopping"
12
+ # config.request_challenge_expiration = 5.minutes
13
+ # config.default_request_options = { user_verification: :required }
14
+ # end
15
+ class Configuration
16
+ # The Active Record base class the Credential model inherits from.
17
+ attr_accessor :parent_class_name
18
+
19
+ # Where the stateless challenge endpoint is mounted, and whether the engine
20
+ # draws it at all. Override these in +config/application.rb+ if you need
21
+ # them applied before the engine draws its routes.
22
+ attr_accessor :routes_prefix, :draw_routes
23
+
24
+ # Optional callable, +instance_exec+'d in the view, returning the URL the
25
+ # form helpers point the challenge fetch at. Defaults to the engine's
26
+ # challenge path when nil.
27
+ attr_accessor :challenge_url
28
+
29
+ # Global option defaults merged into every registration / authentication
30
+ # ceremony.
31
+ attr_accessor :default_creation_options, :default_request_options
32
+
33
+ # How long an issued challenge stays valid, per ceremony.
34
+ attr_accessor :creation_challenge_expiration, :request_challenge_expiration
35
+
36
+ # Relying party identity. When nil, falls back to the request host and
37
+ # +Rails.application.name+ respectively.
38
+ attr_accessor :relying_party_id, :relying_party_name
39
+
40
+ def initialize
41
+ @parent_class_name = "ApplicationRecord"
42
+ @routes_prefix = "/auth/passkeys"
43
+ @draw_routes = true
44
+ @challenge_url = nil
45
+ @default_creation_options = {}
46
+ @default_request_options = {}
47
+ @creation_challenge_expiration = 10.minutes
48
+ @request_challenge_expiration = 5.minutes
49
+ @relying_party_id = nil
50
+ @relying_party_name = nil
51
+ end
52
+ end
53
+ end
54
+ end
@@ -9,24 +9,14 @@ module Unmagic
9
9
  # == Configuration
10
10
  #
11
11
  # # config/initializers/passkeys.rb
12
- # config.unmagic_passkeys.web_authn.default_creation_options = { attestation: :none }
13
- # config.unmagic_passkeys.web_authn.default_request_options = { user_verification: :required }
14
- # config.unmagic_passkeys.routes_prefix = "/unmagic/passkeys"
12
+ # Unmagic::Passkeys.configure do |config|
13
+ # config.default_creation_options = { attestation: :none }
14
+ # config.default_request_options = { user_verification: :required }
15
+ # config.routes_prefix = "/auth/passkeys"
16
+ # end
15
17
  class Engine < ::Rails::Engine
16
- config.unmagic_passkeys = ActiveSupport::OrderedOptions.new
17
- config.unmagic_passkeys.parent_class_name = "ApplicationRecord"
18
- config.unmagic_passkeys.routes_prefix = "/unmagic/passkeys"
19
- config.unmagic_passkeys.draw_routes = true
20
- config.unmagic_passkeys.challenge_url = nil
21
-
22
- config.unmagic_passkeys.web_authn = ActiveSupport::OrderedOptions.new
23
- config.unmagic_passkeys.web_authn.default_request_options = {}
24
- config.unmagic_passkeys.web_authn.default_creation_options = {}
25
- config.unmagic_passkeys.web_authn.creation_challenge_expiration = 10.minutes
26
- config.unmagic_passkeys.web_authn.request_challenge_expiration = 5.minutes
27
-
28
18
  initializer "unmagic_passkeys.routes" do |app|
29
- passkey_config = config.unmagic_passkeys
19
+ passkey_config = Unmagic::Passkeys.configuration
30
20
 
31
21
  app.routes.prepend do
32
22
  if passkey_config.draw_routes
@@ -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,13 +95,27 @@ 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
97
- if challenge_url = Rails.configuration.unmagic_passkeys.challenge_url
118
+ if challenge_url = Unmagic::Passkeys.configuration.challenge_url
98
119
  instance_exec(&challenge_url)
99
120
  else
100
121
  passkey_challenge_path
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "openssl"
4
+ require "base64"
5
+ require "json"
6
+ require "digest"
7
+ require "securerandom"
8
+
9
+ module Unmagic
10
+ module Passkeys
11
+ module Test
12
+ # Mints valid WebAuthn ceremony payloads from a fixed EC P-256 key pair, so
13
+ # passkey registration and authentication can be exercised end to end
14
+ # without a browser. Ported from fizzy's test helper.
15
+ #
16
+ # In a Rails app, requiring this file auto-includes it into integration
17
+ # tests. Elsewhere (e.g. RSpec) include it yourself:
18
+ #
19
+ # require "unmagic/passkeys/test/helpers"
20
+ # RSpec.configure { |c| c.include Unmagic::Passkeys::Test::Helpers }
21
+ #
22
+ # The relying party defaults to "www.example.com" / "http://www.example.com"
23
+ # (the Rails integration-test host, so the engine's request context lines up
24
+ # automatically). Override +webauthn_rp_id+ / +webauthn_origin+ in your test
25
+ # class to test under a different host.
26
+ module Helpers
27
+ WEBAUTHN_PRIVATE_KEY = OpenSSL::PKey::EC.new(
28
+ [ "307702010104201dd589de7210b3318620f32150e3012cce021519df1d6e9e01" \
29
+ "0471146d395cdca00a06082a8648ce3d030107a14403420004116847fe19e1ad" \
30
+ "4471ab9980d7ff9cc1e4c7cb7a3af00e082b64fcd84f5ae70114c2495eef16f" \
31
+ "542b5e57dd1b263661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
32
+ )
33
+
34
+ # Pre-encoded COSE EC2/ES256 public key (CBOR) for the key above.
35
+ COSE_PUBLIC_KEY = [ "a5010203262001215820116847fe19e1ad4471ab9980d7ff9cc1" \
36
+ "e4c7cb7a3af00e082b64fcd84f5ae70122582014c2495eef16f542b5e57dd1b2" \
37
+ "63661624e3cf28f581b57a441edbd756a41d0e" ].pack("H*")
38
+
39
+ # CBOR prefix for {"fmt": "none", "attStmt": {}, "authData": bytes(164)}.
40
+ ATTESTATION_OBJECT_CBOR_PREFIX =
41
+ [ "a363666d74646e6f6e656761747453746d74a068617574684461746158a4" ].pack("H*")
42
+
43
+ RP_ID = "www.example.com"
44
+ ORIGIN = "http://www.example.com"
45
+
46
+ # The relying party identity used to mint payloads. Override in a test
47
+ # class to exercise a different host.
48
+ def webauthn_rp_id = RP_ID
49
+ def webauthn_origin = ORIGIN
50
+
51
+ # Registers a passkey for +holder+ directly (used to set up the
52
+ # "already enrolled" state). Returns the persisted credential.
53
+ def register_passkey_for(holder)
54
+ with_webauthn_request_context do
55
+ holder.passkeys.register(build_attestation_params(challenge: webauthn_challenge(purpose: "registration")))
56
+ end
57
+ end
58
+
59
+ def with_webauthn_request_context
60
+ Unmagic::Passkeys::WebAuthn::Current.host = webauthn_rp_id
61
+ Unmagic::Passkeys::WebAuthn::Current.origin = webauthn_origin
62
+ yield
63
+ ensure
64
+ Unmagic::Passkeys::WebAuthn::Current.reset
65
+ end
66
+ alias_method :in_webauthn_context, :with_webauthn_request_context
67
+
68
+ def webauthn_challenge(purpose: nil)
69
+ Unmagic::Passkeys::WebAuthn::PublicKeyCredential::Options.new(challenge_purpose: purpose).challenge
70
+ end
71
+
72
+ def build_attestation_params(challenge:)
73
+ credential_id = SecureRandom.random_bytes(32)
74
+ auth_data = build_attestation_auth_data(credential_id: credential_id)
75
+
76
+ {
77
+ client_data_json: webauthn_client_data_json(challenge: challenge, type: "webauthn.create"),
78
+ attestation_object: Base64.urlsafe_encode64(ATTESTATION_OBJECT_CBOR_PREFIX + auth_data, padding: false),
79
+ transports: [ "internal" ]
80
+ }
81
+ end
82
+
83
+ def build_assertion_params(challenge:, credential:, sign_count: 1)
84
+ client_data_json = webauthn_client_data_json(challenge: challenge, type: "webauthn.get")
85
+ authenticator_data = build_assertion_auth_data(sign_count: sign_count)
86
+ signature = webauthn_sign(authenticator_data, client_data_json)
87
+
88
+ {
89
+ id: credential.credential_id,
90
+ client_data_json: client_data_json,
91
+ authenticator_data: Base64.urlsafe_encode64(authenticator_data, padding: false),
92
+ signature: Base64.urlsafe_encode64(signature, padding: false)
93
+ }
94
+ end
95
+
96
+ private
97
+ def webauthn_client_data_json(challenge:, type:)
98
+ { challenge: challenge, origin: webauthn_origin, type: type }.to_json
99
+ end
100
+
101
+ def build_attestation_auth_data(credential_id:)
102
+ [
103
+ Digest::SHA256.digest(webauthn_rp_id),
104
+ [ 0x45 ].pack("C"), # flags: UP + UV + AT
105
+ [ 0 ].pack("N"), # sign_count
106
+ "\x00" * 16, # aaguid
107
+ [ credential_id.bytesize ].pack("n"), # credential_id_length
108
+ credential_id,
109
+ COSE_PUBLIC_KEY
110
+ ].join.b
111
+ end
112
+
113
+ def build_assertion_auth_data(sign_count:)
114
+ [
115
+ Digest::SHA256.digest(webauthn_rp_id),
116
+ [ 0x05 ].pack("C"), # flags: UP + UV
117
+ [ sign_count ].pack("N")
118
+ ].join.b
119
+ end
120
+
121
+ def webauthn_sign(authenticator_data, client_data_json)
122
+ signed_data = authenticator_data + Digest::SHA256.digest(client_data_json)
123
+ WEBAUTHN_PRIVATE_KEY.sign("SHA256", signed_data)
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+
130
+ # Auto-include into Rails integration tests when Active Support is present.
131
+ if defined?(ActiveSupport)
132
+ ActiveSupport.on_load(:action_dispatch_integration_test) do
133
+ include Unmagic::Passkeys::Test::Helpers
134
+ end
135
+ end
@@ -1,5 +1,5 @@
1
1
  module Unmagic
2
2
  module Passkeys
3
- VERSION = "0.1.0"
3
+ VERSION = "0.3.0"
4
4
  end
5
5
  end
@@ -52,7 +52,7 @@ class Unmagic::Passkeys::WebAuthn::PublicKeyCredential::CreationOptions < Unmagi
52
52
  attribute :resident_key, default: :required
53
53
  attribute :exclude_credentials, default: -> { [] }
54
54
  attribute :attestation, default: :none
55
- attribute :challenge_expiration, default: -> { Rails.configuration.unmagic_passkeys.web_authn.creation_challenge_expiration }
55
+ attribute :challenge_expiration, default: -> { Unmagic::Passkeys.configuration.creation_challenge_expiration }
56
56
  attribute :challenge_purpose, default: "registration"
57
57
 
58
58
  validates :id, :name, :display_name, presence: true
@@ -64,8 +64,8 @@ class Unmagic::Passkeys::WebAuthn::PublicKeyCredential::Options
64
64
  #
65
65
  # The timestamp allows the server to reject stale challenges. The expiration
66
66
  # window is configurable per-ceremony via
67
- # +config.unmagic_passkeys.web_authn.creation_challenge_expiration+ and
68
- # +config.unmagic_passkeys.web_authn.request_challenge_expiration+, or per-instance
67
+ # +Unmagic::Passkeys.configuration.creation_challenge_expiration+ and
68
+ # +Unmagic::Passkeys.configuration.request_challenge_expiration+, or per-instance
69
69
  # via the +challenge_expiration+ attribute.
70
70
  def challenge
71
71
  @challenge ||= Base64.urlsafe_encode64(
@@ -25,7 +25,7 @@
25
25
  # +Unmagic::Passkeys::WebAuthn.relying_party+.
26
26
  class Unmagic::Passkeys::WebAuthn::PublicKeyCredential::RequestOptions < Unmagic::Passkeys::WebAuthn::PublicKeyCredential::Options
27
27
  attribute :credentials, default: -> { [] }
28
- attribute :challenge_expiration, default: -> { Rails.configuration.unmagic_passkeys.web_authn.request_challenge_expiration }
28
+ attribute :challenge_expiration, default: -> { Unmagic::Passkeys.configuration.request_challenge_expiration }
29
29
  attribute :challenge_purpose, default: "authentication"
30
30
 
31
31
  def initialize(attributes = {})
@@ -37,9 +37,16 @@ module Unmagic::Passkeys::WebAuthn
37
37
  class InvalidOptionsError < StandardError; end
38
38
 
39
39
  class << self
40
- # Returns a new RelyingParty configured from the current request context.
40
+ # Returns a new RelyingParty. Identity comes from +Unmagic::Passkeys.configuration+
41
+ # when set, otherwise falls back to the current request host and
42
+ # +Rails.application.name+.
41
43
  def relying_party
42
- RelyingParty.new
44
+ config = Unmagic::Passkeys.configuration
45
+
46
+ RelyingParty.new(
47
+ id: config.relying_party_id || Current.host,
48
+ name: config.relying_party_name || Rails.application.name
49
+ )
43
50
  end
44
51
 
45
52
  # Returns the MessageVerifier used to sign and verify WebAuthn challenges.
@@ -9,6 +9,7 @@ require "json"
9
9
  require "digest"
10
10
 
11
11
  require "unmagic/passkeys/version"
12
+ require "unmagic/passkeys/configuration"
12
13
  require "unmagic/passkeys/web_authn"
13
14
  require "unmagic/passkeys/holder"
14
15
  require "unmagic/passkeys/request"
@@ -21,6 +22,20 @@ module Unmagic
21
22
  # +Unmagic::Passkeys.authenticate(params)+.
22
23
  module Passkeys
23
24
  class << self
25
+ # The singleton Configuration. Memoized with defaults; mutate via +configure+.
26
+ def configuration
27
+ @configuration ||= Configuration.new
28
+ end
29
+
30
+ # Configure the engine in a single block:
31
+ #
32
+ # Unmagic::Passkeys.configure do |config|
33
+ # config.relying_party_name = "Shopping"
34
+ # end
35
+ def configure
36
+ yield configuration
37
+ end
38
+
24
39
  def registration_options(**options)
25
40
  Credential.registration_options(**options)
26
41
  end
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.1.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-26 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
@@ -103,10 +103,12 @@ files:
103
103
  - lib/generators/unmagic/passkeys/templates/POST_INSTALL
104
104
  - lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt
105
105
  - lib/unmagic/passkeys.rb
106
+ - lib/unmagic/passkeys/configuration.rb
106
107
  - lib/unmagic/passkeys/engine.rb
107
108
  - lib/unmagic/passkeys/form_helper.rb
108
109
  - lib/unmagic/passkeys/holder.rb
109
110
  - lib/unmagic/passkeys/request.rb
111
+ - lib/unmagic/passkeys/test/helpers.rb
110
112
  - lib/unmagic/passkeys/version.rb
111
113
  - lib/unmagic/passkeys/web_authn.rb
112
114
  - lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb