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