unmagic-passkeys 0.1.0 → 0.2.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: 6ddd2eb491d1dbf70760a8fa843d6e09284b44d0031178c9312afebed493a3f5
4
+ data.tar.gz: a173df187a85d06e116321cb203966b6613c924771e294e1308504923d707b49
5
5
  SHA512:
6
- metadata.gz: 40df919dbc7b4c5e1496bcba443898bb332af4dbe62fe87624f105ce5aa91dd2dd2e9774196a5bebddb5b3713f333343daafd02f928d575dacda5561cef69134
7
- data.tar.gz: e3be802090ee4736ba752d702972857224440a95b65b1c3654ab628e33d6e5667f5298d8301968ffb8e2f399a3aa7cfba89237d28b9ab44ccd29ab33e73e43dd
6
+ metadata.gz: 2091181c23ecdbc5a1e1d0a1e75e7c28faa030158a452ef77c8b4ac171200ec0964cfeed4ffa69346d60b613fdfd93155108ceb009616d8e941a1659869e87ab
7
+ data.tar.gz: 49727646f8936293eaac8f24c26c0fc35e40c8879fb3ae5f19006d3edbf03f6017580071e8da6aa324873501c16c1108bbf91b93f154cab18cf4289026088dc3
data/CHANGELOG.md CHANGED
@@ -2,8 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-06-28
6
+
5
7
  ### Added
6
8
  - Initial extraction: passkey (WebAuthn) registration and authentication for Rails
7
9
  as a self-contained engine — pure-Ruby CBOR/COSE/attestation/assertion, stateless
8
10
  signed challenges, a `has_passkeys` model macro, form helpers, a challenge
9
11
  endpoint, and JavaScript web components. No external dependencies.
12
+ - `Unmagic::Passkeys.configure { |config| ... }` block as the single
13
+ configuration entry point, with a memoized `Unmagic::Passkeys.configuration`.
14
+ Adds `relying_party_id` / `relying_party_name` overrides and `base_controller`.
15
+ - `use_unmagic_passkeys` router macro that
16
+ draws the multi-user sign-in (`/session`) and management (`/my/passkeys`) flows,
17
+ pointing at subclassable base controllers (`Unmagic::Passkeys::SessionsController`
18
+ / `CredentialsController`) with overridable ERB views. Supports `controllers`,
19
+ `skip_controllers`, and `scope`. App-specific behaviour is injected through
20
+ configuration hooks: `sign_in`, `sign_out`, `current_holder`. Sign-in is
21
+ usernameless (discoverable credentials), so it serves any number of users.
22
+ Account creation (signup) is left to the host app, which registers a passkey
23
+ for a holder it has created with the existing primitives.
24
+ - `Unmagic::Passkeys::Test::Helpers` — test helper that mints valid WebAuthn
25
+ ceremony payloads from a fixed key pair (`register_passkey_for`,
26
+ `build_attestation_params`, `build_assertion_params`, `webauthn_challenge`,
27
+ `with_webauthn_request_context`). Requiring `unmagic/passkeys/test/helpers`
28
+ auto-includes it into Rails integration tests; RP id/origin are overridable.
29
+
30
+ ### Changed (breaking)
31
+ - Configuration moved off the Rails engine options. The
32
+ `config.unmagic_passkeys.*` (and `config.unmagic_passkeys.web_authn.*`) settings
33
+ are removed entirely — there is no backward-compatible bridge. Migrate to the
34
+ `Unmagic::Passkeys.configure` block (e.g.
35
+ `config.unmagic_passkeys.web_authn.request_challenge_expiration` becomes
36
+ `config.request_challenge_expiration`).
data/README.md CHANGED
@@ -64,21 +64,88 @@ The engine mounts a stateless challenge endpoint at `POST /unmagic/passkeys/chal
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
+ Include the JavaScript once the form helpers render self-contained web components:
69
68
 
70
69
  ```js
71
70
  // app/javascript/application.js
72
71
  import "unmagic/passkeys"
73
72
  ```
74
73
 
75
- **Sign in** (`app/views/sessions/new.html.erb`):
74
+ ### Batteries included: `use_unmagic_passkeys`
76
75
 
77
- ```erb
78
- <%= passkey_sign_in_button "Sign in with a passkey", session_passkey_path,
79
- options: @authentication_options, mediation: "conditional" %>
76
+ This draws the multi-user passkey auth flows and points them at the engine's
77
+ base controllers:
78
+
79
+ ```ruby
80
+ # config/routes.rb
81
+ use_unmagic_passkeys
82
+ ```
83
+
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` |
88
+
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:
92
+
93
+ ```ruby
94
+ # config/initializers/passkeys.rb
95
+ Unmagic::Passkeys.configure do |config|
96
+ config.base_controller = "ApplicationController" # so hooks see your helpers
97
+
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
80
102
  ```
81
103
 
104
+ Customize by subclassing a base controller and re-pointing the route:
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
113
+
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
118
+ end
119
+ ```
120
+
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.
126
+
127
+ ### Signup is yours
128
+
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:
133
+
134
+ ```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
+ ```
140
+
141
+ ### À la carte primitives
142
+
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:
148
+
82
149
  ```ruby
83
150
  class Sessions::PasskeysController < ApplicationController
84
151
  include Unmagic::Passkeys::Request
@@ -94,46 +161,55 @@ class Sessions::PasskeysController < ApplicationController
94
161
  end
95
162
  ```
96
163
 
97
- **Register** (signed-in):
164
+ ## Configuration
98
165
 
99
- ```erb
100
- <%= passkey_registration_button "Register a passkey", passkeys_path,
101
- options: @registration_options %>
102
- ```
166
+ Configure the engine in a single block:
103
167
 
104
168
  ```ruby
105
- class PasskeysController < ApplicationController
106
- include Unmagic::Passkeys::Request # sets the WebAuthn request context + param helpers
107
-
108
- def index
109
- @registration_options = passkey_registration_options(holder: Current.user)
110
- end
111
-
112
- def create
113
- Current.user.passkeys.register(passkey_registration_params)
114
- redirect_to passkeys_path, notice: "Passkey added."
115
- end
169
+ # config/initializers/passkeys.rb
170
+ Unmagic::Passkeys.configure do |config|
171
+ config.default_creation_options = { attestation: :none }
172
+ config.default_request_options = { user_verification: :required }
173
+ config.creation_challenge_expiration = 10.minutes
174
+ config.request_challenge_expiration = 5.minutes
175
+
176
+ # Relying party identity (default: request host / Rails.application.name)
177
+ # config.relying_party_id = "example.com"
178
+ # config.relying_party_name = "Example"
179
+
180
+ # config.parent_class_name = "ApplicationRecord"
181
+ # config.routes_prefix = "/unmagic/passkeys" # set in config/application.rb if overriding
182
+ # config.draw_routes = true
116
183
  end
117
184
  ```
118
185
 
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.
186
+ ## Testing
123
187
 
124
- ## Configuration
188
+ Mint valid WebAuthn ceremony payloads without a browser. Requiring the helper
189
+ auto-includes it into Rails integration tests:
125
190
 
126
191
  ```ruby
127
- # 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"
192
+ # test/test_helper.rb
193
+ require "unmagic/passkeys/test/helpers"
194
+
195
+ # test/controllers/sessions/passkeys_controller_test.rb
196
+ credential = register_passkey_for(@user)
197
+ assertion = in_webauthn_context do
198
+ build_assertion_params(challenge: webauthn_challenge(purpose: "authentication"), credential: credential)
134
199
  end
200
+ post session_passkey_path, params: { passkey: assertion }
135
201
  ```
136
202
 
203
+ For RSpec (or any non-integration test), include it yourself:
204
+
205
+ ```ruby
206
+ require "unmagic/passkeys/test/helpers"
207
+ RSpec.configure { |c| c.include Unmagic::Passkeys::Test::Helpers }
208
+ ```
209
+
210
+ It defaults the relying party to `www.example.com` (the integration-test host).
211
+ Override `webauthn_rp_id` / `webauthn_origin` in your test class to use another.
212
+
137
213
  ## Development
138
214
 
