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 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"