better_auth-passkey 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a9d897b27289670fa9776d35b6f8d7e24232626b3079feaa9df4989bf1778d41
4
+ data.tar.gz: 42ca8da646e0db4a2c4ce0c7380a6bd657385470beddfa0a3e45f4d33179f578
5
+ SHA512:
6
+ metadata.gz: 653ff4209165b99511fda3f9bf433069b3ffd85915f88a014913ae9671e18abfcec1fd283a72d2014db6b9baca97441fb99ca658cb3f36b4548bb8294d22a201
7
+ data.tar.gz: 11af45bc047d37897caa689ef15d5dda7bd794b17c6c6a607c00e6d3b475a5f9907af6c941a7f4bb07ec5b6503587d8c1c3285dcdcc1071853f55167f248d21f
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ - Initial external passkey package extracted from `better_auth`.
data/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # better_auth-passkey
2
+
3
+ Passkey/WebAuthn plugin package for Better Auth Ruby.
4
+
5
+ ## Installation
6
+
7
+ Add the gem and require the package before configuring the plugin:
8
+
9
+ ```ruby
10
+ gem "better_auth-passkey"
11
+ ```
12
+
13
+ ```ruby
14
+ require "better_auth/passkey"
15
+
16
+ auth = BetterAuth.auth(
17
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
18
+ database: :memory,
19
+ plugins: [
20
+ BetterAuth::Plugins.passkey
21
+ ]
22
+ )
23
+ ```
24
+
25
+ ## Notes
26
+
27
+ This package depends on the maintained `webauthn` gem. Keeping passkeys outside `better_auth` avoids installing WebAuthn dependencies for applications that do not use passkeys.
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Passkey
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require_relative "passkey/version"
5
+ require_relative "plugins/passkey"
6
+
7
+ module BetterAuth
8
+ module Passkey
9
+ end
10
+ end
@@ -0,0 +1,487 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "uri"
6
+ require "webauthn"
7
+
8
+ module BetterAuth
9
+ module Plugins
10
+ singleton_class.remove_method(:passkey) if singleton_class.method_defined?(:passkey)
11
+ remove_method(:passkey) if method_defined?(:passkey) || private_method_defined?(:passkey)
12
+
13
+ module_function
14
+
15
+ PASSKEY_ERROR_CODES = {
16
+ "CHALLENGE_NOT_FOUND" => "Challenge not found",
17
+ "YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY" => "You are not allowed to register this passkey",
18
+ "FAILED_TO_VERIFY_REGISTRATION" => "Failed to verify registration",
19
+ "PASSKEY_NOT_FOUND" => "Passkey not found",
20
+ "AUTHENTICATION_FAILED" => "Authentication failed",
21
+ "UNABLE_TO_CREATE_SESSION" => "Unable to create session",
22
+ "FAILED_TO_UPDATE_PASSKEY" => "Failed to update passkey",
23
+ "PREVIOUSLY_REGISTERED" => "Previously registered",
24
+ "REGISTRATION_CANCELLED" => "Registration cancelled",
25
+ "AUTH_CANCELLED" => "Auth cancelled",
26
+ "UNKNOWN_ERROR" => "Unknown error",
27
+ "SESSION_REQUIRED" => "Passkey registration requires an authenticated session",
28
+ "RESOLVE_USER_REQUIRED" => "Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false",
29
+ "RESOLVED_USER_INVALID" => "Resolved user is invalid"
30
+ }.freeze
31
+
32
+ PASSKEY_CHALLENGE_MAX_AGE = 60 * 5
33
+
34
+ def passkey(options = {})
35
+ config = {
36
+ origin: nil,
37
+ advanced: {
38
+ web_authn_challenge_cookie: "better-auth-passkey"
39
+ }
40
+ }.merge(normalize_hash(options))
41
+ config[:advanced] = {
42
+ web_authn_challenge_cookie: "better-auth-passkey"
43
+ }.merge(config[:advanced] || {})
44
+
45
+ Plugin.new(
46
+ id: "passkey",
47
+ schema: passkey_schema(config[:schema]),
48
+ endpoints: {
49
+ generate_passkey_registration_options: generate_passkey_registration_options_endpoint(config),
50
+ generate_passkey_authentication_options: generate_passkey_authentication_options_endpoint(config),
51
+ verify_passkey_registration: verify_passkey_registration_endpoint(config),
52
+ verify_passkey_authentication: verify_passkey_authentication_endpoint(config),
53
+ list_passkeys: list_passkeys_endpoint,
54
+ delete_passkey: delete_passkey_endpoint,
55
+ update_passkey: update_passkey_endpoint
56
+ },
57
+ error_codes: PASSKEY_ERROR_CODES,
58
+ options: config
59
+ )
60
+ end
61
+
62
+ def generate_passkey_registration_options_endpoint(config)
63
+ Endpoint.new(path: "/passkey/generate-register-options", method: "GET") do |ctx|
64
+ query = normalize_hash(ctx.query)
65
+ user = passkey_resolve_registration_user(config, ctx, query)
66
+ passkey_configure_webauthn(config, ctx)
67
+ existing = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: user.fetch("id")}])
68
+ options = WebAuthn::Credential.options_for_create(
69
+ user: {
70
+ id: Crypto.random_string(32).downcase,
71
+ name: query[:name].to_s.empty? ? (user["email"] || user["name"] || user["id"]) : query[:name].to_s,
72
+ display_name: user["displayName"] || user["display_name"] || user["email"] || user["name"] || user["id"]
73
+ },
74
+ exclude: existing.map { |passkey| passkey_credential_id(passkey) },
75
+ authenticator_selection: passkey_authenticator_selection(config, query),
76
+ extensions: passkey_resolve_extensions(config.dig(:registration, :extensions), ctx)
77
+ )
78
+ passkey_store_challenge(ctx, config, options.challenge, {
79
+ id: user.fetch("id"),
80
+ name: user["name"] || user["email"] || user["id"],
81
+ displayName: user["displayName"] || user["display_name"]
82
+ }.compact)
83
+ ctx.json(options.as_json.merge(excludeCredentials: existing.map { |passkey| passkey_credential_descriptor(passkey) }))
84
+ end
85
+ end
86
+
87
+ def generate_passkey_authentication_options_endpoint(config)
88
+ Endpoint.new(path: "/passkey/generate-authenticate-options", method: "GET") do |ctx|
89
+ session = Routes.current_session(ctx, allow_nil: true)
90
+ passkey_configure_webauthn(config, ctx)
91
+ passkeys = if session
92
+ ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
93
+ else
94
+ []
95
+ 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
+ )
100
+ passkey_store_challenge(ctx, config, options.challenge, session ? session.fetch(:user).fetch("id") : "")
101
+ payload = options.as_json.merge(userVerification: "preferred")
102
+ payload[:allowCredentials] = passkeys.map { |passkey| passkey_credential_descriptor(passkey) } if passkeys.any?
103
+ ctx.json(payload)
104
+ end
105
+ end
106
+
107
+ def verify_passkey_registration_endpoint(config)
108
+ Endpoint.new(path: "/passkey/verify-registration", method: "POST") do |ctx|
109
+ 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)
111
+ origin = passkey_origin(config, ctx)
112
+ raise APIError.new("BAD_REQUEST", message: "origin missing") if origin.to_s.empty?
113
+
114
+ verification_token = passkey_challenge_token(ctx, config)
115
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
116
+
117
+ challenge = passkey_find_challenge(ctx, verification_token)
118
+ unless challenge
119
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND"))
120
+ end
121
+ if session && challenge.fetch("userData").fetch("id") != session.fetch(:user).fetch("id")
122
+ raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
123
+ end
124
+
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)
128
+ credential.verify(challenge.fetch("expectedChallenge"), user_verification: false)
129
+ authenticator_data = passkey_authenticator_data(credential)
130
+ body = normalize_hash(ctx.body)
131
+ target_user_id = passkey_after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
132
+ data = ctx.context.adapter.create(
133
+ model: "passkey",
134
+ data: {
135
+ name: body[:name],
136
+ userId: target_user_id,
137
+ credentialID: credential.id,
138
+ publicKey: Base64.strict_encode64(credential.public_key),
139
+ counter: credential.sign_count,
140
+ deviceType: authenticator_data&.credential_backup_eligible? ? "multiDevice" : "singleDevice",
141
+ backedUp: authenticator_data&.credential_backed_up? || false,
142
+ transports: Array(passkey_attestation_response(credential)&.transports).join(","),
143
+ createdAt: Time.now,
144
+ aaguid: passkey_attestation_response(credential)&.aaguid
145
+ }
146
+ )
147
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
148
+ ctx.json(passkey_wire(data))
149
+ rescue WebAuthn::Error => error
150
+ ctx.context.logger&.error("Failed to verify registration", error)
151
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION"))
152
+ end
153
+ end
154
+
155
+ def verify_passkey_authentication_endpoint(config)
156
+ Endpoint.new(path: "/passkey/verify-authentication", method: "POST") do |ctx|
157
+ origin = passkey_origin(config, ctx)
158
+ raise APIError.new("BAD_REQUEST", message: "origin missing") if origin.to_s.empty?
159
+
160
+ verification_token = passkey_challenge_token(ctx, config)
161
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
162
+
163
+ challenge = passkey_find_challenge(ctx, verification_token)
164
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless challenge
165
+
166
+ response = passkey_webauthn_response(normalize_hash(ctx.body)[:response])
167
+ credential_id = response.fetch("id")
168
+ passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "credentialID", value: credential_id}])
169
+ raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
170
+
171
+ passkey_configure_webauthn(config, ctx, origin: origin)
172
+ credential = WebAuthn::Credential.from_get(response)
173
+ credential.verify(
174
+ challenge.fetch("expectedChallenge"),
175
+ public_key: Base64.strict_decode64(passkey.fetch("publicKey")),
176
+ sign_count: passkey.fetch("counter").to_i,
177
+ user_verification: false
178
+ )
179
+ passkey_call_callback(config.dig(:authentication, :after_verification), {
180
+ ctx: ctx,
181
+ verification: credential,
182
+ client_data: response
183
+ })
184
+ ctx.context.adapter.update(
185
+ model: "passkey",
186
+ where: [{field: "id", value: passkey.fetch("id")}],
187
+ update: {counter: credential.sign_count}
188
+ )
189
+ session = ctx.context.internal_adapter.create_session(passkey.fetch("userId"))
190
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: PASSKEY_ERROR_CODES.fetch("UNABLE_TO_CREATE_SESSION")) unless session
191
+
192
+ user = ctx.context.internal_adapter.find_user_by_id(passkey.fetch("userId"))
193
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "User not found") unless user
194
+
195
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
196
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
197
+ ctx.json({session: session, user: user})
198
+ rescue WebAuthn::Error, ArgumentError => error
199
+ ctx.context.logger&.error("Failed to verify authentication", error)
200
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("AUTHENTICATION_FAILED"))
201
+ end
202
+ end
203
+
204
+ def list_passkeys_endpoint
205
+ Endpoint.new(path: "/passkey/list-user-passkeys", method: "GET") do |ctx|
206
+ session = Routes.current_session(ctx)
207
+ passkeys = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
208
+ ctx.json(passkeys.map { |passkey| passkey_wire(passkey) })
209
+ end
210
+ end
211
+
212
+ def delete_passkey_endpoint
213
+ Endpoint.new(path: "/passkey/delete-passkey", method: "POST") do |ctx|
214
+ session = Routes.current_session(ctx)
215
+ body = normalize_hash(ctx.body)
216
+ passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
217
+ 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")
219
+
220
+ ctx.context.adapter.delete(model: "passkey", where: [{field: "id", value: passkey.fetch("id")}])
221
+ ctx.json({status: true})
222
+ end
223
+ end
224
+
225
+ def update_passkey_endpoint
226
+ Endpoint.new(path: "/passkey/update-passkey", method: "POST") do |ctx|
227
+ session = Routes.current_session(ctx)
228
+ body = normalize_hash(ctx.body)
229
+ passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
230
+ raise APIError.new("NOT_FOUND", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
231
+ if passkey.fetch("userId") != session.fetch(:user).fetch("id")
232
+ raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
233
+ end
234
+
235
+ updated = ctx.context.adapter.update(
236
+ model: "passkey",
237
+ where: [{field: "id", value: body[:id]}],
238
+ update: {name: body[:name].to_s}
239
+ )
240
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: PASSKEY_ERROR_CODES.fetch("FAILED_TO_UPDATE_PASSKEY")) unless updated
241
+
242
+ ctx.json({passkey: passkey_wire(updated)})
243
+ end
244
+ end
245
+
246
+ def passkey_schema(custom_schema = nil)
247
+ base = {
248
+ passkey: {
249
+ model_name: "passkeys",
250
+ fields: {
251
+ name: {type: "string", required: false},
252
+ publicKey: {type: "string", required: true},
253
+ userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true},
254
+ credentialID: {type: "string", required: true, index: true},
255
+ counter: {type: "number", required: true},
256
+ deviceType: {type: "string", required: true},
257
+ backedUp: {type: "boolean", required: true},
258
+ transports: {type: "string", required: false},
259
+ createdAt: {type: "date", required: false},
260
+ aaguid: {type: "string", required: false}
261
+ }
262
+ }
263
+ }
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
269
+ end
270
+
271
+ def passkey_store_challenge(ctx, config, challenge, user_id)
272
+ user_data = user_id.is_a?(Hash) ? user_id : {id: user_id}
273
+ verification_token = Crypto.random_string(32)
274
+ cookie = passkey_challenge_cookie(ctx, config)
275
+ ctx.set_signed_cookie(cookie.name, verification_token, ctx.context.secret, cookie.attributes.merge(max_age: PASSKEY_CHALLENGE_MAX_AGE))
276
+ ctx.context.internal_adapter.create_verification_value(
277
+ identifier: verification_token,
278
+ value: JSON.generate({
279
+ expectedChallenge: challenge,
280
+ userData: user_data,
281
+ context: normalize_hash(ctx.query)[:context]
282
+ }),
283
+ expiresAt: Time.now + PASSKEY_CHALLENGE_MAX_AGE
284
+ )
285
+ end
286
+
287
+ def passkey_find_challenge(ctx, verification_token)
288
+ verification = ctx.context.internal_adapter.find_verification_value(verification_token)
289
+ return nil unless verification && !Routes.expired_time?(verification["expiresAt"])
290
+
291
+ JSON.parse(verification.fetch("value"))
292
+ rescue JSON::ParserError
293
+ nil
294
+ end
295
+
296
+ def passkey_challenge_token(ctx, config)
297
+ ctx.get_signed_cookie(passkey_challenge_cookie(ctx, config).name, ctx.context.secret)
298
+ end
299
+
300
+ def passkey_challenge_cookie(ctx, config)
301
+ ctx.context.create_auth_cookie(config.dig(:advanced, :web_authn_challenge_cookie), max_age: PASSKEY_CHALLENGE_MAX_AGE)
302
+ end
303
+
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)
308
+ end
309
+
310
+ def passkey_origin(config, ctx)
311
+ config[:origin] || ctx.headers["origin"]
312
+ end
313
+
314
+ def passkey_allowed_origins(config, ctx, origin: nil)
315
+ Array(origin || config[:origin] || ctx.context.options.base_url).compact
316
+ end
317
+
318
+ def passkey_rp_id(config, ctx)
319
+ return config[:rp_id] if config[:rp_id]
320
+
321
+ URI.parse(ctx.context.options.base_url.to_s).host || "localhost"
322
+ rescue URI::InvalidURIError
323
+ "localhost"
324
+ end
325
+
326
+ def passkey_authenticator_selection(config, query)
327
+ selection = normalize_hash(config[:authenticator_selection] || {})
328
+ attachment = query[:authenticator_attachment]
329
+ selection[:authenticator_attachment] = attachment if attachment
330
+ {
331
+ resident_key: selection[:resident_key] || "preferred",
332
+ user_verification: selection[:user_verification] || "preferred",
333
+ authenticator_attachment: selection[:authenticator_attachment]
334
+ }.compact
335
+ end
336
+
337
+ def passkey_resolve_registration_user(config, ctx, query)
338
+ require_session = config.dig(:registration, :require_session) != false
339
+ if require_session
340
+ session = Routes.current_session(ctx, sensitive: true)
341
+ user = session.fetch(:user)
342
+ return passkey_registration_user_data(
343
+ id: user.fetch("id"),
344
+ name: user["email"] || user["id"],
345
+ display_name: user["email"] || user["id"],
346
+ email: user["email"]
347
+ )
348
+ end
349
+
350
+ session = Routes.current_session(ctx, allow_nil: true)
351
+ if session
352
+ user = session.fetch(:user)
353
+ return passkey_registration_user_data(
354
+ id: user.fetch("id"),
355
+ name: user["email"] || user["id"],
356
+ display_name: user["email"] || user["id"],
357
+ email: user["email"]
358
+ )
359
+ end
360
+
361
+ resolver = config.dig(:registration, :resolve_user)
362
+ unless resolver.respond_to?(:call)
363
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("RESOLVE_USER_REQUIRED"))
364
+ end
365
+
366
+ resolved = normalize_hash(passkey_call_callback(resolver, {ctx: ctx, context: query[:context]}) || {})
367
+ unless resolved[:id].to_s != "" && resolved[:name].to_s != ""
368
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("RESOLVED_USER_INVALID"))
369
+ end
370
+
371
+ passkey_registration_user_data(
372
+ id: resolved[:id],
373
+ name: resolved[:name],
374
+ display_name: resolved[:display_name],
375
+ email: resolved[:email]
376
+ )
377
+ end
378
+
379
+ def passkey_registration_user_data(id:, name:, display_name: nil, email: nil)
380
+ {
381
+ "id" => id,
382
+ "name" => name,
383
+ "displayName" => display_name,
384
+ "email" => email
385
+ }.compact
386
+ end
387
+
388
+ def passkey_resolve_extensions(extensions, ctx)
389
+ return nil unless extensions
390
+
391
+ normalize_hash(extensions.respond_to?(:call) ? passkey_call_callback(extensions, {ctx: ctx}) : extensions)
392
+ end
393
+
394
+ def passkey_after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
395
+ user_data = challenge.fetch("userData")
396
+ target_user_id = user_data.fetch("id")
397
+ callback = config.dig(:registration, :after_verification)
398
+ return target_user_id unless callback.respond_to?(:call)
399
+
400
+ result = normalize_hash(passkey_call_callback(callback, {
401
+ ctx: ctx,
402
+ verification: credential,
403
+ user: {
404
+ id: user_data.fetch("id"),
405
+ name: user_data["name"] || user_data.fetch("id"),
406
+ display_name: user_data["displayName"] || user_data["display_name"]
407
+ },
408
+ client_data: response,
409
+ context: challenge["context"]
410
+ }) || {})
411
+ returned_user_id = result[:user_id]
412
+ return target_user_id if returned_user_id.to_s.empty?
413
+
414
+ unless returned_user_id.is_a?(String)
415
+ raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("RESOLVED_USER_INVALID"))
416
+ end
417
+
418
+ if session && returned_user_id != session.fetch(:user).fetch("id")
419
+ raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
420
+ end
421
+
422
+ returned_user_id
423
+ end
424
+
425
+ def passkey_call_callback(callback, data)
426
+ return nil unless callback.respond_to?(:call)
427
+
428
+ if callback.parameters.any? { |kind, _name| [:key, :keyreq, :keyrest].include?(kind) }
429
+ callback.call(**data)
430
+ else
431
+ callback.call(data)
432
+ end
433
+ end
434
+
435
+ def passkey_webauthn_response(value)
436
+ data = normalize_hash(value || {})
437
+ response = normalize_hash(data[:response] || {})
438
+ webauthn = {
439
+ "type" => data[:type],
440
+ "id" => data[:id],
441
+ "rawId" => data[:raw_id],
442
+ "authenticatorAttachment" => data[:authenticator_attachment],
443
+ "clientExtensionResults" => data[:client_extension_results] || {},
444
+ "response" => {
445
+ "attestationObject" => response[:attestation_object],
446
+ "clientDataJSON" => response[:client_data_json],
447
+ "transports" => response[:transports],
448
+ "authenticatorData" => response[:authenticator_data],
449
+ "signature" => response[:signature],
450
+ "userHandle" => response[:user_handle]
451
+ }.compact
452
+ }.compact
453
+ webauthn["rawId"] ||= webauthn["id"]
454
+ webauthn
455
+ end
456
+
457
+ def passkey_attestation_response(credential)
458
+ credential.instance_variable_get(:@response)
459
+ end
460
+
461
+ def passkey_authenticator_data(credential)
462
+ passkey_attestation_response(credential)&.authenticator_data
463
+ end
464
+
465
+ def passkey_wire(record)
466
+ return record unless record.is_a?(Hash)
467
+
468
+ output = record.dup
469
+ output["credentialID"] = output.delete("credentialId") if output.key?("credentialId")
470
+ output
471
+ end
472
+
473
+ def passkey_credential_id(record)
474
+ record["credentialID"] || record["credentialId"] || record[:credentialID] || record[:credential_id]
475
+ end
476
+
477
+ def passkey_credential_descriptor(record)
478
+ descriptor = {
479
+ id: passkey_credential_id(record),
480
+ type: "public-key"
481
+ }
482
+ transports = (record["transports"] || record[:transports]).to_s.split(",").map(&:strip).reject(&:empty?)
483
+ descriptor[:transports] = transports if transports.any?
484
+ descriptor
485
+ end
486
+ end
487
+ end
metadata ADDED
@@ -0,0 +1,160 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: better_auth-passkey
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Sebastian Sala
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: better_auth
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: base64
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0.2'
33
+ - - "<"
34
+ - !ruby/object:Gem::Version
35
+ version: '1.0'
36
+ type: :runtime
37
+ prerelease: false
38
+ version_requirements: !ruby/object:Gem::Requirement
39
+ requirements:
40
+ - - ">="
41
+ - !ruby/object:Gem::Version
42
+ version: '0.2'
43
+ - - "<"
44
+ - !ruby/object:Gem::Version
45
+ version: '1.0'
46
+ - !ruby/object:Gem::Dependency
47
+ name: webauthn
48
+ requirement: !ruby/object:Gem::Requirement
49
+ requirements:
50
+ - - "~>"
51
+ - !ruby/object:Gem::Version
52
+ version: '3.4'
53
+ - - ">="
54
+ - !ruby/object:Gem::Version
55
+ version: 3.4.3
56
+ type: :runtime
57
+ prerelease: false
58
+ version_requirements: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "~>"
61
+ - !ruby/object:Gem::Version
62
+ version: '3.4'
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: 3.4.3
66
+ - !ruby/object:Gem::Dependency
67
+ name: bundler
68
+ requirement: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - "~>"
71
+ - !ruby/object:Gem::Version
72
+ version: '2.5'
73
+ type: :development
74
+ prerelease: false
75
+ version_requirements: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - "~>"
78
+ - !ruby/object:Gem::Version
79
+ version: '2.5'
80
+ - !ruby/object:Gem::Dependency
81
+ name: minitest
82
+ requirement: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - "~>"
85
+ - !ruby/object:Gem::Version
86
+ version: '5.25'
87
+ type: :development
88
+ prerelease: false
89
+ version_requirements: !ruby/object:Gem::Requirement
90
+ requirements:
91
+ - - "~>"
92
+ - !ruby/object:Gem::Version
93
+ version: '5.25'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rake
96
+ requirement: !ruby/object:Gem::Requirement
97
+ requirements:
98
+ - - "~>"
99
+ - !ruby/object:Gem::Version
100
+ version: '13.2'
101
+ type: :development
102
+ prerelease: false
103
+ version_requirements: !ruby/object:Gem::Requirement
104
+ requirements:
105
+ - - "~>"
106
+ - !ruby/object:Gem::Version
107
+ version: '13.2'
108
+ - !ruby/object:Gem::Dependency
109
+ name: standardrb
110
+ requirement: !ruby/object:Gem::Requirement
111
+ requirements:
112
+ - - "~>"
113
+ - !ruby/object:Gem::Version
114
+ version: '1.0'
115
+ type: :development
116
+ prerelease: false
117
+ version_requirements: !ruby/object:Gem::Requirement
118
+ requirements:
119
+ - - "~>"
120
+ - !ruby/object:Gem::Version
121
+ version: '1.0'
122
+ description: Adds passkey/WebAuthn registration, authentication, and credential management
123
+ routes for Better Auth Ruby.
124
+ email:
125
+ - sebastian.sala.tech@gmail.com
126
+ executables: []
127
+ extensions: []
128
+ extra_rdoc_files: []
129
+ files:
130
+ - CHANGELOG.md
131
+ - README.md
132
+ - lib/better_auth/passkey.rb
133
+ - lib/better_auth/passkey/version.rb
134
+ - lib/better_auth/plugins/passkey.rb
135
+ homepage: https://github.com/sebasxsala/better-auth
136
+ licenses:
137
+ - MIT
138
+ metadata:
139
+ homepage_uri: https://github.com/sebasxsala/better-auth
140
+ source_code_uri: https://github.com/sebasxsala/better-auth
141
+ changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-passkey/CHANGELOG.md
142
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
143
+ rdoc_options: []
144
+ require_paths:
145
+ - lib
146
+ required_ruby_version: !ruby/object:Gem::Requirement
147
+ requirements:
148
+ - - ">="
149
+ - !ruby/object:Gem::Version
150
+ version: 3.2.0
151
+ required_rubygems_version: !ruby/object:Gem::Requirement
152
+ requirements:
153
+ - - ">="
154
+ - !ruby/object:Gem::Version
155
+ version: '0'
156
+ requirements: []
157
+ rubygems_version: 3.6.9
158
+ specification_version: 4
159
+ summary: Passkey/WebAuthn plugin package for Better Auth Ruby
160
+ test_files: []