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 +4 -4
- data/CHANGELOG.md +65 -0
- data/README.md +182 -23
- data/app/controllers/unmagic/passkeys/challenges_controller.rb +3 -3
- data/app/models/unmagic/passkeys/credential.rb +6 -6
- data/lib/generators/unmagic/passkeys/install_generator.rb +6 -2
- data/lib/generators/unmagic/passkeys/templates/POST_INSTALL +1 -1
- data/lib/unmagic/passkeys/configuration.rb +54 -0
- data/lib/unmagic/passkeys/engine.rb +6 -16
- data/lib/unmagic/passkeys/form_helper.rb +26 -5
- data/lib/unmagic/passkeys/test/helpers.rb +135 -0
- data/lib/unmagic/passkeys/version.rb +1 -1
- data/lib/unmagic/passkeys/web_authn/public_key_credential/creation_options.rb +1 -1
- data/lib/unmagic/passkeys/web_authn/public_key_credential/options.rb +2 -2
- data/lib/unmagic/passkeys/web_authn/public_key_credential/request_options.rb +1 -1
- data/lib/unmagic/passkeys/web_authn.rb +9 -2
- data/lib/unmagic/passkeys.rb +15 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9d45bf4f51fee5d670d79d59a70518f2052417f00ecf549a263f1c23fa7d8aa3
|
|
4
|
+
data.tar.gz: a97fb1cf155def235ff658328850b0bafd5cf99b18c579501e8360839084e224
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 /
|
|
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
|
|
68
|
-
|
|
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
|
-
|
|
80
|
+
Draw the routes:
|
|
76
81
|
|
|
77
|
-
```
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
129
|
-
config.
|
|
130
|
-
config.
|
|
131
|
-
config.
|
|
132
|
-
config.
|
|
133
|
-
|
|
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 +/
|
|
16
|
-
#
|
|
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 =
|
|
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 <
|
|
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
|
|
38
|
-
# configuration
|
|
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
|
-
**
|
|
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
|
-
**
|
|
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
|
-
|
|
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 /
|
|
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
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
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 =
|
|
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 =
|
|
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
|
|
@@ -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: -> {
|
|
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
|
-
# +
|
|
68
|
-
# +
|
|
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: -> {
|
|
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
|
|
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
|
-
|
|
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.
|
data/lib/unmagic/passkeys.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|