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.
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Passkeyed
4
+ # A stored passkey: the public half of a WebAuthn credential, plus the
5
+ # signature counter used to spot cloned authenticators. The private key
6
+ # never reaches us, so there is nothing secret in this table.
7
+ #
8
+ # The owner is polymorphic (+user+) so any model can hold passkeys, not just
9
+ # one called User.
10
+ class Credential < Passkeyed.base_record_class
11
+ self.table_name = "passkeyed_credentials"
12
+
13
+ belongs_to :user, polymorphic: true, optional: false
14
+
15
+ # The WebAuthn credential id (base64url). Unique across the whole table:
16
+ # authentication looks a credential up by this alone.
17
+ validates :external_id, presence: true, uniqueness: true
18
+ validates :public_key, presence: true
19
+ validates :sign_count,
20
+ presence: true,
21
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
22
+ # Optional user-supplied label. Cap it at the string column's width so a long
23
+ # value is rejected cleanly rather than silently truncated (e.g. on MySQL).
24
+ validates :nickname, length: { maximum: 255 }, allow_nil: true
25
+
26
+ # Persist the state a successful assertion produces: the advanced signature
27
+ # counter, and — when the columns are present — a last-used timestamp and
28
+ # the current backup state (a device-bound passkey may later start syncing,
29
+ # flipping the flag). The columns are guarded so authentication keeps
30
+ # working on apps that trimmed the optional columns from the migration.
31
+ def record_sign_in!(new_sign_count, backed_up: nil)
32
+ attributes = { sign_count: new_sign_count }
33
+ attributes[:last_used_at] = Time.current if has_attribute?(:last_used_at)
34
+ attributes[:backed_up] = backed_up if !backed_up.nil? && has_attribute?(:backed_up)
35
+ assign_attributes(attributes)
36
+ # Everything written here is server-derived, so skip validations: a record
37
+ # invalid by rules added after it was persisted (e.g. a nickname cap
38
+ # introduced in a future version) must not start failing every sign-in.
39
+ save!(validate: false)
40
+ end
41
+
42
+ # +transports+ is stored as a JSON string (portable across databases);
43
+ # expose it as the Array WebAuthn reported, e.g. ["internal", "hybrid"].
44
+ # Unparseable stored data (a manual write, a botched import) reads as nil
45
+ # rather than raising out of every page that lists credentials.
46
+ def transports
47
+ raw = super
48
+ raw.is_a?(String) ? JSON.parse(raw) : raw
49
+ rescue JSON::ParserError
50
+ nil
51
+ end
52
+
53
+ def transports=(value)
54
+ value = JSON.generate(value) unless value.nil? || value.is_a?(String)
55
+ super
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Passkeyed
4
+ # Base class for every error raised by passkeyed. Rescue this to catch any
5
+ # ceremony failure in one place.
6
+ class Error < StandardError; end
7
+
8
+ # Raised when a registration (attestation) ceremony fails verification.
9
+ class RegistrationError < Error; end
10
+
11
+ # Raised when an authentication (assertion) ceremony fails verification.
12
+ class AuthenticationError < Error; end
13
+
14
+ # Raised during authentication when the asserted credential is not one we
15
+ # have on record.
16
+ class CredentialNotFound < AuthenticationError; end
17
+
18
+ # Raised when the server-issued challenge is absent from the session, which
19
+ # usually means the ceremony was started in a different session or the
20
+ # challenge was already consumed.
21
+ class ChallengeMissing < Error; end
22
+
23
+ # Raised when the stored challenge is older than the configured
24
+ # +challenge_timeout+. A challenge should be signed within moments of being
25
+ # issued; a stale one usually means a form left open for hours. Subclasses
26
+ # ChallengeMissing because the remedy is identical (restart the ceremony), so
27
+ # one rescue can handle both.
28
+ class ChallengeExpired < ChallengeMissing; end
29
+
30
+ # Raised at boot/use time when passkeyed is misconfigured or the host model
31
+ # is missing required schema (e.g. an invalid +user_verification+ value or a
32
+ # missing +webauthn_id+ column). Fails fast rather than degrading silently.
33
+ class ConfigurationError < Error; end
34
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Passkeyed
6
+ # Mix into the model that owns passkeys (typically your User):
7
+ #
8
+ # class User < ApplicationRecord
9
+ # include Passkeyed::Model
10
+ # end
11
+ #
12
+ # It wires up the credentials association and assigns the random WebAuthn
13
+ # user handle (+webauthn_id+) on create. The host table needs a
14
+ # +webauthn_id:string+ column (the install generator adds it).
15
+ module Model
16
+ extend ActiveSupport::Concern
17
+
18
+ included do
19
+ has_many :passkeyed_credentials,
20
+ class_name: "Passkeyed::Credential",
21
+ as: :user,
22
+ dependent: :destroy
23
+
24
+ before_create :assign_passkeyed_webauthn_id
25
+ end
26
+
27
+ # The name passed to the authenticator at registration time. Prefers an
28
+ # +email+, then a +name+, falling back to the opaque handle. Override in
29
+ # your model if you want something else shown.
30
+ def passkey_name
31
+ return email if respond_to?(:email) && email.present?
32
+ return name if respond_to?(:name) && name.present?
33
+
34
+ passkeyed_webauthn_id!
35
+ end
36
+
37
+ # The human-friendly display name (WebAuthn's user.displayName) shown by
38
+ # account pickers, distinct from +passkey_name+ (the account identifier).
39
+ # Defaults to +passkey_name+; override to show e.g. a full name next to an
40
+ # email.
41
+ def passkey_display_name
42
+ passkey_name
43
+ end
44
+
45
+ # Rename one of this owner's passkeys (e.g. from a credential-management UI).
46
+ # Looked up *through the association*, so an id belonging to another owner
47
+ # raises ActiveRecord::RecordNotFound rather than letting one user relabel
48
+ # another's credential. A blank nickname clears the label. Returns the
49
+ # credential.
50
+ def rename_passkey(credential_id, nickname)
51
+ credential = passkeyed_credentials.find(credential_id)
52
+ credential.update!(nickname: nickname.presence)
53
+ credential
54
+ end
55
+
56
+ # Revoke (delete) one of this owner's passkeys. Scoped through the
57
+ # association like +rename_passkey+. Returns the destroyed credential; raises
58
+ # ActiveRecord::RecordNotFound if the id isn't this owner's.
59
+ #
60
+ # This does not stop a user removing their last passkey — guard against that
61
+ # in your app if it would lock the account out.
62
+ def revoke_passkey(credential_id)
63
+ passkeyed_credentials.find(credential_id).destroy!
64
+ end
65
+
66
+ # Return this record's WebAuthn user handle, assigning one when absent.
67
+ # +before_create+ only covers rows created after the gem is installed, so a
68
+ # pre-existing user (or one whose backfill was missed) would otherwise reach
69
+ # the ceremony with a nil handle, which webauthn-ruby drops from the options
70
+ # JSON and the browser then rejects. Persists the handle when the record is
71
+ # already saved; assigns it in memory otherwise (the +before_create+ keeps
72
+ # it for an unsaved record). Raises if the +webauthn_id+ column is missing.
73
+ def passkeyed_webauthn_id!
74
+ ensure_webauthn_id_column!
75
+ return webauthn_id if webauthn_id.present?
76
+
77
+ handle = WebAuthn.generate_user_id
78
+ self.webauthn_id = handle
79
+ update_column(:webauthn_id, handle) if persisted?
80
+ handle
81
+ end
82
+
83
+ private
84
+
85
+ def assign_passkeyed_webauthn_id
86
+ ensure_webauthn_id_column!
87
+ self.webauthn_id ||= WebAuthn.generate_user_id
88
+ end
89
+
90
+ def ensure_webauthn_id_column!
91
+ return if self.class.column_names.include?("webauthn_id")
92
+
93
+ raise Passkeyed::ConfigurationError,
94
+ "#{self.class.name} has no webauthn_id column; run the passkeyed install migration " \
95
+ "(bin/rails generate passkeyed:install && bin/rails db:migrate)"
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Passkeyed
4
+ VERSION = "0.1.0"
5
+ end
data/lib/passkeyed.rb ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webauthn"
4
+ require "active_support"
5
+
6
+ require "passkeyed/version"
7
+ require "passkeyed/errors"
8
+ require "passkeyed/configuration"
9
+
10
+ # passkeyed adds passwordless, usernameless passkey (WebAuthn) authentication
11
+ # to Rails apps as a small, transparent runtime plus an install generator.
12
+ #
13
+ # The heavier pieces (the ActiveRecord credential model and the two concerns)
14
+ # are autoloaded so the gem can be required before ActiveRecord is ready.
15
+ module Passkeyed
16
+ autoload :Credential, "passkeyed/credential"
17
+ autoload :Model, "passkeyed/model"
18
+ autoload :Ceremonies, "passkeyed/ceremonies"
19
+ end
@@ -0,0 +1,22 @@
1
+ module Passkeyed
2
+ module Ceremonies
3
+ def passkey_registration_options: (untyped user) -> untyped
4
+ def passkey_register!: (untyped user, untyped credential, ?nickname: String?) -> untyped
5
+ def passkey_authentication_options: () -> untyped
6
+ def passkey_authenticate!: (untyped credential) -> untyped
7
+
8
+ private
9
+
10
+ def after_passkey_registration: (untyped credential) -> void
11
+ def after_passkey_authentication: (untyped credential, untyped user) -> void
12
+ def verify_registration: (untyped user, untyped credential, ?nickname: String?) -> untyped
13
+ def verify_authentication: (untyped credential) -> untyped
14
+ def passkeyed_session: () -> untyped
15
+ def stash_challenge: (Symbol key, String challenge) -> void
16
+ def consume_challenge: (Symbol key, String ceremony) -> String
17
+ def credential_metadata: (untyped webauthn_credential) -> Hash[Symbol, untyped]
18
+ def passkeyed_user_verification?: () -> bool
19
+ def parse_credential: (Symbol method, untyped credential, untyped error_class) -> untyped
20
+ def normalize_credential: (untyped credential) -> untyped
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ module Passkeyed
2
+ class Configuration
3
+ USER_VERIFICATION_LEVELS: Array[String]
4
+
5
+ attr_accessor rp_name: String
6
+ attr_accessor rp_id: String?
7
+ attr_accessor allowed_origins: Array[String]?
8
+ attr_reader user_verification: String
9
+ attr_accessor session_key: Symbol
10
+ attr_reader challenge_timeout: Integer
11
+
12
+ def initialize: () -> void
13
+ def user_verification=: (untyped value) -> String
14
+ def challenge_timeout=: (untyped value) -> Integer
15
+ def registration_challenge_key: () -> Symbol
16
+ def authentication_challenge_key: () -> Symbol
17
+ # WebAuthn::RelyingParty (webauthn-ruby ships no RBS, so left untyped).
18
+ def relying_party: () -> untyped
19
+ def validate!: () -> void
20
+
21
+ private
22
+
23
+ def enforce_secure_origins?: () -> bool
24
+ end
25
+
26
+ def self.base_record_class: () -> untyped
27
+ def self.base_record_class=: (untyped) -> untyped
28
+ def self.configuration: () -> Configuration
29
+ def self.configure: () ?{ (Configuration) -> void } -> Configuration
30
+ def self.reset_configuration!: () -> Configuration
31
+ end
@@ -0,0 +1,9 @@
1
+ module Passkeyed
2
+ # Inherits Passkeyed.base_record_class (ApplicationRecord / ActiveRecord::Base)
3
+ # at load time; declared here against ActiveRecord::Base for typing.
4
+ class Credential < ActiveRecord::Base
5
+ def record_sign_in!: (Integer new_sign_count, ?backed_up: bool?) -> void
6
+ def transports: () -> Array[String]?
7
+ def transports=: (Array[String] | String | nil value) -> untyped
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ module Passkeyed
2
+ class Error < StandardError
3
+ end
4
+
5
+ class RegistrationError < Error
6
+ end
7
+
8
+ class AuthenticationError < Error
9
+ end
10
+
11
+ class CredentialNotFound < AuthenticationError
12
+ end
13
+
14
+ class ChallengeMissing < Error
15
+ end
16
+
17
+ class ChallengeExpired < ChallengeMissing
18
+ end
19
+
20
+ class ConfigurationError < Error
21
+ end
22
+ end
@@ -0,0 +1,16 @@
1
+ module Passkeyed
2
+ module Model
3
+ extend ActiveSupport::Concern
4
+
5
+ def passkey_name: () -> String
6
+ def passkey_display_name: () -> String
7
+ def rename_passkey: (untyped credential_id, String? nickname) -> untyped
8
+ def revoke_passkey: (untyped credential_id) -> untyped
9
+ def passkeyed_webauthn_id!: () -> String
10
+
11
+ private
12
+
13
+ def assign_passkeyed_webauthn_id: () -> void
14
+ def ensure_webauthn_id_column!: () -> void
15
+ end
16
+ end
@@ -0,0 +1,3 @@
1
+ module Passkeyed
2
+ VERSION: String
3
+ end
metadata ADDED
@@ -0,0 +1,208 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: passkeyed
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Paul Ardeleanu
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 2026-07-05 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: railties
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webauthn
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.4'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '3.4'
68
+ - !ruby/object:Gem::Dependency
69
+ name: minitest
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '5.0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '5.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: rake
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '13.0'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '13.0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '1.60'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '1.60'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rubocop-minitest
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0.34'
117
+ type: :development
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0.34'
124
+ - !ruby/object:Gem::Dependency
125
+ name: rubocop-rake
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - ">="
129
+ - !ruby/object:Gem::Version
130
+ version: '0.6'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - ">="
136
+ - !ruby/object:Gem::Version
137
+ version: '0.6'
138
+ - !ruby/object:Gem::Dependency
139
+ name: sqlite3
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '1.6'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '1.6'
152
+ description: |-
153
+ passkeyed adds passwordless, usernameless passkey sign-in to Rails apps:
154
+ a small, transparent runtime (a model concern, a controller concern, and a
155
+ Stimulus controller) plus an install generator, built on webauthn-ruby.
156
+ email:
157
+ - pardel@gmail.com
158
+ executables: []
159
+ extensions: []
160
+ extra_rdoc_files: []
161
+ files:
162
+ - LICENSE.txt
163
+ - README.md
164
+ - lib/generators/passkeyed/install/USAGE
165
+ - lib/generators/passkeyed/install/install_generator.rb
166
+ - lib/generators/passkeyed/install/templates/create_passkeyed_credentials.rb.erb
167
+ - lib/generators/passkeyed/install/templates/initializer.rb
168
+ - lib/generators/passkeyed/install/templates/passkey_controller.js
169
+ - lib/passkeyed.rb
170
+ - lib/passkeyed/ceremonies.rb
171
+ - lib/passkeyed/configuration.rb
172
+ - lib/passkeyed/credential.rb
173
+ - lib/passkeyed/errors.rb
174
+ - lib/passkeyed/model.rb
175
+ - lib/passkeyed/version.rb
176
+ - sig/passkeyed/ceremonies.rbs
177
+ - sig/passkeyed/configuration.rbs
178
+ - sig/passkeyed/credential.rbs
179
+ - sig/passkeyed/errors.rbs
180
+ - sig/passkeyed/model.rbs
181
+ - sig/passkeyed/version.rbs
182
+ homepage: https://github.com/pardel/passkeyed
183
+ licenses:
184
+ - MIT
185
+ metadata:
186
+ homepage_uri: https://github.com/pardel/passkeyed
187
+ source_code_uri: https://github.com/pardel/passkeyed
188
+ bug_tracker_uri: https://github.com/pardel/passkeyed/issues
189
+ documentation_uri: https://rubydoc.info/gems/passkeyed
190
+ rubygems_mfa_required: 'true'
191
+ rdoc_options: []
192
+ require_paths:
193
+ - lib
194
+ required_ruby_version: !ruby/object:Gem::Requirement
195
+ requirements:
196
+ - - ">="
197
+ - !ruby/object:Gem::Version
198
+ version: '3.1'
199
+ required_rubygems_version: !ruby/object:Gem::Requirement
200
+ requirements:
201
+ - - ">="
202
+ - !ruby/object:Gem::Version
203
+ version: '0'
204
+ requirements: []
205
+ rubygems_version: 3.6.2
206
+ specification_version: 4
207
+ summary: Passwordless passkey (WebAuthn) authentication for Rails.
208
+ test_files: []