passkeyed 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +271 -0
- data/lib/generators/passkeyed/install/USAGE +14 -0
- data/lib/generators/passkeyed/install/install_generator.rb +105 -0
- data/lib/generators/passkeyed/install/templates/create_passkeyed_credentials.rb.erb +56 -0
- data/lib/generators/passkeyed/install/templates/initializer.rb +30 -0
- data/lib/generators/passkeyed/install/templates/passkey_controller.js +246 -0
- data/lib/passkeyed/ceremonies.rb +255 -0
- data/lib/passkeyed/configuration.rb +188 -0
- data/lib/passkeyed/credential.rb +58 -0
- data/lib/passkeyed/errors.rb +34 -0
- data/lib/passkeyed/model.rb +98 -0
- data/lib/passkeyed/version.rb +5 -0
- data/lib/passkeyed.rb +19 -0
- data/sig/passkeyed/ceremonies.rbs +22 -0
- data/sig/passkeyed/configuration.rbs +31 -0
- data/sig/passkeyed/credential.rbs +9 -0
- data/sig/passkeyed/errors.rbs +22 -0
- data/sig/passkeyed/model.rbs +16 -0
- data/sig/passkeyed/version.rbs +3 -0
- metadata +208 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2bee85e27d2a1bad05798dc69c2f4e0eb401c33c16cbdb7a300192b19739165e
|
|
4
|
+
data.tar.gz: 017b71f8707aff080a232197fb576d8a8633c3c18a87e82cb8ea1f516386adac
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 378aaa416e294eb648ef4c2257e9f8cad8dbd2d108dfc83276f1abc0cb91bd490174f60b3207f023040c3647411e3223fcca477b81b8521ad3ad746d71a794ff
|
|
7
|
+
data.tar.gz: 9f81bcb1a00d2f3c77d99b59e81bd5d581b591dcfa48e22406307e8aad83daad71ec17a020093b29891331cbff036f1e408f6100d87409807385e3b6cd68c870
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Paul Ardeleanu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# passkeyed
|
|
2
|
+
|
|
3
|
+
Passwordless, usernameless passkey ([WebAuthn](https://www.w3.org/TR/webauthn-2/))
|
|
4
|
+
authentication for Rails, built on
|
|
5
|
+
[webauthn-ruby](https://github.com/cedarcode/webauthn-ruby). No mountable
|
|
6
|
+
engine hiding the flow: you get a model concern, a controller concern exposing
|
|
7
|
+
four methods, and a Stimulus controller — you write your own routes,
|
|
8
|
+
controllers, and views.
|
|
9
|
+
|
|
10
|
+
A passkey is a public/private key pair created for one site. The private key
|
|
11
|
+
never leaves the user's device, your server stores only the public half, and
|
|
12
|
+
the browser binds each credential to its origin — nothing to phish, nothing
|
|
13
|
+
worth stealing from your database.
|
|
14
|
+
|
|
15
|
+
Companion to a two-part series:
|
|
16
|
+
[*Passkeys from first principles*](https://www.pardel.dev/2026/06/28/passkeys-from-first-principles.html)
|
|
17
|
+
(what passkeys are and how the ceremonies work) and
|
|
18
|
+
[*Building passkeys in Ruby on Rails*](https://www.pardel.dev/2026/07/04/passkeys-in-ruby-on-rails.html)
|
|
19
|
+
(the from-scratch build this gem packages).
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```ruby
|
|
24
|
+
gem "passkeyed"
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
bundle install
|
|
29
|
+
bin/rails generate passkeyed:install
|
|
30
|
+
bin/rails db:migrate
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
The generator adds the `passkeyed_credentials` table, a `webauthn_id` column
|
|
34
|
+
on your owner model (backfilled for existing rows, so current users can
|
|
35
|
+
register passkeys immediately), an initializer, the Stimulus controller, and
|
|
36
|
+
`include Passkeyed::Model` in your model.
|
|
37
|
+
|
|
38
|
+
There is no JavaScript dependency: the Stimulus controller uses the browser's
|
|
39
|
+
native `PublicKeyCredential` JSON methods
|
|
40
|
+
([shipped in every major browser](https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential))
|
|
41
|
+
and reports passkeys as unavailable on a browser too old to have them.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
`config/initializers/passkeyed.rb`:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
Passkeyed.configure do |config|
|
|
49
|
+
config.rp_name = "Acme"
|
|
50
|
+
config.rp_id = "acme.example" # optional in dev
|
|
51
|
+
config.allowed_origins = ["https://acme.example"] # required when deployed
|
|
52
|
+
config.user_verification = "required"
|
|
53
|
+
config.challenge_timeout = 300 # seconds a challenge stays valid
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
In any environment other than `development` and `test`, boot fails with a
|
|
58
|
+
`Passkeyed::ConfigurationError` when `allowed_origins` is blank or non-HTTPS
|
|
59
|
+
(WebAuthn requires a secure context), so the generated localhost default can't
|
|
60
|
+
silently ship. passkeyed never writes to webauthn-ruby's global configuration —
|
|
61
|
+
it hands an isolated `WebAuthn::RelyingParty` to every call — so an app using
|
|
62
|
+
webauthn-ruby directly keeps its own settings.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
Mix the ceremony helpers into a controller. Each ceremony is a challenge that
|
|
67
|
+
the authenticator signs and the server verifies.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
class RegistrationsController < ApplicationController
|
|
71
|
+
include Passkeyed::Ceremonies
|
|
72
|
+
|
|
73
|
+
# POST /passkeys/registration_options
|
|
74
|
+
def options
|
|
75
|
+
render json: passkey_registration_options(current_user)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# POST /passkeys
|
|
79
|
+
def create
|
|
80
|
+
passkey_register!(current_user, params[:credential], nickname: params[:nickname])
|
|
81
|
+
render json: { status: "ok" }
|
|
82
|
+
rescue Passkeyed::Error => e
|
|
83
|
+
render json: { error: e.message }, status: :unprocessable_entity
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class SessionsController < ApplicationController
|
|
88
|
+
include Passkeyed::Ceremonies
|
|
89
|
+
|
|
90
|
+
# POST /session/options
|
|
91
|
+
def options
|
|
92
|
+
render json: passkey_authentication_options
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# POST /session
|
|
96
|
+
def create
|
|
97
|
+
user = passkey_authenticate!(params[:credential])
|
|
98
|
+
session[:user_id] = user.id
|
|
99
|
+
render json: { status: "ok" }
|
|
100
|
+
rescue Passkeyed::Error
|
|
101
|
+
render json: { error: "Authentication failed" }, status: :unauthorized
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Wire the Stimulus controller in your views. To register a passkey (the optional
|
|
107
|
+
`nickname` input lets the user label the device):
|
|
108
|
+
|
|
109
|
+
```erb
|
|
110
|
+
<div data-controller="passkey"
|
|
111
|
+
data-passkey-registration-options-url-value="<%= options_passkeys_path %>"
|
|
112
|
+
data-passkey-registration-url-value="<%= passkeys_path %>"
|
|
113
|
+
data-passkey-redirect-url-value="<%= dashboard_path %>">
|
|
114
|
+
<input data-passkey-target="nickname" placeholder="Laptop">
|
|
115
|
+
<button data-action="passkey#register">Add a passkey</button>
|
|
116
|
+
<p data-passkey-target="error" role="alert"></p>
|
|
117
|
+
</div>
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
To sign in:
|
|
121
|
+
|
|
122
|
+
```erb
|
|
123
|
+
<div data-controller="passkey"
|
|
124
|
+
data-passkey-authentication-options-url-value="<%= options_session_path %>"
|
|
125
|
+
data-passkey-authentication-url-value="<%= session_path %>"
|
|
126
|
+
data-passkey-redirect-url-value="<%= dashboard_path %>">
|
|
127
|
+
<button data-action="passkey#authenticate">Sign in with a passkey</button>
|
|
128
|
+
<p data-passkey-target="error" role="alert"></p>
|
|
129
|
+
</div>
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
(`role="alert"` makes screen readers announce a ceremony failure.)
|
|
133
|
+
|
|
134
|
+
### Conditional UI (passkey autofill)
|
|
135
|
+
|
|
136
|
+
Add `data-passkey-conditional-value="true"` and an input marked
|
|
137
|
+
`autocomplete="username webauthn"` to also offer passkeys through the
|
|
138
|
+
browser's autofill:
|
|
139
|
+
|
|
140
|
+
```erb
|
|
141
|
+
<div data-controller="passkey"
|
|
142
|
+
data-passkey-conditional-value="true"
|
|
143
|
+
data-passkey-authentication-options-url-value="<%= options_session_path %>"
|
|
144
|
+
data-passkey-authentication-url-value="<%= session_path %>"
|
|
145
|
+
data-passkey-redirect-url-value="<%= dashboard_path %>">
|
|
146
|
+
<input type="text" autocomplete="username webauthn" placeholder="you@example.com">
|
|
147
|
+
<button data-action="passkey#authenticate">Sign in with a passkey</button>
|
|
148
|
+
<p data-passkey-target="error" role="alert"></p>
|
|
149
|
+
</div>
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The feature is skipped where the browser lacks it, a modal ceremony or
|
|
153
|
+
navigation aborts the pending request cleanly, and a failed attempt (a
|
|
154
|
+
dismissed sheet, an expired challenge) re-arms autofill automatically.
|
|
155
|
+
|
|
156
|
+
### Reacting to a ceremony from JavaScript
|
|
157
|
+
|
|
158
|
+
After a successful ceremony the controller dispatches a cancelable
|
|
159
|
+
`passkey:registered` / `passkey:authenticated` event (detail: the server's
|
|
160
|
+
JSON response) before following `data-passkey-redirect-url-value` (or
|
|
161
|
+
reloading). Call `event.preventDefault()` to take over — e.g. to update the
|
|
162
|
+
page with Turbo. Failures dispatch `passkey:error`.
|
|
163
|
+
|
|
164
|
+
## Public API
|
|
165
|
+
|
|
166
|
+
| Method | Purpose |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `Passkeyed::Model` | Include in the owner model: credentials association + `webauthn_id`. |
|
|
169
|
+
| `passkey_registration_options(user)` | Build creation options (discoverable), stash the challenge. |
|
|
170
|
+
| `passkey_register!(user, credential, nickname:)` | Verify and persist a new credential. |
|
|
171
|
+
| `passkey_authentication_options` | Build request options (no allow-list), stash the challenge. |
|
|
172
|
+
| `passkey_authenticate!(credential)` | Verify an assertion and return the owner. |
|
|
173
|
+
|
|
174
|
+
All ceremony failures raise `Passkeyed::Error` or a subclass
|
|
175
|
+
(`RegistrationError`, `AuthenticationError`, `CredentialNotFound`,
|
|
176
|
+
`ChallengeMissing`, `ChallengeExpired`).
|
|
177
|
+
|
|
178
|
+
### Managing a user's passkeys
|
|
179
|
+
|
|
180
|
+
List, rename, and revoke through the owner, so a mismatched id raises
|
|
181
|
+
`ActiveRecord::RecordNotFound` instead of touching another account's
|
|
182
|
+
credential:
|
|
183
|
+
|
|
184
|
+
```ruby
|
|
185
|
+
current_user.passkeyed_credentials # list (id, nickname, created_at, ...)
|
|
186
|
+
current_user.rename_passkey(id, "Work Laptop")
|
|
187
|
+
current_user.revoke_passkey(id) # guard the last passkey in your app
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
Each credential also records metadata for richer management UIs:
|
|
191
|
+
|
|
192
|
+
| Column | Meaning |
|
|
193
|
+
|---|---|
|
|
194
|
+
| `backed_up` / `backup_eligible` | Synced passkey (iCloud Keychain, Google Password Manager) vs device-bound. Refreshed on every sign-in. |
|
|
195
|
+
| `transports` | How the authenticator talks to clients, e.g. `["internal", "hybrid"]`. |
|
|
196
|
+
| `aaguid` | The authenticator model's UUID (nil when zeroed); map to names via the [community AAGUID list](https://github.com/passkeydeveloper/passkey-authenticator-aaguids). |
|
|
197
|
+
| `last_used_at` | Stamped on every successful assertion. |
|
|
198
|
+
|
|
199
|
+
The user entity sent at registration uses `passkey_name` (an email by default)
|
|
200
|
+
and `passkey_display_name` (defaults to `passkey_name`). Override either in
|
|
201
|
+
your model.
|
|
202
|
+
|
|
203
|
+
### Hooks
|
|
204
|
+
|
|
205
|
+
Override these private no-op methods in your controller to run after a
|
|
206
|
+
successful ceremony:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
def after_passkey_authentication(credential, user)
|
|
210
|
+
AuditLog.record(:passkey_sign_in, user:, credential:)
|
|
211
|
+
end
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
`after_passkey_registration(credential)` is the registration counterpart. Both
|
|
215
|
+
run with full controller context and outside the ceremony's error handling, so
|
|
216
|
+
an exception you raise surfaces as itself.
|
|
217
|
+
|
|
218
|
+
### Instrumentation
|
|
219
|
+
|
|
220
|
+
Both bang methods emit `ActiveSupport::Notifications` events —
|
|
221
|
+
`register.passkeyed` and `authenticate.passkeyed` — with `:credential`/`:user`
|
|
222
|
+
in the payload and, unlike the success-only hooks, the standard `:exception`
|
|
223
|
+
keys on failure, so probing is observable:
|
|
224
|
+
|
|
225
|
+
```ruby
|
|
226
|
+
ActiveSupport::Notifications.subscribe("authenticate.passkeyed") do |event|
|
|
227
|
+
next unless event.payload[:exception]
|
|
228
|
+
|
|
229
|
+
Rails.logger.warn("passkey sign-in failed: #{event.payload[:exception_object]&.message}")
|
|
230
|
+
end
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
## Notes and non-goals
|
|
234
|
+
|
|
235
|
+
- **Signature counter.** A non-increasing `sign_count` from an authenticator
|
|
236
|
+
that reports nonzero counts raises `Passkeyed::AuthenticationError` — the
|
|
237
|
+
classic cloned-authenticator signal. Synced passkeys report zero on both
|
|
238
|
+
sides and are exempt.
|
|
239
|
+
- **Return one generic message for failed sign-ins**, as the example above
|
|
240
|
+
does: rescue `Passkeyed::AuthenticationError` (which `CredentialNotFound`
|
|
241
|
+
subclasses) so you don't reveal whether a credential id is on record.
|
|
242
|
+
Response *timing* can still hint at it — an unknown id skips the signature
|
|
243
|
+
check — so pair the generic message with rate limiting.
|
|
244
|
+
- **Rate-limit the ceremony endpoints.** They are unauthenticated and run
|
|
245
|
+
public-key crypto on every request. On Rails 8+:
|
|
246
|
+
`rate_limit to: 10, within: 1.minute, only: %i[options create]`; on older
|
|
247
|
+
Rails, use `rack-attack`.
|
|
248
|
+
- **Sessions and CSRF.** Challenges are stashed in `session`, and the Stimulus
|
|
249
|
+
controller sends the CSRF token from the `csrf_meta_tags` meta tag — both
|
|
250
|
+
standard in a full-stack Rails app. In an API-only app, add session
|
|
251
|
+
middleware (or override `passkeyed_session`) and your own
|
|
252
|
+
request-authenticity scheme.
|
|
253
|
+
- **Account recovery** is your responsibility: plan for the user who loses
|
|
254
|
+
their device (a second passkey, or an out-of-band recovery path).
|
|
255
|
+
- Out of scope: password fallback, passkeys-as-second-factor, attestation
|
|
256
|
+
verification, and Devise integration.
|
|
257
|
+
|
|
258
|
+
## Development
|
|
259
|
+
|
|
260
|
+
```sh
|
|
261
|
+
bin/setup
|
|
262
|
+
bundle exec rake test # Minitest; ceremonies driven by WebAuthn::FakeClient
|
|
263
|
+
node --test test/javascript/*.test.mjs # Stimulus controller tests, no npm install
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
CI also runs the suite against the oldest supported Rails:
|
|
267
|
+
`BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test`.
|
|
268
|
+
|
|
269
|
+
## License
|
|
270
|
+
|
|
271
|
+
[MIT](LICENSE.txt).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Description:
|
|
2
|
+
Installs passkeyed: adds the passkeyed_credentials table and a webauthn_id
|
|
3
|
+
column on the owner model, an initializer, the passkey Stimulus controller,
|
|
4
|
+
and includes Passkeyed::Model in the owner model.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
bin/rails generate passkeyed:install
|
|
8
|
+
bin/rails generate passkeyed:install --user=Account
|
|
9
|
+
|
|
10
|
+
This will create:
|
|
11
|
+
db/migrate/XXXXXXXX_create_passkeyed_credentials.rb
|
|
12
|
+
config/initializers/passkeyed.rb
|
|
13
|
+
app/javascript/controllers/passkey_controller.js
|
|
14
|
+
And inject `include Passkeyed::Model` into the owner model.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Passkeyed
|
|
7
|
+
module Generators
|
|
8
|
+
# Wires passkeyed into a host Rails app:
|
|
9
|
+
#
|
|
10
|
+
# bin/rails generate passkeyed:install
|
|
11
|
+
#
|
|
12
|
+
# Adds the credentials table (and a webauthn_id column on the owner),
|
|
13
|
+
# an initializer, the Stimulus controller, and the model concern.
|
|
14
|
+
class InstallGenerator < Rails::Generators::Base
|
|
15
|
+
include ActiveRecord::Generators::Migration
|
|
16
|
+
|
|
17
|
+
source_root File.expand_path("templates", __dir__)
|
|
18
|
+
|
|
19
|
+
class_option :user,
|
|
20
|
+
type: :string,
|
|
21
|
+
default: "User",
|
|
22
|
+
desc: "Name of the model that owns passkeys"
|
|
23
|
+
|
|
24
|
+
def create_migration_file
|
|
25
|
+
migration_template(
|
|
26
|
+
"create_passkeyed_credentials.rb.erb",
|
|
27
|
+
"db/migrate/create_passkeyed_credentials.rb"
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def create_initializer
|
|
32
|
+
template "initializer.rb", "config/initializers/passkeyed.rb"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def copy_stimulus_controller
|
|
36
|
+
copy_file(
|
|
37
|
+
"passkey_controller.js",
|
|
38
|
+
"app/javascript/controllers/passkey_controller.js"
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inject_model_concern
|
|
43
|
+
model_path = "app/models/#{owner_model.underscore}.rb"
|
|
44
|
+
full_path = File.join(destination_root, model_path)
|
|
45
|
+
|
|
46
|
+
unless File.exist?(full_path)
|
|
47
|
+
say_status :skip, "#{model_path} not found; add `include Passkeyed::Model` to your owner model", :yellow
|
|
48
|
+
return
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
source = File.read(full_path)
|
|
52
|
+
class_name = owner_model.demodulize
|
|
53
|
+
|
|
54
|
+
# Idempotent: re-running the generator must not inject a second include.
|
|
55
|
+
# Anchor on a real include line (not a bare substring, which a comment or
|
|
56
|
+
# the just-injected snippet would satisfy).
|
|
57
|
+
if source.match?(/^\s*include\s+Passkeyed::Model\b/)
|
|
58
|
+
say_status :identical, "Passkeyed::Model already included in #{model_path}", :blue
|
|
59
|
+
return
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A single-line body (`class User; end`) can't take an injected line
|
|
63
|
+
# safely, so bail to a manual instruction rather than placing the include
|
|
64
|
+
# outside the class.
|
|
65
|
+
if source.match?(/^\s*class\s+#{Regexp.escape(class_name)}\b[^\n]*;\s*end/)
|
|
66
|
+
say_status :skip, "#{model_path} defines #{class_name} on one line; add `include Passkeyed::Model` manually",
|
|
67
|
+
:yellow
|
|
68
|
+
return
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# inject_into_class places the include after the class declaration with
|
|
72
|
+
# correct indentation, and handles namespaced/indented definitions.
|
|
73
|
+
inject_into_class model_path, class_name, " include Passkeyed::Model\n"
|
|
74
|
+
|
|
75
|
+
# inject_into_class is a silent no-op when the class can't be matched, so
|
|
76
|
+
# confirm the include actually landed rather than reporting false success.
|
|
77
|
+
return if File.read(full_path).match?(/^\s*include\s+Passkeyed::Model\b/)
|
|
78
|
+
|
|
79
|
+
say_status :skip, "could not edit #{model_path}; add `include Passkeyed::Model` manually", :yellow
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def print_next_steps
|
|
83
|
+
say "\npasskeyed installed. Next:", :green
|
|
84
|
+
say " 1. bin/rails db:migrate"
|
|
85
|
+
say " 2. add routes + a controller using Passkeyed::Ceremonies (see the README)"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def owner_model
|
|
91
|
+
options[:user]
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Used by the migration template. Resolve the real table from the model
|
|
95
|
+
# class so namespaced owners work (Admin::User.table_name => "users");
|
|
96
|
+
# tableize would wrongly produce "admin/users". Fall back to tableize when
|
|
97
|
+
# the class can't be loaded at generate time.
|
|
98
|
+
def owner_table
|
|
99
|
+
owner_model.constantize.table_name
|
|
100
|
+
rescue NameError
|
|
101
|
+
owner_model.tableize
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
class CreatePasskeyedCredentials < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
|
|
2
|
+
# On a large, busy <%= owner_table %> table (Postgres), consider building the
|
|
3
|
+
# indexes with algorithm: :concurrently (in their own disable_ddl_transaction!
|
|
4
|
+
# migration) to avoid holding a write lock.
|
|
5
|
+
def change
|
|
6
|
+
add_column :<%= owner_table %>, :webauthn_id, :string
|
|
7
|
+
|
|
8
|
+
# Backfill a unique random WebAuthn handle for every pre-existing
|
|
9
|
+
# <%= owner_model %> *before* adding the unique index. Without this, rows that
|
|
10
|
+
# existed when passkeyed was installed keep a NULL webauthn_id (the model's
|
|
11
|
+
# before_create only fires for new rows), and their registration options
|
|
12
|
+
# would omit user.id, breaking passkey registration for the existing user base.
|
|
13
|
+
reversible do |dir|
|
|
14
|
+
dir.up do
|
|
15
|
+
backfill = Class.new(ActiveRecord::Base) { self.table_name = :<%= owner_table %> }
|
|
16
|
+
backfill.reset_column_information
|
|
17
|
+
backfill.where(webauthn_id: nil).find_each do |record|
|
|
18
|
+
record.update_columns(webauthn_id: WebAuthn.generate_user_id)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
add_index :<%= owner_table %>, :webauthn_id, unique: true
|
|
24
|
+
|
|
25
|
+
create_table :passkeyed_credentials do |t|
|
|
26
|
+
t.references :user, polymorphic: true, null: false
|
|
27
|
+
t.string :external_id, null: false
|
|
28
|
+
# COSE public keys are base64url-encoded; an RSA key (RS256/PS256, which
|
|
29
|
+
# webauthn-ruby advertises by default) exceeds varchar(255) on MySQL, so
|
|
30
|
+
# use a text column to avoid truncation.
|
|
31
|
+
t.text :public_key, null: false
|
|
32
|
+
t.bigint :sign_count, null: false, default: 0
|
|
33
|
+
t.string :nickname
|
|
34
|
+
# Bumped on every successful assertion; handy for a "last used" column in a
|
|
35
|
+
# credential-management UI. Nil until the credential's first sign-in.
|
|
36
|
+
t.datetime :last_used_at
|
|
37
|
+
# Authenticator metadata captured at registration, for a management UI:
|
|
38
|
+
# backup_eligible/backed_up distinguish a synced passkey (iCloud Keychain,
|
|
39
|
+
# Google Password Manager) from a device-bound one; transports (JSON array,
|
|
40
|
+
# e.g. ["internal","hybrid"]) and aaguid (the authenticator model's UUID,
|
|
41
|
+
# nil when zeroed) hint at what kind of device holds the key. backed_up is
|
|
42
|
+
# refreshed on each sign-in — the flag may flip when a key later syncs.
|
|
43
|
+
t.boolean :backup_eligible
|
|
44
|
+
t.boolean :backed_up
|
|
45
|
+
t.string :transports
|
|
46
|
+
t.string :aaguid
|
|
47
|
+
|
|
48
|
+
t.timestamps
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Credential ids from real authenticators fit comfortably in a string + unique
|
|
52
|
+
# index. The WebAuthn spec permits ids up to 1023 bytes; if you must support
|
|
53
|
+
# such authenticators on MySQL, index a digest of external_id instead.
|
|
54
|
+
add_index :passkeyed_credentials, :external_id, unique: true
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Passkeyed configuration. See https://github.com/pardel/passkeyed.
|
|
4
|
+
Passkeyed.configure do |config|
|
|
5
|
+
# Shown by some authenticators during a ceremony.
|
|
6
|
+
config.rp_name = Rails.application.class.module_parent_name
|
|
7
|
+
|
|
8
|
+
# Your domain. Leave commented to derive it from the request origin in
|
|
9
|
+
# development; set it explicitly in production (e.g. "example.com").
|
|
10
|
+
# config.rp_id = "example.com"
|
|
11
|
+
|
|
12
|
+
# Origins the server will accept assertions from. Required (and must be
|
|
13
|
+
# https) in any environment other than development and test.
|
|
14
|
+
config.allowed_origins = ["http://localhost:3000"]
|
|
15
|
+
|
|
16
|
+
# "required" makes a single passkey gesture genuinely multi-factor.
|
|
17
|
+
config.user_verification = "required"
|
|
18
|
+
|
|
19
|
+
# How long (in seconds) an issued challenge may sit unsigned before the
|
|
20
|
+
# ceremony must be restarted. The default suits almost everyone.
|
|
21
|
+
# config.challenge_timeout = 300
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The ActiveRecord base class Passkeyed::Credential inherits from. Defaults to
|
|
25
|
+
# ApplicationRecord when defined. Set this ONLY if you need a different base
|
|
26
|
+
# (e.g. a secondary database), and set it *here*, before anything references
|
|
27
|
+
# Passkeyed::Credential: the parent class is resolved once, when that constant
|
|
28
|
+
# is first loaded, so a later assignment is ignored.
|
|
29
|
+
#
|
|
30
|
+
# Passkeyed.base_record_class = "SecondaryRecord"
|