better_auth-passkey 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a9d897b27289670fa9776d35b6f8d7e24232626b3079feaa9df4989bf1778d41
4
- data.tar.gz: 42ca8da646e0db4a2c4ce0c7380a6bd657385470beddfa0a3e45f4d33179f578
3
+ metadata.gz: bd306893b45a068e5d878de3b262cae1283fd77c4b32a572c389940cf0802bd9
4
+ data.tar.gz: b28b537cc8cf727bc5b7d4c9c370b0d32953f80e25137f097cd5c80b1e417221
5
5
  SHA512:
6
- metadata.gz: 653ff4209165b99511fda3f9bf433069b3ffd85915f88a014913ae9671e18abfcec1fd283a72d2014db6b9baca97441fb99ca658cb3f36b4548bb8294d22a201
7
- data.tar.gz: 11af45bc047d37897caa689ef15d5dda7bd794b17c6c6a607c00e6d3b475a5f9907af6c941a7f4bb07ec5b6503587d8c1c3285dcdcc1071853f55167f248d21f
6
+ metadata.gz: d18c881ba150a5bd6ae0f635b8b45ecffc09936292995448b301ef5a990e6fef4715c208aee4a067049e95c69b6478915771dd4a53c28e3e061a88fa02b19be0
7
+ data.tar.gz: 4dc7a18a75749e3c53374ab5183d690a3160c581d7fbdad150eaf86c25cf9534a83354a2a317e79639e0af6ec97afae70e3f0daec6d4ab68dbb5ed0af53966bc
data/CHANGELOG.md CHANGED
@@ -2,4 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-04-29
6
+
7
+ - Aligned passkey registration, authentication, verification, origin handling, credential metadata, and route behavior with upstream Better Auth v1.6.9.
8
+ - Expanded passkey documentation and test coverage for upstream server parity.
9
+
10
+ ## [0.1.0] - 2026-04-28
11
+
5
12
  - Initial external passkey package extracted from `better_auth`.
data/README.md CHANGED
@@ -17,11 +17,99 @@ auth = BetterAuth.auth(
17
17
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
18
18
  database: :memory,
19
19
  plugins: [
20
- BetterAuth::Plugins.passkey
20
+ BetterAuth::Plugins.passkey(
21
+ rp_id: "localhost",
22
+ rp_name: "Example App",
23
+ origin: "http://localhost:3000"
24
+ )
21
25
  ]
22
26
  )
