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 +4 -4
- data/CHANGELOG.md +27 -0
- data/README.md +110 -34
- data/app/controllers/unmagic/passkeys/application_controller.rb +35 -0
- data/app/controllers/unmagic/passkeys/challenges_controller.rb +3 -3
- data/app/controllers/unmagic/passkeys/credentials_controller.rb +25 -0
- data/app/controllers/unmagic/passkeys/sessions_controller.rb +40 -0
- data/app/models/unmagic/passkeys/credential.rb +6 -6
- data/app/views/unmagic/passkeys/credentials/index.html.erb +22 -0
- data/app/views/unmagic/passkeys/sessions/new.html.erb +7 -0
- data/lib/unmagic/passkeys/configuration.rb +94 -0
- data/lib/unmagic/passkeys/engine.rb +10 -15
- data/lib/unmagic/passkeys/form_helper.rb +1 -1
- data/lib/unmagic/passkeys/rails/routes.rb +106 -0
- 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 +18 -0
- metadata +10 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6ddd2eb491d1dbf70760a8fa843d6e09284b44d0031178c9312afebed493a3f5
|
|
4
|
+
data.tar.gz: a173df187a85d06e116321cb203966b6613c924771e294e1308504923d707b49
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
74
|
+
### Batteries included: `use_unmagic_passkeys`
|
|
76
75
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
164
|
+
## Configuration
|
|
98
165
|
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 +/
|
|
16
|
-
#
|
|
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 =
|
|
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 <
|
|
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
|
)
|
|
@@ -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
|
-
#
|
|
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 = "/unmagic/passkeys"
|
|
16
|
+
# end
|
|
15
17
|
class Engine < ::Rails::Engine
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
@@ -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"
|
|
@@ -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.
|
|
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-
|
|
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
|