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