better_auth-passkey 0.2.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +10 -0
- data/lib/better_auth/passkey/challenges.rb +46 -0
- data/lib/better_auth/passkey/credentials.rb +59 -0
- data/lib/better_auth/passkey/error_codes.rb +24 -0
- data/lib/better_auth/passkey/routes/authentication.rb +94 -0
- data/lib/better_auth/passkey/routes/management.rb +61 -0
- data/lib/better_auth/passkey/routes/registration.rb +93 -0
- data/lib/better_auth/passkey/routes.rb +5 -0
- data/lib/better_auth/passkey/schema.rb +57 -0
- data/lib/better_auth/passkey/utils.rb +174 -0
- data/lib/better_auth/passkey/version.rb +1 -1
- data/lib/better_auth/passkey.rb +6 -0
- data/lib/better_auth/plugins/passkey.rb +37 -403
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e2c8efe9ffa3d3b3e9e9058ea634d112dedf87191602c80416a4dc1c1300686b
|
|
4
|
+
data.tar.gz: c86ac1bcb22af335f614331737bc5bb14da9b8b6ae61fd8bfa737631084c75b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: adbf65435960e42abf9a7e22cd139c729dd08e5a4c826f02b68fb1acccf2efc4cd7216a0955ef538c23de152fdafd3acf977583140889286eaf9b62a2c7562d3
|
|
7
|
+
data.tar.gz: 5d27c29f09d309d7e67b7e704a2a4374405636407e2bd333581d398f1424ebf9d52f2b6e0d317a6709dcf8637d2e04efa0091493a420b5d8f9734fdd29fe7b59
|
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,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
|
data/lib/better_auth/passkey.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
+
BetterAuth::Passkey::Challenges.challenge_token(ctx, config)
|
|
314
83
|
end
|
|
315
84
|
|
|
316
85
|
def passkey_challenge_cookie(ctx, config)
|
|
317
|
-
|
|
86
|
+
BetterAuth::Passkey::Challenges.challenge_cookie(ctx, config)
|
|
318
87
|
end
|
|
319
88
|
|
|
320
89
|
def passkey_relying_party(config, ctx, origin: nil)
|
|
321
|
-
|
|
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
|
|
94
|
+
BetterAuth::Passkey::Utils.origin(config, ctx)
|
|
330
95
|
end
|
|
331
96
|
|
|
332
97
|
def passkey_allowed_origins(config, ctx, origin: nil)
|
|
333
|
-
|
|
98
|
+
BetterAuth::Passkey::Utils.allowed_origins(config, ctx, origin: origin)
|
|
334
99
|
end
|
|
335
100
|
|
|
336
101
|
def passkey_rp_id(config, ctx)
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
146
|
+
BetterAuth::Passkey::Credentials.attestation_response(credential)
|
|
499
147
|
end
|
|
500
148
|
|
|
501
149
|
def passkey_authenticator_data(credential)
|
|
502
|
-
|
|
150
|
+
BetterAuth::Passkey::Credentials.authenticator_data(credential)
|
|
503
151
|
end
|
|
504
152
|
|
|
505
153
|
def passkey_wire(record)
|
|
506
|
-
|
|
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
|
|
158
|
+
BetterAuth::Passkey::Credentials.credential_id(record)
|
|
515
159
|
end
|
|
516
160
|
|
|
517
161
|
def passkey_credential_descriptor(record, kind: :allow)
|
|
518
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
+
version: 0.6.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
|