better_auth-passkey 0.6.2 → 0.7.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: 9b348cd40c439788196f83400ac1fe16c085e18d900e18b9e3dd006b43251109
4
- data.tar.gz: 326ca4a6f5f7f5cf1b11402f50ef27fc0ee35d10f2d71996e4c1a0d66695a209
3
+ metadata.gz: f9ac8e58f88a438814e567da18b4e9743d720288349cbffb2fe66ae5633bf3b1
4
+ data.tar.gz: ba9f004353696dd528b6c8e65d6901c17921c6875645f139bbec335cae5a067f
5
5
  SHA512:
6
- metadata.gz: aca06e7b35e60d2912aea58cd58f742c2de1334557e1ed823dd0fc5100dd20336ef69e39d560c1a795636f05e4fcd856484d7fd584102fae7933e8980e4d6d40
7
- data.tar.gz: d572aa343e48f18f2374dfcc6c06e648481206493a70cc613fd09cdd22fb2e46532a0d1a390f863c4ae10fbb6d0110a82446893f4036f9e5afdf3f3fe43e5ed8
6
+ metadata.gz: 70a070660b5cb7cbcef46e20e30b8b08ec8ae37b47e6a3dcdacfe446c396efade1cdf25a08afd544ac356bbadf82e5b5c51785354d88758da79f380e11bb4e28
7
+ data.tar.gz: b28373cda543efd6d73eb2a4d9fe22aa53daccb2c7a517204b82fd01398dc9d6ac50eb485b1edec12cc3cfff960c6d40f99f0e2c81b6c837e8692c87002a7974
data/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.0] - 2026-05-05
6
+
7
+ - Require a fresh session for session-required passkey registration verification.
8
+ - Return `BAD_REQUEST` for passkey registration WebAuthn verification failures while preserving `INTERNAL_SERVER_ERROR` for unexpected failures.
9
+ - Invalidate stored WebAuthn challenges after failed registration or authentication verification attempts.
10
+ - Read passkey attestation metadata via the public `credential.response` API from the `webauthn` gem.
11
+ - Invalidate authentication challenges after all terminal failures once a valid challenge is loaded, including missing credentials, callback errors, and session creation failures.
12
+ - Reject duplicate registered WebAuthn credential IDs with `PREVIOUSLY_REGISTERED` and mark `credentialID` unique in the passkey schema.
13
+
5
14
  ## [0.2.0] - 2026-04-29
6
15
 
7
16
  - Aligned passkey registration, authentication, verification, origin handling, credential metadata, and route behavior with upstream Better Auth v1.6.9.
data/README.md CHANGED
@@ -85,8 +85,16 @@ Ruby uses hashes for the upstream TypeScript contracts:
85
85
  - `resolve_user` receives `{ ctx:, context: }` and must return at least `id` and `name`; it may also return `display_name` and `email`.
86
86
  - Registration `after_verification` receives `{ ctx:, verification:, user:, client_data:, context: }`.
87
87
  - Authentication `after_verification` receives `{ ctx:, verification:, client_data: }`.
88
+ - Callback `verification` values are objects from the Ruby `webauthn` gem. They are not the TypeScript `VerifiedRegistrationResponse` or `VerifiedAuthenticationResponse` structs from upstream's Node implementation.
88
89
  - Passkey records use upstream wire keys including `userId`, `credentialID`, `publicKey`, `deviceType`, `backedUp`, `createdAt`, and optional `aaguid`.
89
90
 
91
+ ### Ruby vs TypeScript callback shapes
92
+
93
+ Callback parity is behavioral at the HTTP JSON boundary, not a static export of
94
+ the TypeScript interfaces from `@better-auth/passkey`. Treat
95
+ `data[:verification]` as the Ruby `webauthn` verification result or hash-like
96
+ object produced by this gem, not as a SimpleWebAuthn DTO.
97
+
90
98
  ## WebAuthn extensions
91
99
 
92
100
  ```ruby
@@ -115,10 +123,12 @@ The Ruby plugin tracks Better Auth `v1.6.9` upstream behavior. A few wire-shape
115
123
  - `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.
