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 +4 -4
- data/CHANGELOG.md +9 -0
- data/README.md +10 -0
- data/lib/better_auth/passkey/credentials.rb +1 -1
- data/lib/better_auth/passkey/routes/authentication.rb +42 -32
- data/lib/better_auth/passkey/routes/registration.rb +28 -8
- data/lib/better_auth/passkey/schema.rb +1 -1
- data/lib/better_auth/passkey/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9ac8e58f88a438814e567da18b4e9743d720288349cbffb2fe66ae5633bf3b1
|
|
4
|
+
data.tar.gz: ba9f004353696dd528b6c8e65d6901c17921c6875645f139bbec335cae5a067f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
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,
|
|
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},
|