139
215
  ```sh
@@ -0,0 +1,35 @@
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
@@ -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 +/unmagic/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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,40 @@
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
@@ -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
  )
@@ -0,0 +1,22 @@
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>
@@ -0,0 +1,7 @@
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>
@@ -0,0 +1,94 @@
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
+ # 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
+ def initialize
46
+ @parent_class_name = "ApplicationRecord"
47
+ @routes_prefix = "/unmagic/passkeys"
48
+ @draw_routes = true
49
+ @challenge_url = nil
50
+ @default_creation_options = {}
51
+ @default_request_options = {}
52
+ @creation_challenge_expiration = 10.minutes
53
+ @request_challenge_expiration = 5.minutes
54
+ @relying_party_id = nil
55
+ @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
+ end
92
+ end
93
+ end
94
+ end
@@ -9,24 +9,19 @@ 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 = "/unmagic/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
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
27
22
 
28
23
  initializer "unmagic_passkeys.routes" do |app|
29
- passkey_config = config.unmagic_passkeys
24
+ passkey_config = Unmagic::Passkeys.configuration
30
25
 
31
26
  app.routes.prepend do
32
27
  if passkey_config.draw_routes
@@ -94,7 +94,7 @@ module Unmagic::Passkeys::FormHelper
94
94
  end
95
95
 
96
96
  def default_passkey_challenge_url
97
- if challenge_url = Rails.configuration.unmagic_passkeys.challenge_url
97
+ if challenge_url = Unmagic::Passkeys.configuration.challenge_url
98
98
  instance_exec(&challenge_url)
99
99
  else
100
100
  passkey_challenge_path
@@ -0,0 +1,106 @@
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
@@ -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.2.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"
@@ -20,7 +21,24 @@ module Unmagic
20
21
  # +Unmagic::Passkeys::Credential+ Active Record model so host code reads as
21
22
  # +Unmagic::Passkeys.authenticate(params)+.
22
23
  module Passkeys
24
+ # Raised when a controller flow needs a hook that has not been configured.
25
+ class ConfigurationError < StandardError; end
26
+
23
27
  class << self
28
+ # The singleton Configuration. Memoized with defaults; mutate via +configure+.
29
+ def configuration
30
+ @configuration ||= Configuration.new
31
+ end
32
+
33
+ # Configure the engine in a single block:
34
+ #
35
+ # Unmagic::Passkeys.configure do |config|
36
+ # config.relying_party_name = "Shopping"
37
+ # end
38
+ def configure
39
+ yield configuration
40
+ end
41
+
24
42
  def registration_options(**options)
25
43
  Credential.registration_options(**options)
26
44
  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.2.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-06-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -95,18 +95,26 @@ 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
98
99
  - app/controllers/unmagic/passkeys/challenges_controller.rb
100
+ - app/controllers/unmagic/passkeys/credentials_controller.rb
101
+ - app/controllers/unmagic/passkeys/sessions_controller.rb
99
102
  - app/models/unmagic/passkeys/credential.rb
103
+ - app/views/unmagic/passkeys/credentials/index.html.erb
104
+ - app/views/unmagic/passkeys/sessions/new.html.erb
100
105
  - config/importmap.rb
101
106
  - config/routes.rb
102
107
  - lib/generators/unmagic/passkeys/install_generator.rb
103
108
  - lib/generators/unmagic/passkeys/templates/POST_INSTALL
104
109
  - lib/generators/unmagic/passkeys/templates/create_unmagic_passkeys_credentials.rb.tt
105
110
  - lib/unmagic/passkeys.rb
111
+ - lib/unmagic/passkeys/configuration.rb
106
112
  - lib/unmagic/passkeys/engine.rb
107
113
  - lib/unmagic/passkeys/form_helper.rb
108
114
  - lib/unmagic/passkeys/holder.rb
115
+ - lib/unmagic/passkeys/rails/routes.rb
109
116
  - lib/unmagic/passkeys/request.rb
117
+ - lib/unmagic/passkeys/test/helpers.rb
110
118
  - lib/unmagic/passkeys/version.rb
111
119
  - lib/unmagic/passkeys/web_authn.rb
112
120
  - lib/unmagic/passkeys/web_authn/authenticator/assertion_response.rb