better_auth-passkey 0.1.0 → 0.5.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: 509b37215dc04f3b6764922cb082261651d80b215d524b3af3fe03dffa1e046f
4
+ data.tar.gz: f5c58225f30016ced29b4f901d871ceac8c20c0620176961a6cf05880567935c
5
5
  SHA512:
6
- metadata.gz: 653ff4209165b99511fda3f9bf433069b3ffd85915f88a014913ae9671e18abfcec1fd283a72d2014db6b9baca97441fb99ca658cb3f36b4548bb8294d22a201
7
- data.tar.gz: 11af45bc047d37897caa689ef15d5dda7bd794b17c6c6a607c00e6d3b475a5f9907af6c941a7f4bb07ec5b6503587d8c1c3285dcdcc1071853f55167f248d21f
6
+ metadata.gz: d5b4b313d7962247d9af939d3f8edb37e649c5183f219362608c19bcd397cb236e1095700ef0d27f2338212f6a1ba83d0d05c75adbea090a0403f543264404fe
7
+ data.tar.gz: 1c4bb5d2f5ac3cfb1245d59103128a29fb2457242c66d709e7fe08df8f396081bd5cf99a4e8cb0327479c7a57db210f1aadd4d018e525cd86d49280cee96ceda
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,109 @@ 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
+ ## Callback contracts
81
+
82
+ Ruby uses hashes for the upstream TypeScript contracts:
83
+
84
+ - Stored challenge value: `expectedChallenge`, `userData.id`, optional `userData.name`, optional `userData.displayName`, and optional `context`.
85
+ - `resolve_user` receives `{ ctx:, context: }` and must return at least `id` and `name`; it may also return `display_name` and `email`.
86
+ - Registration `after_verification` receives `{ ctx:, verification:, user:, client_data:, context: }`.
87
+ - Authentication `after_verification` receives `{ ctx:, verification:, client_data: }`.
88
+ - Passkey records use upstream wire keys including `userId`, `credentialID`, `publicKey`, `deviceType`, `backedUp`, `createdAt`, and optional `aaguid`.
89
+
90
+ ## WebAuthn extensions
91
+
92
+ ```ruby
93
+ BetterAuth::Plugins.passkey(
94
+ registration: {
95
+ extensions: { credProps: true }
96
+ },
97
+ authentication: {
98
+ extensions: ->(_data) { { hmacGetSecret: true } }
99
+ }
100
+ )
101
+ ```
102
+
103
+ ## Browser client scope
104
+
105
+ 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.
106
+
107
+ ## WebAuthn configuration
108
+
109
+ 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.
110
+
111
+ ## Upstream parity notes
112
+
113
+ The Ruby plugin tracks Better Auth `v1.6.9` upstream behavior. A few wire-shape and validation details are worth noting:
114
+
115
+ - `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
+ - `transports` is omitted entirely from credential descriptors when the stored value is missing or empty (rather than emitting an empty array).
117
+ - 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.
118
+ - `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
+ - 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
+ - `update_passkey` accepts an empty-string `name` to match upstream `z.string()`. Missing or non-string `name` still raises `VALIDATION_ERROR`.
121
+ - Cross-user `delete_passkey` raises `UNAUTHORIZED` with the `PASSKEY_NOT_FOUND` message, mirroring upstream's `requireResourceOwnership` middleware behavior when only `notFoundError` is configured.
122
+
25
123
  ## Notes
26
124
 
27
125
  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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterAuth
6
+ module Passkey
7
+ module Challenges
8
+ CHALLENGE_MAX_AGE = 60 * 5
9
+
10
+ module_function
11
+
12
+ def store_challenge(ctx, config, challenge, user_id)
13
+ user_data = user_id.is_a?(Hash) ? user_id : {id: user_id}
14
+ verification_token = Crypto.random_string(32)
15
+ cookie = challenge_cookie(ctx, config)
16
+ ctx.set_signed_cookie(cookie.name, verification_token, ctx.context.secret, cookie.attributes.merge(max_age: CHALLENGE_MAX_AGE))
17
+ ctx.context.internal_adapter.create_verification_value(
18
+ identifier: verification_token,
19
+ value: JSON.generate({
20
+ expectedChallenge: challenge,
21
+ userData: user_data,
22
+ context: BetterAuth::Passkey::Utils.normalize_hash(ctx.query)[:context]
23
+ }),
24
+ expiresAt: Time.now + CHALLENGE_MAX_AGE
25
+ )
26
+ end
27
+
28
+ def find_challenge(ctx, verification_token)
29
+ verification = ctx.context.internal_adapter.find_verification_value(verification_token)
30
+ return nil if verification.nil? || BetterAuth::Routes.expired_time?(verification["expiresAt"] || verification[:expiresAt])
31
+
32
+ JSON.parse(verification.fetch("value") { verification.fetch(:value) })
33
+ rescue JSON::ParserError
34
+ nil
35
+ end
36
+
37
+ def challenge_token(ctx, config)
38
+ ctx.get_signed_cookie(challenge_cookie(ctx, config).name, ctx.context.secret)
39
+ end
40
+
41
+ def challenge_cookie(ctx, config)
42
+ ctx.context.create_auth_cookie(config.dig(:advanced, :web_authn_challenge_cookie), max_age: CHALLENGE_MAX_AGE)
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Passkey
5
+ module Credentials
6
+ module_function
7
+
8
+ def webauthn_response(value)
9
+ data = BetterAuth::Passkey::Utils.normalize_hash(value || {})
10
+ response = BetterAuth::Passkey::Utils.normalize_hash(data[:response] || {})
11
+ webauthn = {
12
+ "type" => data[:type],
13
+ "id" => data[:id],
14
+ "rawId" => data[:raw_id],
15
+ "authenticatorAttachment" => data[:authenticator_attachment],
16
+ "clientExtensionResults" => data[:client_extension_results] || {},
17
+ "response" => {
18
+ "attestationObject" => response[:attestation_object],
19
+ "clientDataJSON" => response[:client_data_json],
20
+ "transports" => response[:transports],
21
+ "authenticatorData" => response[:authenticator_data],
22
+ "signature" => response[:signature],
23
+ "userHandle" => response[:user_handle]
24
+ }.compact
25
+ }.compact
26
+ webauthn["rawId"] ||= webauthn["id"]
27
+ webauthn
28
+ end
29
+
30
+ def attestation_response(credential)
31
+ credential.instance_variable_get(:@response)
32
+ end
33
+
34
+ def authenticator_data(credential)
35
+ attestation_response(credential)&.authenticator_data
36
+ end
37
+
38
+ def wire(record)
39
+ return record unless record.is_a?(Hash)
40
+
41
+ output = record.dup
42
+ output["credentialID"] = output.delete("credentialId") if output.key?("credentialId")
43
+ output
44
+ end
45
+
46
+ def credential_id(record)
47
+ record["credentialID"] || record["credentialId"] || record[:credentialID] || record[:credential_id]
48
+ end
49
+
50
+ def credential_descriptor(record, kind: :allow)
51
+ descriptor = {id: credential_id(record)}
52
+ descriptor[:type] = "public-key" if kind == :allow
53
+ transports = (record["transports"] || record[:transports]).to_s.split(",").map(&:strip).reject(&:empty?)
54
+ descriptor[:transports] = transports if transports.any?
55
+ descriptor
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Passkey
5
+ module ErrorCodes
6
+ PASSKEY_ERROR_CODES = {
7
+ "CHALLENGE_NOT_FOUND" => "Challenge not found",
8
+ "YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY" => "You are not allowed to register this passkey",
9
+ "FAILED_TO_VERIFY_REGISTRATION" => "Failed to verify registration",
10
+ "PASSKEY_NOT_FOUND" => "Passkey not found",
11
+ "AUTHENTICATION_FAILED" => "Authentication failed",
12
+ "UNABLE_TO_CREATE_SESSION" => "Unable to create session",
13
+ "FAILED_TO_UPDATE_PASSKEY" => "Failed to update passkey",
14
+ "PREVIOUSLY_REGISTERED" => "Previously registered",
15
+ "REGISTRATION_CANCELLED" => "Registration cancelled",
16
+ "AUTH_CANCELLED" => "Auth cancelled",
17
+ "UNKNOWN_ERROR" => "Unknown error",
18
+ "SESSION_REQUIRED" => "Passkey registration requires an authenticated session",
19
+ "RESOLVE_USER_REQUIRED" => "Passkey registration requires either an authenticated session or a resolveUser callback when requireSession is false",
20
+ "RESOLVED_USER_INVALID" => "Resolved user is invalid"
21
+ }.freeze
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "webauthn"
5
+
6
+ module BetterAuth
7
+ module Passkey
8
+ module Routes
9
+ module Authentication
10
+ module_function
11
+
12
+ def generate_passkey_authentication_options_endpoint(config)
13
+ Endpoint.new(path: "/passkey/generate-authenticate-options", method: "GET") do |ctx|
14
+ session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
15
+ relying_party = Utils.relying_party(config, ctx)
16
+ passkeys = if session
17
+ ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
18
+ else
19
+ []
20
+ end
21
+ get_options = {
22
+ extensions: Utils.resolve_extensions(config.dig(:authentication, :extensions), ctx),
23
+ relying_party: relying_party
24
+ }
25
+ get_options[:allow] = passkeys.map { |passkey| Credentials.credential_id(passkey) } if passkeys.any?
26
+ options = WebAuthn::Credential.options_for_get(**get_options)
27
+ Challenges.store_challenge(ctx, config, options.challenge, session ? session.fetch(:user).fetch("id") : "")
28
+ payload = options.as_json.merge(userVerification: "preferred")
29
+ payload.delete(:extensions) if payload[:extensions].nil? || payload[:extensions] == {}
30
+ payload.delete("extensions") if payload["extensions"].nil? || payload["extensions"] == {}
31
+ if passkeys.any?
32
+ payload[:allowCredentials] = passkeys.map { |passkey| Credentials.credential_descriptor(passkey) }
33
+ else
34
+ payload.delete(:allowCredentials)
35
+ payload.delete("allowCredentials")
36
+ end
37
+ ctx.json(payload)
38
+ end
39
+ end
40
+
41
+ def verify_passkey_authentication_endpoint(config)
42
+ Endpoint.new(path: "/passkey/verify-authentication", method: "POST") do |ctx|
43
+ body = Utils.normalize_hash(ctx.body)
44
+ Utils.require_key!(body, :response)
45
+ origin = Utils.origin(config, ctx)
46
+ raise APIError.new("BAD_REQUEST", message: "origin missing") if origin.to_s.empty?
47
+
48
+ verification_token = Challenges.challenge_token(ctx, config)
49
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
50
+
51
+ challenge = Challenges.find_challenge(ctx, verification_token)
52
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless challenge
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
58
+
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
79
+
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
82
+
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"))
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Passkey
5
+ module Routes
6
+ module Management
7
+ module_function
8
+
9
+ def list_passkeys_endpoint
10
+ Endpoint.new(path: "/passkey/list-user-passkeys", method: "GET") do |ctx|
11
+ session = BetterAuth::Routes.current_session(ctx)
12
+ passkeys = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
13
+ ctx.json(passkeys.map { |passkey| Credentials.wire(passkey) })
14
+ end
15
+ end
16
+
17
+ def delete_passkey_endpoint
18
+ Endpoint.new(path: "/passkey/delete-passkey", method: "POST") do |ctx|
19
+ session = BetterAuth::Routes.current_session(ctx)
20
+ body = Utils.normalize_hash(ctx.body)
21
+ Utils.require_string!(body, :id)
22
+ passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
23
+ raise APIError.new("NOT_FOUND", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
24
+ unless passkey.fetch("userId") == session.fetch(:user).fetch("id")
25
+ raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND"))
26
+ end
27
+
28
+ ctx.context.adapter.delete(model: "passkey", where: [{field: "id", value: passkey.fetch("id")}])
29
+ ctx.json({status: true})
30
+ end
31
+ end
32
+
33
+ def update_passkey_endpoint
34
+ Endpoint.new(path: "/passkey/update-passkey", method: "POST") do |ctx|
35
+ session = BetterAuth::Routes.current_session(ctx)
36
+ body = Utils.normalize_hash(ctx.body)
37
+ Utils.require_string!(body, :id)
38
+ unless body.key?(:name) && body[:name].is_a?(String)
39
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
40
+ end
41
+
42
+ passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
43
+ raise APIError.new("NOT_FOUND", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
44
+ if passkey.fetch("userId") != session.fetch(:user).fetch("id")
45
+ raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
46
+ end
47
+
48
+ updated = ctx.context.adapter.update(
49
+ model: "passkey",
50
+ where: [{field: "id", value: body[:id]}],
51
+ update: {name: body[:name].to_s}
52
+ )
53
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_UPDATE_PASSKEY")) unless updated
54
+
55
+ ctx.json({passkey: Credentials.wire(updated)})
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "webauthn"
5
+
6
+ module BetterAuth
7
+ module Passkey
8
+ module Routes
9
+ module Registration
10
+ module_function
11
+
12
+ def generate_passkey_registration_options_endpoint(config)
13
+ Endpoint.new(path: "/passkey/generate-register-options", method: "GET") do |ctx|
14
+ query = Utils.normalize_hash(ctx.query)
15
+ Utils.validate_authenticator_attachment!(query[:authenticator_attachment])
16
+ user = Utils.resolve_registration_user(config, ctx, query)
17
+ relying_party = Utils.relying_party(config, ctx)
18
+ existing = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: user.fetch("id")}])
19
+ options = WebAuthn::Credential.options_for_create(
20
+ user: {
21
+ id: Crypto.random_string(32).downcase,
22
+ name: query[:name].to_s.empty? ? (user["email"] || user["name"] || user["id"]) : query[:name].to_s,
23
+ display_name: user["displayName"] || user["display_name"] || user["email"] || user["name"] || user["id"]
24
+ },
25
+ exclude: existing.map { |passkey| Credentials.credential_id(passkey) },
26
+ authenticator_selection: Utils.authenticator_selection(config, query),
27
+ extensions: Utils.resolve_extensions(config.dig(:registration, :extensions), ctx),
28
+ relying_party: relying_party
29
+ )
30
+ Challenges.store_challenge(ctx, config, options.challenge, {
31
+ id: user.fetch("id"),
32
+ name: user["name"] || user["email"] || user["id"],
33
+ displayName: user["displayName"] || user["display_name"]
34
+ }.compact)
35
+ payload = options.as_json.merge(attestation: "none", excludeCredentials: existing.map { |passkey| Credentials.credential_descriptor(passkey, kind: :exclude) })
36
+ payload.delete(:extensions) if payload[:extensions].nil? || payload[:extensions] == {}
37
+ payload.delete("extensions") if payload["extensions"].nil? || payload["extensions"] == {}
38
+ ctx.json(payload)
39
+ end
40
+ end
41
+
42
+ def verify_passkey_registration_endpoint(config)
43
+ Endpoint.new(path: "/passkey/verify-registration", method: "POST") do |ctx|
44
+ body = Utils.normalize_hash(ctx.body)
45
+ Utils.require_key!(body, :response)
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)
48
+ origin = Utils.origin(config, ctx)
49
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION")) if origin.to_s.empty?
50
+
51
+ verification_token = Challenges.challenge_token(ctx, config)
52
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
53
+
54
+ challenge = Challenges.find_challenge(ctx, verification_token)
55
+ unless challenge
56
+ raise APIError.new("BAD_REQUEST", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND"))
57
+ end
58
+ if session && challenge.fetch("userData").fetch("id") != session.fetch(:user).fetch("id")
59
+ raise APIError.new("UNAUTHORIZED", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
60
+ end
61
+
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)
68
+ data = ctx.context.adapter.create(
69
+ model: "passkey",
70
+ data: {
71
+ name: body[:name],
72
+ userId: target_user_id,
73
+ credentialID: credential.id,
74
+ publicKey: Base64.strict_encode64(credential.public_key),
75
+ counter: credential.sign_count,
76
+ deviceType: authenticator_data&.credential_backup_eligible? ? "multiDevice" : "singleDevice",
77
+ backedUp: authenticator_data&.credential_backed_up? || false,
78
+ transports: Array(Credentials.attestation_response(credential)&.transports).join(","),
79
+ createdAt: Time.now,
80
+ aaguid: Credentials.attestation_response(credential)&.aaguid
81
+ }
82
+ )
83
+ ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
84
+ ctx.json(Credentials.wire(data))
85
+ rescue WebAuthn::Error => error
86
+ ctx.context.logger&.error("Failed to verify registration", error)
87
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: ErrorCodes::PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION"))
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "routes/registration"
4
+ require_relative "routes/authentication"
5
+ require_relative "routes/management"
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Passkey
5
+ module Schema
6
+ module_function
7
+
8
+ def passkey_schema(custom_schema = nil)
9
+ base = {
10
+ passkey: {
11
+ model_name: "passkeys",
12
+ fields: {
13
+ name: {type: "string", required: false},
14
+ publicKey: {type: "string", required: true},
15
+ userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true},
16
+ credentialID: {type: "string", required: true, index: true},
17
+ counter: {type: "number", required: true},
18
+ deviceType: {type: "string", required: true},
19
+ backedUp: {type: "boolean", required: true},
20
+ transports: {type: "string", required: false},
21
+ createdAt: {type: "date", required: false},
22
+ aaguid: {type: "string", required: false}
23
+ }
24
+ }
25
+ }
26
+ deep_merge_hashes(normalize_hash(base), normalize_hash(custom_schema || {}))
27
+ end
28
+
29
+ def deep_merge_hashes(base, override)
30
+ base.merge(override) do |_key, old_value, new_value|
31
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
32
+ deep_merge_hashes(old_value, new_value)
33
+ else
34
+ new_value
35
+ end
36
+ end
37
+ end
38
+
39
+ def normalize_hash(value)
40
+ return {} unless value.is_a?(Hash)
41
+
42
+ value.each_with_object({}) do |(key, object), result|
43
+ result[normalize_key(key)] = object.is_a?(Hash) ? normalize_hash(object) : object
44
+ end
45
+ end
46
+
47
+ def normalize_key(key)
48
+ key.to_s
49
+ .delete_prefix("$")
50
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
51
+ .tr("-", "_")
52
+ .downcase
53
+ .to_sym
54
+ end
55
+ end
56
+ end
57
+ end