23
27
  ```
24
28
 
29
+ ## Options
30
+
31
+ `BetterAuth::Plugins.passkey` accepts Ruby `snake_case` options:
32
+
33
+ - `rp_id`: WebAuthn relying party ID. Defaults to the configured `base_url` host.
34
+ - `rp_name`: WebAuthn relying party name. Defaults to the Better Auth app name.
35
+ - `origin`: allowed WebAuthn origin or array of origins.
36
+ - `authenticator_selection`: supports `resident_key`, `user_verification`, and `authenticator_attachment`.
37
+ - `advanced.web_authn_challenge_cookie`: challenge cookie name. Defaults to `better-auth-passkey`.
38
+ - `registration`: supports `require_session`, `resolve_user`, `after_verification`, and `extensions`.
39
+ - `authentication`: supports `after_verification` and `extensions`.
40
+ - `schema`: deep-merged schema overrides. The built-in SQL table remains `passkeys`, matching the Ruby adapter convention.
41
+
42
+ HTTP routes and wire JSON keys are kept compatible with upstream Better Auth passkey server behavior. Ruby method names and configuration keys remain idiomatic `snake_case`.
43
+
44
+ ## Passkey-first registration
45
+
46
+ Use `require_session: false` to register a passkey before a session exists:
47
+
48
+ ```ruby
49
+ BetterAuth::Plugins.passkey(
50
+ registration: {
51
+ require_session: false,
52
+ resolve_user: lambda do |data|
53
+ invitation = Invitations.verify!(data.fetch(:context))
54
+ {
55
+ id: invitation.user_id,
56
+ name: invitation.email,
57
+ display_name: invitation.name,
58
+ email: invitation.email
59
+ }
60
+ end,
61
+ after_verification: lambda do |data|
62
+ Audit.passkey_registered!(
63
+ user_id: data.fetch(:user).fetch(:id),
64
+ context: data.fetch(:context)
65
+ )
66
+ nil
67
+ end
68
+ }
69
+ )
70
+ ```
71
+
72
+ Pass `context` when generating registration options:
73
+
74
+ ```ruby
75
+ auth.api.generate_passkey_registration_options(query: { context: invitation_token })
76
+ ```
77
+
78
+ During passkey-first registration, `after_verification` may return `{ user_id: "..." }` to attach the credential to a concrete user. During session-required registration, switching users is rejected.
79
+
80
+ ## WebAuthn extensions
81
+
82
+ ```ruby
83
+ BetterAuth::Plugins.passkey(
84
+ registration: {
85
+ extensions: { credProps: true }
86
+ },
87
+ authentication: {
88
+ extensions: ->(_data) { { hmacGetSecret: true } }
89
+ }
90
+ )
91
+ ```
92
+
93
+ ## Browser client scope
94
+
95
+ This gem provides server WebAuthn routes. It does not ship the upstream browser-only `@better-auth/passkey/client` helper, `passkeyClient`, `startRegistration`, `startAuthentication`, conditional UI, autofill, or extension-result handling. Use the browser WebAuthn APIs directly or wrap them in application JavaScript.
96
+
97
+ ## WebAuthn configuration
98
+
99
+ The plugin uses `WebAuthn::RelyingParty` per request for `rp_id`, `rp_name`, and allowed origins. It does not mutate global `WebAuthn.configuration`, so multiple Better Auth instances can use different relying-party settings in the same Ruby process.
100
+
101
+ ## Upstream parity notes
102
+
103
+ The Ruby plugin tracks Better Auth `v1.6.9` upstream behavior. A few wire-shape and validation details are worth noting:
104
+
105
+ - `excludeCredentials` entries (registration options) are emitted as `{id, transports?}` to match upstream's `@simplewebauthn/server` output. `allowCredentials` (authentication options) still includes `type: "public-key"` to mirror upstream's authentication wire shape.
106
+ - `transports` is omitted entirely from credential descriptors when the stored value is missing or empty (rather than emitting an empty array).
107
+ - The default storage table is named `passkeys` (plural) in the SQL adapters, mapped from the upstream `passkey` model. Custom SQL adapters that translate the `passkey` model name continue to work.
108
+ - `rp_id` resolution falls back to `URI.parse(base_url).host` (port stripped). When `base_url` is empty or unparseable, `rp_id` defaults to `"localhost"`.
109
+ - For passkey-first registration, the `after_verification` callback may return `{ user_id: nil }` or `{ user_id: "" }` to leave the resolved user unchanged. Returning any other non-empty-string value (integer, boolean, etc.) raises `RESOLVED_USER_INVALID`.
110
+ - `update_passkey` accepts an empty-string `name` to match upstream `z.string()`. Missing or non-string `name` still raises `VALIDATION_ERROR`.
111
+ - Cross-user `delete_passkey` raises `UNAUTHORIZED` with the `PASSKEY_NOT_FOUND` message, mirroring upstream's `requireResourceOwnership` middleware behavior when only `notFoundError` is configured.
112
+
25
113
  ## Notes
26
114
 
27
115
  This package depends on the maintained `webauthn` gem. Keeping passkeys outside `better_auth` avoids installing WebAuthn dependencies for applications that do not use passkeys.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Passkey
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
@@ -62,8 +62,9 @@ module BetterAuth
62
62
  def generate_passkey_registration_options_endpoint(config)
63
63
  Endpoint.new(path: "/passkey/generate-register-options", method: "GET") do |ctx|
64
64
  query = normalize_hash(ctx.query)
65
+ passkey_validate_authenticator_attachment!(query[:authenticator_attachment])
65
66
  user = passkey_resolve_registration_user(config, ctx, query)
66
- passkey_configure_webauthn(config, ctx)
67
+ relying_party = passkey_relying_party(config, ctx)
67
68
  existing = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: user.fetch("id")}])
68
69
  options = WebAuthn::Credential.options_for_create(
69
70
  user: {
@@ -73,43 +74,53 @@ module BetterAuth
73
74
  },
74
75
  exclude: existing.map { |passkey| passkey_credential_id(passkey) },
75
76
  authenticator_selection: passkey_authenticator_selection(config, query),
76
- extensions: passkey_resolve_extensions(config.dig(:registration, :extensions), ctx)
77
+ extensions: passkey_resolve_extensions(config.dig(:registration, :extensions), ctx),
78
+ relying_party: relying_party
77
79
  )
78
80
  passkey_store_challenge(ctx, config, options.challenge, {
79
81
  id: user.fetch("id"),
80
82
  name: user["name"] || user["email"] || user["id"],
81
83
  displayName: user["displayName"] || user["display_name"]
82
84
  }.compact)
83
- ctx.json(options.as_json.merge(excludeCredentials: existing.map { |passkey| passkey_credential_descriptor(passkey) }))
85
+ ctx.json(options.as_json.merge(attestation: "none", excludeCredentials: existing.map { |passkey| passkey_credential_descriptor(passkey, kind: :exclude) }))
84
86
  end
85
87
  end
86
88
 
87
89
  def generate_passkey_authentication_options_endpoint(config)
88
90
  Endpoint.new(path: "/passkey/generate-authenticate-options", method: "GET") do |ctx|
89
91
  session = Routes.current_session(ctx, allow_nil: true)
90
- passkey_configure_webauthn(config, ctx)
92
+ relying_party = passkey_relying_party(config, ctx)
91
93
  passkeys = if session
92
94
  ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
93
95
  else
94
96
  []
95
97
  end
96
- options = WebAuthn::Credential.options_for_get(
97
- allow: passkeys.map { |passkey| passkey_credential_id(passkey) },
98
- extensions: passkey_resolve_extensions(config.dig(:authentication, :extensions), ctx)
99
- )
98
+ get_options = {
99
+ extensions: passkey_resolve_extensions(config.dig(:authentication, :extensions), ctx),
100
+ relying_party: relying_party
101
+ }
102
+ get_options[:allow] = passkeys.map { |passkey| passkey_credential_id(passkey) } if passkeys.any?
103
+ options = WebAuthn::Credential.options_for_get(**get_options)
100
104
  passkey_store_challenge(ctx, config, options.challenge, session ? session.fetch(:user).fetch("id") : "")
101
105
  payload = options.as_json.merge(userVerification: "preferred")
102
- payload[:allowCredentials] = passkeys.map { |passkey| passkey_credential_descriptor(passkey) } if passkeys.any?
106
+ if passkeys.any?
107
+ payload[:allowCredentials] = passkeys.map { |passkey| passkey_credential_descriptor(passkey) }
108
+ else
109
+ payload.delete(:allowCredentials)
110
+ payload.delete("allowCredentials")
111
+ end
103
112
  ctx.json(payload)
104
113
  end
105
114
  end
106
115
 
107
116
  def verify_passkey_registration_endpoint(config)
108
117
  Endpoint.new(path: "/passkey/verify-registration", method: "POST") do |ctx|
118
+ body = normalize_hash(ctx.body)
119
+ passkey_require_key!(body, :response)
109
120
  require_session = config.dig(:registration, :require_session) != false
110
- session = require_session ? Routes.current_session(ctx, sensitive: true) : Routes.current_session(ctx, allow_nil: true, sensitive: true)
121
+ session = require_session ? Routes.current_session(ctx, sensitive: true) : Routes.current_session(ctx, allow_nil: true)
111
122
  origin = passkey_origin(config, ctx)
112
- raise APIError.new("BAD_REQUEST", message: "origin missing") if origin.to_s.empty?
123
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION")) if origin.to_s.empty?
113
124
 
114
125
  verification_token = passkey_challenge_token(ctx, config)
115
126
  raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
@@ -122,12 +133,11 @@ module BetterAuth
122
133
  raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
123
134
  end
124
135
 
125
- response = passkey_webauthn_response(normalize_hash(ctx.body)[:response])
126
- passkey_configure_webauthn(config, ctx, origin: origin)
127
- credential = WebAuthn::Credential.from_create(response)
136
+ response = passkey_webauthn_response(body[:response])
137
+ relying_party = passkey_relying_party(config, ctx, origin: origin)
138
+ credential = WebAuthn::Credential.from_create(response, relying_party: relying_party)
128
139
  credential.verify(challenge.fetch("expectedChallenge"), user_verification: false)
129
140
  authenticator_data = passkey_authenticator_data(credential)
130
- body = normalize_hash(ctx.body)
131
141
  target_user_id = passkey_after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
132
142
  data = ctx.context.adapter.create(
133
143
  model: "passkey",
@@ -154,6 +164,8 @@ module BetterAuth
154
164
 
155
165
  def verify_passkey_authentication_endpoint(config)
156
166
  Endpoint.new(path: "/passkey/verify-authentication", method: "POST") do |ctx|
167
+ body = normalize_hash(ctx.body)
168
+ passkey_require_key!(body, :response)
157
169
  origin = passkey_origin(config, ctx)
158
170
  raise APIError.new("BAD_REQUEST", message: "origin missing") if origin.to_s.empty?
159
171
 
@@ -163,13 +175,13 @@ module BetterAuth
163
175
  challenge = passkey_find_challenge(ctx, verification_token)
164
176
  raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless challenge
165
177
 
166
- response = passkey_webauthn_response(normalize_hash(ctx.body)[:response])
178
+ response = passkey_webauthn_response(body[:response])
167
179
  credential_id = response.fetch("id")
168
180
  passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "credentialID", value: credential_id}])
169
181
  raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
170
182
 
171
- passkey_configure_webauthn(config, ctx, origin: origin)
172
- credential = WebAuthn::Credential.from_get(response)
183
+ relying_party = passkey_relying_party(config, ctx, origin: origin)
184
+ credential = WebAuthn::Credential.from_get(response, relying_party: relying_party)
173
185
  credential.verify(
174
186
  challenge.fetch("expectedChallenge"),
175
187
  public_key: Base64.strict_decode64(passkey.fetch("publicKey")),
@@ -213,9 +225,12 @@ module BetterAuth
213
225
  Endpoint.new(path: "/passkey/delete-passkey", method: "POST") do |ctx|
214
226
  session = Routes.current_session(ctx)
215
227
  body = normalize_hash(ctx.body)
228
+ passkey_require_string!(body, :id)
216
229
  passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
217
230
  raise APIError.new("NOT_FOUND", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
218
- raise APIError.new("UNAUTHORIZED") unless passkey.fetch("userId") == session.fetch(:user).fetch("id")
231
+ unless passkey.fetch("userId") == session.fetch(:user).fetch("id")
232
+ raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND"))
233
+ end
219
234
 
220
235
  ctx.context.adapter.delete(model: "passkey", where: [{field: "id", value: passkey.fetch("id")}])
221
236
  ctx.json({status: true})
@@ -226,6 +241,11 @@ module BetterAuth
226
241
  Endpoint.new(path: "/passkey/update-passkey", method: "POST") do |ctx|
227
242
  session = Routes.current_session(ctx)
228
243
  body = normalize_hash(ctx.body)
244
+ passkey_require_string!(body, :id)
245
+ unless body.key?(:name) && body[:name].is_a?(String)
246
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
247
+ end
248
+
229
249
  passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
230
250
  raise APIError.new("NOT_FOUND", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
231
251
  if passkey.fetch("userId") != session.fetch(:user).fetch("id")
@@ -261,11 +281,7 @@ module BetterAuth
261
281
  }
262
282
  }
263
283
  }
264
- return base unless custom_schema.is_a?(Hash)
265
-
266
- base.merge(custom_schema) do |_key, old_value, new_value|
267
- (old_value.is_a?(Hash) && new_value.is_a?(Hash)) ? old_value.merge(new_value) : new_value
268
- end
284
+ passkey_deep_merge_hashes(normalize_hash(base), normalize_hash(custom_schema || {}))
269
285
  end
270
286
 
271
287
  def passkey_store_challenge(ctx, config, challenge, user_id)
@@ -301,10 +317,12 @@ module BetterAuth
301
317
  ctx.context.create_auth_cookie(config.dig(:advanced, :web_authn_challenge_cookie), max_age: PASSKEY_CHALLENGE_MAX_AGE)
302
318
  end
303
319
 
304
- def passkey_configure_webauthn(config, ctx, origin: nil)
305
- WebAuthn.configuration.rp_id = passkey_rp_id(config, ctx)
306
- WebAuthn.configuration.rp_name = config[:rp_name] || ctx.context.app_name
307
- WebAuthn.configuration.allowed_origins = passkey_allowed_origins(config, ctx, origin: origin)
320
+ def passkey_relying_party(config, ctx, origin: nil)
321
+ WebAuthn::RelyingParty.new(
322
+ id: passkey_rp_id(config, ctx),
323
+ name: config[:rp_name] || ctx.context.app_name,
324
+ allowed_origins: passkey_allowed_origins(config, ctx, origin: origin)
325
+ )
308
326
  end
309
327
 
310
328
  def passkey_origin(config, ctx)
@@ -318,7 +336,10 @@ module BetterAuth
318
336
  def passkey_rp_id(config, ctx)
319
337
  return config[:rp_id] if config[:rp_id]
320
338
 
321
- URI.parse(ctx.context.options.base_url.to_s).host || "localhost"
339
+ base_url = ctx.context.options.base_url.to_s
340
+ return "localhost" if base_url.empty?
341
+
342
+ URI.parse(base_url).host || "localhost"
322
343
  rescue URI::InvalidURIError
323
344
  "localhost"
324
345
  end
@@ -334,6 +355,25 @@ module BetterAuth
334
355
  }.compact
335
356
  end
336
357
 
358
+ def passkey_validate_authenticator_attachment!(value)
359
+ return if value.nil? || ["platform", "cross-platform"].include?(value)
360
+
361
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
362
+ end
363
+
364
+ def passkey_require_key!(body, key)
365
+ return if body.key?(key)
366
+
367
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
368
+ end
369
+
370
+ def passkey_require_string!(body, key)
371
+ passkey_require_key!(body, key)
372
+ return if body[key].is_a?(String)
373
+
374
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
375
+ end
376
+
337
377
  def passkey_resolve_registration_user(config, ctx, query)
338
378
  require_session = config.dig(:registration, :require_session) != false
339
379
  if require_session
@@ -409,9 +449,9 @@ module BetterAuth
409
449
  context: challenge["context"]
410
450
  }) || {})
411
451
  returned_user_id = result[:user_id]
412
- return target_user_id if returned_user_id.to_s.empty?
452
+ return target_user_id if returned_user_id.nil? || returned_user_id == ""
413
453
 
414
- unless returned_user_id.is_a?(String)
454
+ unless returned_user_id.is_a?(String) && returned_user_id.length.positive?
415
455
  raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("RESOLVED_USER_INVALID"))
416
456
  end
417
457
 
@@ -474,14 +514,22 @@ module BetterAuth
474
514
  record["credentialID"] || record["credentialId"] || record[:credentialID] || record[:credential_id]
475
515
  end
476
516
 
477
- def passkey_credential_descriptor(record)
478
- descriptor = {
479
- id: passkey_credential_id(record),
480
- type: "public-key"
481
- }
517
+ def passkey_credential_descriptor(record, kind: :allow)
518
+ descriptor = {id: passkey_credential_id(record)}
519
+ descriptor[:type] = "public-key" if kind == :allow
482
520
  transports = (record["transports"] || record[:transports]).to_s.split(",").map(&:strip).reject(&:empty?)
483
521
  descriptor[:transports] = transports if transports.any?
484
522
  descriptor
485
523
  end
524
+
525
+ def passkey_deep_merge_hashes(base, override)
526
+ base.merge(override) do |_key, old_value, new_value|
527
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
528
+ passkey_deep_merge_hashes(old_value, new_value)
529
+ else
530
+ new_value
531
+ end
532
+ end
533
+ end
486
534
  end
487
535
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-passkey
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala