better_auth-passkey 0.2.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: bd306893b45a068e5d878de3b262cae1283fd77c4b32a572c389940cf0802bd9
4
- data.tar.gz: b28b537cc8cf727bc5b7d4c9c370b0d32953f80e25137f097cd5c80b1e417221
3
+ metadata.gz: 509b37215dc04f3b6764922cb082261651d80b215d524b3af3fe03dffa1e046f
4
+ data.tar.gz: f5c58225f30016ced29b4f901d871ceac8c20c0620176961a6cf05880567935c
5
5
  SHA512:
6
- metadata.gz: d18c881ba150a5bd6ae0f635b8b45ecffc09936292995448b301ef5a990e6fef4715c208aee4a067049e95c69b6478915771dd4a53c28e3e061a88fa02b19be0
7
- data.tar.gz: 4dc7a18a75749e3c53374ab5183d690a3160c581d7fbdad150eaf86c25cf9534a83354a2a317e79639e0af6ec97afae70e3f0daec6d4ab68dbb5ed0af53966bc
6
+ metadata.gz: d5b4b313d7962247d9af939d3f8edb37e649c5183f219362608c19bcd397cb236e1095700ef0d27f2338212f6a1ba83d0d05c75adbea090a0403f543264404fe
7
+ data.tar.gz: 1c4bb5d2f5ac3cfb1245d59103128a29fb2457242c66d709e7fe08df8f396081bd5cf99a4e8cb0327479c7a57db210f1aadd4d018e525cd86d49280cee96ceda
data/README.md CHANGED
@@ -77,6 +77,16 @@ auth.api.generate_passkey_registration_options(query: { context: invitation_toke
77
77
 
78
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
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
+
80
90
  ## WebAuthn extensions
81
91
 
82
92
  ```ruby
@@ -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
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "webauthn"
5
+
6
+ module BetterAuth
7
+ module Passkey
8
+ module Utils
9
+ module_function
10
+
11
+ def normalize_hash(value)
12
+ BetterAuth::Passkey::Schema.normalize_hash(value)
13
+ end
14
+
15
+ def relying_party(config, ctx, origin: nil)
16
+ WebAuthn::RelyingParty.new(
17
+ id: rp_id(config, ctx),
18
+ name: config[:rp_name] || ctx.context.app_name,
19
+ allowed_origins: allowed_origins(config, ctx, origin: origin)
20
+ )
21
+ end
22
+
23
+ def origin(config, ctx)
24
+ config[:origin] || ctx.headers["origin"]
25
+ end
26
+
27
+ def allowed_origins(config, ctx, origin: nil)
28
+ Array(origin || config[:origin] || ctx.context.options.base_url).compact
29
+ end
30
+
31
+ def rp_id(config, ctx)
32
+ return config[:rp_id] if config[:rp_id]
33
+
34
+ base_url = ctx.context.options.base_url.to_s
35
+ return "localhost" if base_url.empty?
36
+
37
+ URI.parse(base_url).host || "localhost"
38
+ rescue URI::InvalidURIError
39
+ "localhost"
40
+ end
41
+
42
+ def authenticator_selection(config, query)
43
+ selection = normalize_hash(config[:authenticator_selection] || {})
44
+ attachment = query[:authenticator_attachment]
45
+ selection[:authenticator_attachment] = attachment if attachment
46
+ {
47
+ resident_key: selection[:resident_key] || "preferred",
48
+ user_verification: selection[:user_verification] || "preferred",
49
+ authenticator_attachment: selection[:authenticator_attachment]
50
+ }.compact
51
+ end
52
+
53
+ def validate_authenticator_attachment!(value)
54
+ return if value.nil? || ["platform", "cross-platform"].include?(value)
55
+
56
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
57
+ end
58
+
59
+ def require_key!(body, key)
60
+ return if body.key?(key)
61
+
62
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
63
+ end
64
+
65
+ def require_string!(body, key)
66
+ require_key!(body, key)
67
+ return if body[key].is_a?(String)
68
+
69
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES.fetch("VALIDATION_ERROR"))
70
+ end
71
+
72
+ def resolve_registration_user(config, ctx, query)
73
+ require_session = config.dig(:registration, :require_session) != false
74
+ if require_session
75
+ session = BetterAuth::Routes.current_session(ctx, allow_nil: true, sensitive: true, fresh: true)
76
+ unless session&.dig(:user, "id")
77
+ raise APIError.new("UNAUTHORIZED", message: BetterAuth::Passkey::ErrorCodes::PASSKEY_ERROR_CODES.fetch("SESSION_REQUIRED"))
78
+ end
79
+ user = session.fetch(:user)
80
+ return registration_user_data(
81
+ id: user.fetch("id"),
82
+ name: user["email"] || user["id"],
83
+ display_name: user["email"] || user["id"],
84
+ email: user["email"]
85
+ )
86
+ end
87
+
88
+ session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
89
+ if session
90
+ user = session.fetch(:user)
91
+ return registration_user_data(
92
+ id: user.fetch("id"),
93
+ name: user["email"] || user["id"],
94
+ display_name: user["email"] || user["id"],
95
+ email: user["email"]
96
+ )
97
+ end
98
+
99
+ resolver = config.dig(:registration, :resolve_user)
100
+ unless resolver.respond_to?(:call)
101
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Passkey::ErrorCodes::PASSKEY_ERROR_CODES.fetch("RESOLVE_USER_REQUIRED"))
102
+ end
103
+
104
+ resolved = normalize_hash(call_callback(resolver, {ctx: ctx, context: query[:context]}) || {})
105
+ unless resolved[:id].to_s != "" && resolved[:name].to_s != ""
106
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Passkey::ErrorCodes::PASSKEY_ERROR_CODES.fetch("RESOLVED_USER_INVALID"))
107
+ end
108
+
109
+ registration_user_data(
110
+ id: resolved[:id],
111
+ name: resolved[:name],
112
+ display_name: resolved[:display_name],
113
+ email: resolved[:email]
114
+ )
115
+ end
116
+
117
+ def registration_user_data(id:, name:, display_name: nil, email: nil)
118
+ {
119
+ "id" => id,
120
+ "name" => name,
121
+ "displayName" => display_name,
122
+ "email" => email
123
+ }.compact
124
+ end
125
+
126
+ def resolve_extensions(extensions, ctx)
127
+ return nil unless extensions
128
+
129
+ normalize_hash(extensions.respond_to?(:call) ? call_callback(extensions, {ctx: ctx}) : extensions)
130
+ end
131
+
132
+ def after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
133
+ user_data = challenge.fetch("userData")
134
+ target_user_id = user_data.fetch("id")
135
+ callback = config.dig(:registration, :after_verification)
136
+ return target_user_id unless callback.respond_to?(:call)
137
+
138
+ result = normalize_hash(call_callback(callback, {
139
+ ctx: ctx,
140
+ verification: credential,
141
+ user: {
142
+ id: user_data.fetch("id"),
143
+ name: user_data["name"] || user_data.fetch("id"),
144
+ display_name: user_data["displayName"] || user_data["display_name"]
145
+ },
146
+ client_data: response,
147
+ context: challenge["context"]
148
+ }) || {})
149
+ returned_user_id = result[:user_id]
150
+ return target_user_id if returned_user_id.nil? || returned_user_id == ""
151
+
152
+ unless returned_user_id.is_a?(String) && returned_user_id.length.positive?
153
+ raise APIError.new("BAD_REQUEST", message: BetterAuth::Passkey::ErrorCodes::PASSKEY_ERROR_CODES.fetch("RESOLVED_USER_INVALID"))
154
+ end
155
+
156
+ if session && returned_user_id != session.fetch(:user).fetch("id")
157
+ raise APIError.new("UNAUTHORIZED", message: BetterAuth::Passkey::ErrorCodes::PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
158
+ end
159
+
160
+ returned_user_id
161
+ end
162
+
163
+ def call_callback(callback, data)
164
+ return nil unless callback.respond_to?(:call)
165
+
166
+ if callback.parameters.any? { |kind, _name| [:key, :keyreq, :keyrest].include?(kind) }
167
+ callback.call(**data)
168
+ else
169
+ callback.call(data)
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Passkey
5
- VERSION = "0.2.0"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
@@ -2,6 +2,12 @@
2
2
 
3
3
  require "better_auth"
4
4
  require_relative "passkey/version"
5
+ require_relative "passkey/error_codes"
6
+ require_relative "passkey/schema"
7
+ require_relative "passkey/utils"
8
+ require_relative "passkey/challenges"
9
+ require_relative "passkey/credentials"
10
+ require_relative "passkey/routes"
5
11
  require_relative "plugins/passkey"
6
12
 
7
13
  module BetterAuth
@@ -1,10 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "base64"
4
- require "json"
5
- require "uri"
6
- require "webauthn"
7
-
8
3
  module BetterAuth
9
4
  module Plugins
10
5
  singleton_class.remove_method(:passkey) if singleton_class.method_defined?(:passkey)
@@ -12,24 +7,8 @@ module BetterAuth
12
7
 
13
8
  module_function
14
9
 
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
10
+ PASSKEY_ERROR_CODES = BetterAuth::Passkey::ErrorCodes::PASSKEY_ERROR_CODES
11
+ PASSKEY_CHALLENGE_MAX_AGE = BetterAuth::Passkey::Challenges::CHALLENGE_MAX_AGE
33
12
 
34
13
  def passkey(options = {})
35
14
  config = {
@@ -37,7 +16,7 @@ module BetterAuth
37
16
  advanced: {
38
17
  web_authn_challenge_cookie: "better-auth-passkey"
39
18
  }
40
- }.merge(normalize_hash(options))
19
+ }.merge(BetterAuth::Passkey::Utils.normalize_hash(options))
41
20
  config[:advanced] = {
42
21
  web_authn_challenge_cookie: "better-auth-passkey"
43
22
  }.merge(config[:advanced] || {})
@@ -60,476 +39,131 @@ module BetterAuth
60
39
  end
61
40
 
62
41
  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
- passkey_validate_authenticator_attachment!(query[:authenticator_attachment])
66
- user = passkey_resolve_registration_user(config, ctx, query)
67
- relying_party = passkey_relying_party(config, ctx)
68
- existing = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: user.fetch("id")}])
69
- options = WebAuthn::Credential.options_for_create(
70
- user: {
71
- id: Crypto.random_string(32).downcase,
72
- name: query[:name].to_s.empty? ? (user["email"] || user["name"] || user["id"]) : query[:name].to_s,
73
- display_name: user["displayName"] || user["display_name"] || user["email"] || user["name"] || user["id"]
74
- },
75
- exclude: existing.map { |passkey| passkey_credential_id(passkey) },
76
- authenticator_selection: passkey_authenticator_selection(config, query),
77
- extensions: passkey_resolve_extensions(config.dig(:registration, :extensions), ctx),
78
- relying_party: relying_party
79
- )
80
- passkey_store_challenge(ctx, config, options.challenge, {
81
- id: user.fetch("id"),
82
- name: user["name"] || user["email"] || user["id"],
83
- displayName: user["displayName"] || user["display_name"]
84
- }.compact)
85
- ctx.json(options.as_json.merge(attestation: "none", excludeCredentials: existing.map { |passkey| passkey_credential_descriptor(passkey, kind: :exclude) }))
86
- end
42
+ BetterAuth::Passkey::Routes::Registration.generate_passkey_registration_options_endpoint(config)
87
43
  end
88
44
 
89
- def generate_passkey_authentication_options_endpoint(config)
90
- Endpoint.new(path: "/passkey/generate-authenticate-options", method: "GET") do |ctx|
91
- session = Routes.current_session(ctx, allow_nil: true)
92
- relying_party = passkey_relying_party(config, ctx)
93
- passkeys = if session
94
- ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
95
- else
96
- []
97
- end
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)
104
- passkey_store_challenge(ctx, config, options.challenge, session ? session.fetch(:user).fetch("id") : "")
105
- payload = options.as_json.merge(userVerification: "preferred")
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
112
- ctx.json(payload)
113
- end
45
+ def verify_passkey_registration_endpoint(config)
46
+ BetterAuth::Passkey::Routes::Registration.verify_passkey_registration_endpoint(config)
114
47
  end
115
48
 
116
- def verify_passkey_registration_endpoint(config)
117
- Endpoint.new(path: "/passkey/verify-registration", method: "POST") do |ctx|
118
- body = normalize_hash(ctx.body)
119
- passkey_require_key!(body, :response)
120
- require_session = config.dig(:registration, :require_session) != false
121
- session = require_session ? Routes.current_session(ctx, sensitive: true) : Routes.current_session(ctx, allow_nil: true)
122
- origin = passkey_origin(config, ctx)
123
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION")) if origin.to_s.empty?
124
-
125
- verification_token = passkey_challenge_token(ctx, config)
126
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
127
-
128
- challenge = passkey_find_challenge(ctx, verification_token)
129
- unless challenge
130
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND"))
131
- end
132
- if session && challenge.fetch("userData").fetch("id") != session.fetch(:user).fetch("id")
133
- raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
134
- end
135
-
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)
139
- credential.verify(challenge.fetch("expectedChallenge"), user_verification: false)
140
- authenticator_data = passkey_authenticator_data(credential)
141
- target_user_id = passkey_after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
142
- data = ctx.context.adapter.create(
143
- model: "passkey",
144
- data: {
145
- name: body[:name],
146
- userId: target_user_id,
147
- credentialID: credential.id,
148
- publicKey: Base64.strict_encode64(credential.public_key),
149
- counter: credential.sign_count,
150
- deviceType: authenticator_data&.credential_backup_eligible? ? "multiDevice" : "singleDevice",
151
- backedUp: authenticator_data&.credential_backed_up? || false,
152
- transports: Array(passkey_attestation_response(credential)&.transports).join(","),
153
- createdAt: Time.now,
154
- aaguid: passkey_attestation_response(credential)&.aaguid
155
- }
156
- )
157
- ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
158
- ctx.json(passkey_wire(data))
159
- rescue WebAuthn::Error => error
160
- ctx.context.logger&.error("Failed to verify registration", error)
161
- raise APIError.new("INTERNAL_SERVER_ERROR", message: PASSKEY_ERROR_CODES.fetch("FAILED_TO_VERIFY_REGISTRATION"))
162
- end
49
+ def generate_passkey_authentication_options_endpoint(config)
50
+ BetterAuth::Passkey::Routes::Authentication.generate_passkey_authentication_options_endpoint(config)
163
51
  end
164
52
 
165
53
  def verify_passkey_authentication_endpoint(config)
166
- Endpoint.new(path: "/passkey/verify-authentication", method: "POST") do |ctx|
167
- body = normalize_hash(ctx.body)
168
- passkey_require_key!(body, :response)
169
- origin = passkey_origin(config, ctx)
170
- raise APIError.new("BAD_REQUEST", message: "origin missing") if origin.to_s.empty?
171
-
172
- verification_token = passkey_challenge_token(ctx, config)
173
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless verification_token
174
-
175
- challenge = passkey_find_challenge(ctx, verification_token)
176
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("CHALLENGE_NOT_FOUND")) unless challenge
177
-
178
- response = passkey_webauthn_response(body[:response])
179
- credential_id = response.fetch("id")
180
- passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "credentialID", value: credential_id}])
181
- raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
182
-
183
- relying_party = passkey_relying_party(config, ctx, origin: origin)
184
- credential = WebAuthn::Credential.from_get(response, relying_party: relying_party)
185
- credential.verify(
186
- challenge.fetch("expectedChallenge"),
187
- public_key: Base64.strict_decode64(passkey.fetch("publicKey")),
188
- sign_count: passkey.fetch("counter").to_i,
189
- user_verification: false
190
- )
191
- passkey_call_callback(config.dig(:authentication, :after_verification), {
192
- ctx: ctx,
193
- verification: credential,
194
- client_data: response
195
- })
196
- ctx.context.adapter.update(
197
- model: "passkey",
198
- where: [{field: "id", value: passkey.fetch("id")}],
199
- update: {counter: credential.sign_count}
200
- )
201
- session = ctx.context.internal_adapter.create_session(passkey.fetch("userId"))
202
- raise APIError.new("INTERNAL_SERVER_ERROR", message: PASSKEY_ERROR_CODES.fetch("UNABLE_TO_CREATE_SESSION")) unless session
203
-
204
- user = ctx.context.internal_adapter.find_user_by_id(passkey.fetch("userId"))
205
- raise APIError.new("INTERNAL_SERVER_ERROR", message: "User not found") unless user
206
-
207
- Cookies.set_session_cookie(ctx, {session: session, user: user})
208
- ctx.context.internal_adapter.delete_verification_by_identifier(verification_token)
209
- ctx.json({session: session, user: user})
210
- rescue WebAuthn::Error, ArgumentError => error
211
- ctx.context.logger&.error("Failed to verify authentication", error)
212
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("AUTHENTICATION_FAILED"))
213
- end
54
+ BetterAuth::Passkey::Routes::Authentication.verify_passkey_authentication_endpoint(config)
214
55
  end
215
56
 
216
57
  def list_passkeys_endpoint
217
- Endpoint.new(path: "/passkey/list-user-passkeys", method: "GET") do |ctx|
218
- session = Routes.current_session(ctx)
219
- passkeys = ctx.context.adapter.find_many(model: "passkey", where: [{field: "userId", value: session.fetch(:user).fetch("id")}])
220
- ctx.json(passkeys.map { |passkey| passkey_wire(passkey) })
221
- end
58
+ BetterAuth::Passkey::Routes::Management.list_passkeys_endpoint
222
59
  end
223
60
 
224
61
  def delete_passkey_endpoint
225
- Endpoint.new(path: "/passkey/delete-passkey", method: "POST") do |ctx|
226
- session = Routes.current_session(ctx)
227
- body = normalize_hash(ctx.body)
228
- passkey_require_string!(body, :id)
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
- unless passkey.fetch("userId") == session.fetch(:user).fetch("id")
232
- raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND"))
233
- end
234
-
235
- ctx.context.adapter.delete(model: "passkey", where: [{field: "id", value: passkey.fetch("id")}])
236
- ctx.json({status: true})
237
- end
62
+ BetterAuth::Passkey::Routes::Management.delete_passkey_endpoint
238
63
  end
239
64
 
240
65
  def update_passkey_endpoint
241
- Endpoint.new(path: "/passkey/update-passkey", method: "POST") do |ctx|
242
- session = Routes.current_session(ctx)
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
-
249
- passkey = ctx.context.adapter.find_one(model: "passkey", where: [{field: "id", value: body[:id]}])
250
- raise APIError.new("NOT_FOUND", message: PASSKEY_ERROR_CODES.fetch("PASSKEY_NOT_FOUND")) unless passkey
251
- if passkey.fetch("userId") != session.fetch(:user).fetch("id")
252
- raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
253
- end
254
-
255
- updated = ctx.context.adapter.update(
256
- model: "passkey",
257
- where: [{field: "id", value: body[:id]}],
258
- update: {name: body[:name].to_s}
259
- )
260
- raise APIError.new("INTERNAL_SERVER_ERROR", message: PASSKEY_ERROR_CODES.fetch("FAILED_TO_UPDATE_PASSKEY")) unless updated
261
-
262
- ctx.json({passkey: passkey_wire(updated)})
263
- end
66
+ BetterAuth::Passkey::Routes::Management.update_passkey_endpoint
264
67
  end
265
68
 
266
69
  def passkey_schema(custom_schema = nil)
267
- base = {
268
- passkey: {
269
- model_name: "passkeys",
270
- fields: {
271
- name: {type: "string", required: false},
272
- publicKey: {type: "string", required: true},
273
- userId: {type: "string", references: {model: "user", field: "id"}, required: true, index: true},
274
- credentialID: {type: "string", required: true, index: true},
275
- counter: {type: "number", required: true},
276
- deviceType: {type: "string", required: true},
277
- backedUp: {type: "boolean", required: true},
278
- transports: {type: "string", required: false},
279
- createdAt: {type: "date", required: false},
280
- aaguid: {type: "string", required: false}
281
- }
282
- }
283
- }
284
- passkey_deep_merge_hashes(normalize_hash(base), normalize_hash(custom_schema || {}))
70
+ BetterAuth::Passkey::Schema.passkey_schema(custom_schema)
285
71
  end
286
72
 
287
73
  def passkey_store_challenge(ctx, config, challenge, user_id)
288
- user_data = user_id.is_a?(Hash) ? user_id : {id: user_id}
289
- verification_token = Crypto.random_string(32)
290
- cookie = passkey_challenge_cookie(ctx, config)
291
- ctx.set_signed_cookie(cookie.name, verification_token, ctx.context.secret, cookie.attributes.merge(max_age: PASSKEY_CHALLENGE_MAX_AGE))
292
- ctx.context.internal_adapter.create_verification_value(
293
- identifier: verification_token,
294
- value: JSON.generate({
295
- expectedChallenge: challenge,
296
- userData: user_data,
297
- context: normalize_hash(ctx.query)[:context]
298
- }),
299
- expiresAt: Time.now + PASSKEY_CHALLENGE_MAX_AGE
300
- )
74
+ BetterAuth::Passkey::Challenges.store_challenge(ctx, config, challenge, user_id)
301
75
  end
302
76
 
303
77
  def passkey_find_challenge(ctx, verification_token)
304
- verification = ctx.context.internal_adapter.find_verification_value(verification_token)
305
- return nil unless verification && !Routes.expired_time?(verification["expiresAt"])
306
-
307
- JSON.parse(verification.fetch("value"))
308
- rescue JSON::ParserError
309
- nil
78
+ BetterAuth::Passkey::Challenges.find_challenge(ctx, verification_token)
310
79
  end
311
80
 
312
81
  def passkey_challenge_token(ctx, config)
313
- ctx.get_signed_cookie(passkey_challenge_cookie(ctx, config).name, ctx.context.secret)
82
+ BetterAuth::Passkey::Challenges.challenge_token(ctx, config)
314
83
  end
315
84
 
316
85
  def passkey_challenge_cookie(ctx, config)
317
- ctx.context.create_auth_cookie(config.dig(:advanced, :web_authn_challenge_cookie), max_age: PASSKEY_CHALLENGE_MAX_AGE)
86
+ BetterAuth::Passkey::Challenges.challenge_cookie(ctx, config)
318
87
  end
319
88
 
320
89
  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
- )
90
+ BetterAuth::Passkey::Utils.relying_party(config, ctx, origin: origin)
326
91
  end
327
92
 
328
93
  def passkey_origin(config, ctx)
329
- config[:origin] || ctx.headers["origin"]
94
+ BetterAuth::Passkey::Utils.origin(config, ctx)
330
95
  end
331
96
 
332
97
  def passkey_allowed_origins(config, ctx, origin: nil)
333
- Array(origin || config[:origin] || ctx.context.options.base_url).compact
98
+ BetterAuth::Passkey::Utils.allowed_origins(config, ctx, origin: origin)
334
99
  end
335
100
 
336
101
  def passkey_rp_id(config, ctx)
337
- return config[:rp_id] if config[:rp_id]
338
-
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"
343
- rescue URI::InvalidURIError
344
- "localhost"
102
+ BetterAuth::Passkey::Utils.rp_id(config, ctx)
345
103
  end
346
104
 
347
105
  def passkey_authenticator_selection(config, query)
348
- selection = normalize_hash(config[:authenticator_selection] || {})
349
- attachment = query[:authenticator_attachment]
350
- selection[:authenticator_attachment] = attachment if attachment
351
- {
352
- resident_key: selection[:resident_key] || "preferred",
353
- user_verification: selection[:user_verification] || "preferred",
354
- authenticator_attachment: selection[:authenticator_attachment]
355
- }.compact
106
+ BetterAuth::Passkey::Utils.authenticator_selection(config, query)
356
107
  end
357
108
 
358
109
  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"))
110
+ BetterAuth::Passkey::Utils.validate_authenticator_attachment!(value)
362
111
  end
363
112
 
364
113
  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"))
114
+ BetterAuth::Passkey::Utils.require_key!(body, key)
368
115
  end
369
116
 
370
117
  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"))
118
+ BetterAuth::Passkey::Utils.require_string!(body, key)
375
119
  end
376
120
 
377
121
  def passkey_resolve_registration_user(config, ctx, query)
378
- require_session = config.dig(:registration, :require_session) != false
379
- if require_session
380
- session = Routes.current_session(ctx, sensitive: true)
381
- user = session.fetch(:user)
382
- return passkey_registration_user_data(
383
- id: user.fetch("id"),
384
- name: user["email"] || user["id"],
385
- display_name: user["email"] || user["id"],
386
- email: user["email"]
387
- )
388
- end
389
-
390
- session = Routes.current_session(ctx, allow_nil: true)
391
- if session
392
- user = session.fetch(:user)
393
- return passkey_registration_user_data(
394
- id: user.fetch("id"),
395
- name: user["email"] || user["id"],
396
- display_name: user["email"] || user["id"],
397
- email: user["email"]
398
- )
399
- end
400
-
401
- resolver = config.dig(:registration, :resolve_user)
402
- unless resolver.respond_to?(:call)
403
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("RESOLVE_USER_REQUIRED"))
404
- end
405
-
406
- resolved = normalize_hash(passkey_call_callback(resolver, {ctx: ctx, context: query[:context]}) || {})
407
- unless resolved[:id].to_s != "" && resolved[:name].to_s != ""
408
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("RESOLVED_USER_INVALID"))
409
- end
410
-
411
- passkey_registration_user_data(
412
- id: resolved[:id],
413
- name: resolved[:name],
414
- display_name: resolved[:display_name],
415
- email: resolved[:email]
416
- )
122
+ BetterAuth::Passkey::Utils.resolve_registration_user(config, ctx, query)
417
123
  end
418
124
 
419
125
  def passkey_registration_user_data(id:, name:, display_name: nil, email: nil)
420
- {
421
- "id" => id,
422
- "name" => name,
423
- "displayName" => display_name,
424
- "email" => email
425
- }.compact
126
+ BetterAuth::Passkey::Utils.registration_user_data(id: id, name: name, display_name: display_name, email: email)
426
127
  end
427
128
 
428
129
  def passkey_resolve_extensions(extensions, ctx)
429
- return nil unless extensions
430
-
431
- normalize_hash(extensions.respond_to?(:call) ? passkey_call_callback(extensions, {ctx: ctx}) : extensions)
130
+ BetterAuth::Passkey::Utils.resolve_extensions(extensions, ctx)
432
131
  end
433
132
 
434
133
  def passkey_after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
435
- user_data = challenge.fetch("userData")
436
- target_user_id = user_data.fetch("id")
437
- callback = config.dig(:registration, :after_verification)
438
- return target_user_id unless callback.respond_to?(:call)
439
-
440
- result = normalize_hash(passkey_call_callback(callback, {
441
- ctx: ctx,
442
- verification: credential,
443
- user: {
444
- id: user_data.fetch("id"),
445
- name: user_data["name"] || user_data.fetch("id"),
446
- display_name: user_data["displayName"] || user_data["display_name"]
447
- },
448
- client_data: response,
449
- context: challenge["context"]
450
- }) || {})
451
- returned_user_id = result[:user_id]
452
- return target_user_id if returned_user_id.nil? || returned_user_id == ""
453
-
454
- unless returned_user_id.is_a?(String) && returned_user_id.length.positive?
455
- raise APIError.new("BAD_REQUEST", message: PASSKEY_ERROR_CODES.fetch("RESOLVED_USER_INVALID"))
456
- end
457
-
458
- if session && returned_user_id != session.fetch(:user).fetch("id")
459
- raise APIError.new("UNAUTHORIZED", message: PASSKEY_ERROR_CODES.fetch("YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY"))
460
- end
461
-
462
- returned_user_id
134
+ BetterAuth::Passkey::Utils.after_registration_verification_user_id(config, ctx, credential, challenge, response, session)
463
135
  end
464
136
 
465
137
  def passkey_call_callback(callback, data)
466
- return nil unless callback.respond_to?(:call)
467
-
468
- if callback.parameters.any? { |kind, _name| [:key, :keyreq, :keyrest].include?(kind) }
469
- callback.call(**data)
470
- else
471
- callback.call(data)
472
- end
138
+ BetterAuth::Passkey::Utils.call_callback(callback, data)
473
139
  end
474
140
 
475
141
  def passkey_webauthn_response(value)
476
- data = normalize_hash(value || {})
477
- response = normalize_hash(data[:response] || {})
478
- webauthn = {
479
- "type" => data[:type],
480
- "id" => data[:id],
481
- "rawId" => data[:raw_id],
482
- "authenticatorAttachment" => data[:authenticator_attachment],
483
- "clientExtensionResults" => data[:client_extension_results] || {},
484
- "response" => {
485
- "attestationObject" => response[:attestation_object],
486
- "clientDataJSON" => response[:client_data_json],
487
- "transports" => response[:transports],
488
- "authenticatorData" => response[:authenticator_data],
489
- "signature" => response[:signature],
490
- "userHandle" => response[:user_handle]
491
- }.compact
492
- }.compact
493
- webauthn["rawId"] ||= webauthn["id"]
494
- webauthn
142
+ BetterAuth::Passkey::Credentials.webauthn_response(value)
495
143
  end
496
144
 
497
145
  def passkey_attestation_response(credential)
498
- credential.instance_variable_get(:@response)
146
+ BetterAuth::Passkey::Credentials.attestation_response(credential)
499
147
  end
500
148
 
501
149
  def passkey_authenticator_data(credential)
502
- passkey_attestation_response(credential)&.authenticator_data
150
+ BetterAuth::Passkey::Credentials.authenticator_data(credential)
503
151
  end
504
152
 
505
153
  def passkey_wire(record)
506
- return record unless record.is_a?(Hash)
507
-
508
- output = record.dup
509
- output["credentialID"] = output.delete("credentialId") if output.key?("credentialId")
510
- output
154
+ BetterAuth::Passkey::Credentials.wire(record)
511
155
  end
512
156
 
513
157
  def passkey_credential_id(record)
514
- record["credentialID"] || record["credentialId"] || record[:credentialID] || record[:credential_id]
158
+ BetterAuth::Passkey::Credentials.credential_id(record)
515
159
  end
516
160
 
517
161
  def passkey_credential_descriptor(record, kind: :allow)
518
- descriptor = {id: passkey_credential_id(record)}
519
- descriptor[:type] = "public-key" if kind == :allow
520
- transports = (record["transports"] || record[:transports]).to_s.split(",").map(&:strip).reject(&:empty?)
521
- descriptor[:transports] = transports if transports.any?
522
- descriptor
162
+ BetterAuth::Passkey::Credentials.credential_descriptor(record, kind: kind)
523
163
  end
524
164
 
525
165
  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
166
+ BetterAuth::Passkey::Schema.deep_merge_hashes(base, override)
533
167
  end
534
168
  end
535
169
  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.2.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -130,6 +130,15 @@ files:
130
130
  - CHANGELOG.md
131
131
  - README.md
132
132
  - lib/better_auth/passkey.rb
133
+ - lib/better_auth/passkey/challenges.rb
134
+ - lib/better_auth/passkey/credentials.rb
135
+ - lib/better_auth/passkey/error_codes.rb
136
+ - lib/better_auth/passkey/routes.rb
137
+ - lib/better_auth/passkey/routes/authentication.rb
138
+ - lib/better_auth/passkey/routes/management.rb
139
+ - lib/better_auth/passkey/routes/registration.rb
140
+ - lib/better_auth/passkey/schema.rb
141
+ - lib/better_auth/passkey/utils.rb
133
142
  - lib/better_auth/passkey/version.rb
134
143
  - lib/better_auth/plugins/passkey.rb
135
144
  homepage: https://github.com/sebasxsala/better-auth