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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +89 -1
- data/lib/better_auth/passkey/version.rb +1 -1
- data/lib/better_auth/plugins/passkey.rb +84 -36
- 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: bd306893b45a068e5d878de3b262cae1283fd77c4b32a572c389940cf0802bd9
|
|
4
|
+
data.tar.gz: b28b537cc8cf727bc5b7d4c9c370b0d32953f80e25137f097cd5c80b1e417221
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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(
|
|
126
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
WebAuthn.
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|