116
124
  - `transports` is omitted entirely from credential descriptors when the stored value is missing or empty (rather than emitting an empty array).
117
125
  - 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.
126
+ - `credentialID` is unique in the Ruby schema. This is intentional hardening beyond upstream v1.6.9 and prevents the same WebAuthn credential from being stored more than once.
118
127
  - `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"`.
119
128
  - 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`.
120
129
  - `update_passkey` accepts an empty-string `name` to match upstream `z.string()`. Missing or non-string `name` still raises `VALIDATION_ERROR`.
121
130
  - Cross-user `delete_passkey` raises `UNAUTHORIZED` with the `PASSKEY_NOT_FOUND` message, mirroring upstream's `requireResourceOwnership` middleware behavior when only `notFoundError` is configured.
131
+ - Existing databases should deduplicate historical `credential_id` values before adding the unique constraint during migration.
122
132
 
123
133
  ## Notes
124
134
 
@@ -28,7 +28,7 @@ module BetterAuth
28
28
  end
29
29
 
30
30
  def attestation_response(credential)
31
- credential.instance_variable_get(:@response)
31
+ credential.respond_to?(:response) ? credential.response : nil
32
32
  end
33
33
 
34
34
  def authenticator_data(credential)
@@ -51,41 +51,51 @@ module BetterAuth
51
51
  challenge = Challenges.find_challenge(ctx, verification_token)
52
52
  raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless challenge
53
53
 
54
- response = Credentials.webauthn_response(body[:response])
55
- credential_id = response.fetch("id")
56
- passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "credentialID", value: credential_id}])
57
- raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
54
+ begin
55
+ response = Credentials.webauthn_response(body[:response])
56
+ credential_id = response.fetch("id")
57
+ passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "credentialID", value: credential_id}])
58
+ raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
58
59
 
59
- relying_party = Utils.relying_party(config, ctx, origin: origin)
60
- credential = WebAuthn::Credential.from_get(response, relying_party: relying_party)
61
- credential.verify(
62
- challenge.fetch("expectedChallenge"),
63
- public_key: Base64.strict_decode64(passkey.fetch("publicKey")),
64
- sign_count: passkey.fetch("counter").to_i,
65
- user_verification: false
66
- )
67
- Utils.call_callback(config.dig(:authentication, :after_verification), {
68
- ctx: ctx,
69
- verification: credential,
70
- client_data: response
71
- })
72
- ctx.context.adapter.update(
73
- model: "passkey",
74
- where: [{field: "id", value: passkey.fetch("id")}],
75
- update: {counter: credential.sign_count}
76
- )
77
- session = ctx.context.internal_adapter.create_session(passkey.fetch("userId"))
78
- raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("UNABLE_TO_CREATE_SESSION")) unless session
60
+ relying_party = Utils.relying_party(config, ctx, origin: origin)
61
+ credential = WebAuthn::Credential.from_get(response, relying_party: relying_party)
62
+ credential.verify(
63
+ challenge.fetch("expectedChallenge"),
64
+ public_key: Base64.strict_decode64(passkey.fetch("publicKey")),
65
+ sign_count: passkey.fetch("counter").to_i,
66
+ user_verification: false
67
+ )
68
+ Utils.call_callback(config.dig(:authentication, :after_verification), {
69
+ ctx: ctx,
70
+ verification: credential,
71
+ client_data: response
72
+ })
73
+ ctx.context.adapter.update(
74
+ model: "passkey",
75
+ where: [{field: "id", value: passkey.fetch("id")}],
76
+ update: {counter: credential.sign_count}
77
+ )
78
+ session = ctx.context.internal_adapter.create_session(passkey.fetch("userId"))
79
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("UNABLE_TO_CREATE_SESSION")) unless session
79
80
 
80
- user = ctx.context.internal_adapter.find_user_by_id(passkey.fetch("userId"))
81
- raise APIError.new("INTERNAL_SERVER_ERROR", message: "User not found") unless user
81
+ user = ctx.context.internal_adapter.find_user_by_id(passkey.fetch("userId"))
82
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "User not found") unless user
82
83
 
83
- Cookies.set_session_cookie(ctx, {session: session, user: user})
84
- ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
85
- ctx.json({session: session, user: user})
86
- rescue WebAuthn::Error, ArgumentError => error
87
- ctx.context.logger&.error("Failed to verify authentication", error)
88
- raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("AUTHENTICATION_FAILED"))
84
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
85
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
86
+ ctx.json({session: session, user: user})
87
+ rescue WebAuthn::Error, ArgumentError => error
88
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
89
+ ctx.context.logger&.error("Failed to verify authentication", error)
90
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("AUTHENTICATION_FAILED"))
91
+ rescue APIError
92
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
93
+ raise
94
+ rescue => error
95
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
96
+ ctx.context.logger&.error("Failed to verify authentication", error)
97
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("AUTHENTICATION_FAILED"))
98
+ end
89
99
  end
90
100
  end
91
101
  end
@@ -44,7 +44,7 @@ module BetterAuth
44
44
  body = Utils.normalize_hash(ctx.body)
45
45
  Utils.require_key!(body, :response)
46
46
  require_session = config.dig(:registration, :require_session) != false
47
- session = require_session ? BetterAuth::Routes.current_session(ctx, sensitive: true) : BetterAuth::Routes.current_session(ctx, allow_nil: true)
47
+ session = require_session ? BetterAuth::Routes.current_session(ctx, sensitive: true, fresh: true) : BetterAuth::Routes.current_session(ctx, allow_nil: true)
48
48
  origin = Utils.origin(config, ctx)
49
49
  raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION")) if origin.to_s.empty?
50
50
 
@@ -56,15 +56,32 @@ module BetterAuth
56
56
  raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND"))
57
57
  end
58
58
  if session && challenge.fetch("userData").fetch("id") != session.fetch(:user).fetch("id")
59
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
59
60
  raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
60
61
  end
61
62
 
62
- response = Credentials.webauthn_response(body[:response])
63
- relying_party = Utils.relying_party(config, ctx, origin: origin)
64
- credential = WebAuthn::Credential.from_create(response, relying_party: relying_party)
65
- credential.verify(challenge.fetch("expectedChallenge"), user_verification: false)
66
- authenticator_data = Credentials.authenticator_data(credential)
67
- target_user_id = Utils.after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
63
+ begin
64
+ response = Credentials.webauthn_response(body[:response])
65
+ relying_party = Utils.relying_party(config, ctx, origin: origin)
66
+ credential = WebAuthn::Credential.from_create(response, relying_party: relying_party)
67
+ credential.verify(challenge.fetch("expectedChallenge"), user_verification: false)
68
+ authenticator_data = Credentials.authenticator_data(credential)
69
+ target_user_id = Utils.after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
70
+ rescue WebAuthn::Error, ArgumentError => error
71
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
72
+ ctx.context.logger&.error("Failed to verify registration", error)
73
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION"))
74
+ rescue APIError
75
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
76
+ raise
77
+ end
78
+
79
+ existing_passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "credentialID", value: credential.id}])
80
+ if existing_passkey
81
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
82
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PREVIOUSLY_REGISTERED"))
83
+ end
84
+
68
85
  data = ctx.context.adapter.create(
69
86
  model: "passkey",
70
87
  data: {
@@ -82,7 +99,10 @@ module BetterAuth
82
99
  )
83
100
  ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
84
101
  ctx.json(Credentials.wire(data))
85
- rescue WebAuthn::Error => error
102
+ rescue APIError
103
+ raise
104
+ rescue => error
105
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token) if verification_token
86
106
  ctx.context.logger&.error("Failed to verify registration", error)
87
107
  raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION"))
88
108
  end
@@ -13,7 +13,7 @@ module BetterAuth
13
13
  name: {type: "string", required: false},
14
14
  publicKey: {type: "string", required: true},
15
15
  userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true},
16
- credentialID: {type: "string", required: true, index: true},
16
+ credentialID: {type: "string", required: true, unique: true},
17
17
  counter: {type: "number", required: true},
18
18
  deviceType: {type: "string", required: true},
19
19
  backedUp: {type: "boolean", required: true},
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Passkey
5
- VERSION = "0.6.2"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  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.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala