better_auth 0.8.0 → 0.11.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/CHANGELOG.md +92 -0
- data/LICENSE.md +1 -1
- data/README.md +142 -95
- data/lib/better_auth/adapters/base.rb +64 -1
- data/lib/better_auth/adapters/internal_adapter.rb +197 -11
- data/lib/better_auth/adapters/join_support.rb +10 -0
- data/lib/better_auth/adapters/memory.rb +303 -54
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +390 -63
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +48 -47
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +119 -15
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +108 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/http_client.rb +46 -0
- data/lib/better_auth/logger.rb +1 -1
- data/lib/better_auth/middleware/origin_check.rb +9 -1
- data/lib/better_auth/migration_plan.rb +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- data/lib/better_auth/oauth_state.rb +147 -0
- data/lib/better_auth/plugin_loader.rb +220 -0
- data/lib/better_auth/plugins/access.rb +10 -13
- data/lib/better_auth/plugins/admin.rb +85 -29
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- data/lib/better_auth/plugins/api_key.rb +4 -7
- data/lib/better_auth/plugins/bearer.rb +8 -22
- data/lib/better_auth/plugins/captcha.rb +24 -6
- data/lib/better_auth/plugins/device_authorization.rb +77 -5
- data/lib/better_auth/plugins/dub.rb +10 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +162 -140
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +44 -19
- data/lib/better_auth/plugins/magic_link.rb +18 -21
- data/lib/better_auth/plugins/oauth_popup.rb +330 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
- data/lib/better_auth/plugins/oauth_provider.rb +4 -7
- data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
- data/lib/better_auth/plugins/one_tap.rb +78 -65
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +77 -9
- data/lib/better_auth/plugins/organization.rb +546 -102
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +25 -27
- data/lib/better_auth/plugins/scim.rb +4 -7
- data/lib/better_auth/plugins/siwe.rb +126 -16
- data/lib/better_auth/plugins/sso.rb +4 -7
- data/lib/better_auth/plugins/stripe.rb +4 -7
- data/lib/better_auth/plugins/two_factor.rb +205 -19
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +300 -133
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +44 -8
- data/lib/better_auth/routes/email_verification.rb +93 -6
- data/lib/better_auth/routes/password.rb +6 -6
- data/lib/better_auth/routes/session.rb +7 -2
- data/lib/better_auth/routes/sign_in.rb +10 -1
- data/lib/better_auth/routes/sign_up.rb +45 -22
- data/lib/better_auth/routes/social.rb +197 -54
- data/lib/better_auth/routes/user.rb +17 -10
- data/lib/better_auth/schema/sql.rb +189 -34
- data/lib/better_auth/schema.rb +159 -5
- data/lib/better_auth/session.rb +29 -10
- data/lib/better_auth/session_store.rb +1 -1
- data/lib/better_auth/social_providers/apple.rb +6 -1
- data/lib/better_auth/social_providers/atlassian.rb +1 -0
- data/lib/better_auth/social_providers/base.rb +45 -23
- data/lib/better_auth/social_providers/cognito.rb +75 -5
- data/lib/better_auth/social_providers/facebook.rb +95 -1
- data/lib/better_auth/social_providers/figma.rb +2 -0
- data/lib/better_auth/social_providers/github.rb +2 -0
- data/lib/better_auth/social_providers/google.rb +12 -1
- data/lib/better_auth/social_providers/line.rb +35 -1
- data/lib/better_auth/social_providers/linear.rb +1 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
- data/lib/better_auth/social_providers/naver.rb +24 -1
- data/lib/better_auth/social_providers/notion.rb +1 -0
- data/lib/better_auth/social_providers/paybin.rb +25 -1
- data/lib/better_auth/social_providers/paypal.rb +118 -2
- data/lib/better_auth/social_providers/reddit.rb +25 -4
- data/lib/better_auth/social_providers/salesforce.rb +1 -0
- data/lib/better_auth/social_providers/twitch.rb +24 -1
- data/lib/better_auth/social_providers/twitter.rb +36 -1
- data/lib/better_auth/social_providers/vk.rb +34 -1
- data/lib/better_auth/social_providers/wechat.rb +6 -4
- data/lib/better_auth/sql_migration.rb +566 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +14 -41
- metadata +14 -18
- data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
- data/lib/better_auth/plugins/mcp/config.rb +0 -51
- data/lib/better_auth/plugins/mcp/consent.rb +0 -31
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
- data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
- data/lib/better_auth/plugins/mcp/registration.rb +0 -31
- data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
- data/lib/better_auth/plugins/mcp/schema.rb +0 -91
- data/lib/better_auth/plugins/mcp/token.rb +0 -108
- data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
- data/lib/better_auth/plugins/mcp.rb +0 -215
- data/lib/better_auth/plugins/oidc_provider.rb +0 -710
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "base64"
|
|
4
|
+
require "json"
|
|
4
5
|
require "jwt"
|
|
5
6
|
require "openssl"
|
|
6
7
|
require "time"
|
|
@@ -29,12 +30,18 @@ module BetterAuth
|
|
|
29
30
|
parse_scopes(value).join(" ")
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
def request_body!(value)
|
|
34
|
+
return stringify_keys(value || {}) if value.nil? || value.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
raise APIError.new("BAD_REQUEST", message: "request body must be an object")
|
|
37
|
+
end
|
|
38
|
+
|
|
32
39
|
def issuer(ctx)
|
|
33
|
-
|
|
40
|
+
origin_for(ctx.context.canonical_base_url)
|
|
34
41
|
end
|
|
35
42
|
|
|
36
43
|
def endpoint_base(ctx)
|
|
37
|
-
ctx.context.
|
|
44
|
+
ctx.context.canonical_base_url
|
|
38
45
|
end
|
|
39
46
|
|
|
40
47
|
def origin_for(url)
|
|
@@ -53,6 +60,7 @@ module BetterAuth
|
|
|
53
60
|
end
|
|
54
61
|
|
|
55
62
|
def validate_redirect_uri!(client, redirect_uri)
|
|
63
|
+
validate_safe_url!(redirect_uri, field: "redirect_uri")
|
|
56
64
|
redirects = client_redirect_uris(client)
|
|
57
65
|
return if redirects.include?(redirect_uri.to_s)
|
|
58
66
|
return if loopback_redirect_match?(redirects, redirect_uri)
|
|
@@ -99,15 +107,19 @@ module BetterAuth
|
|
|
99
107
|
value.to_s.split(",").map(&:strip).reject(&:empty?)
|
|
100
108
|
end
|
|
101
109
|
|
|
102
|
-
def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain", unauthenticated: false, default_scopes: nil, allowed_scopes: nil, prefix: {}, dynamic_registration: false, admin: false, pairwise_secret: nil, strip_client_metadata: false, reference_id: nil)
|
|
103
|
-
body =
|
|
110
|
+
def create_client(ctx, model:, body:, owner_session: nil, default_auth_method: "client_secret_basic", store_client_secret: "plain", unauthenticated: false, default_scopes: nil, allowed_scopes: nil, prefix: {}, dynamic_registration: false, admin: false, pairwise_secret: nil, strip_client_metadata: false, reference_id: nil, generate_client_id: nil, generate_client_secret: nil, client_registration_client_secret_expiration: nil)
|
|
111
|
+
body = request_body!(body || {})
|
|
104
112
|
requested_auth_method = body["token_endpoint_auth_method"] || default_auth_method
|
|
105
113
|
validate_client_metadata_enums!(requested_auth_method, body)
|
|
106
114
|
validate_admin_only_fields!(body, admin: admin)
|
|
107
115
|
auth_method = unauthenticated ? "none" : requested_auth_method
|
|
108
116
|
public_client = auth_method == "none"
|
|
109
|
-
client_id = Crypto.random_string(32)
|
|
110
|
-
client_secret = public_client
|
|
117
|
+
client_id = generate_client_id.respond_to?(:call) ? generate_client_id.call : Crypto.random_string(32)
|
|
118
|
+
client_secret = if public_client
|
|
119
|
+
nil
|
|
120
|
+
else
|
|
121
|
+
generate_client_secret.respond_to?(:call) ? generate_client_secret.call : Crypto.random_string(32)
|
|
122
|
+
end
|
|
111
123
|
redirects = Array(body["redirect_uris"]).map(&:to_s)
|
|
112
124
|
raise APIError.new("BAD_REQUEST", message: "redirect_uris is required") if redirects.empty?
|
|
113
125
|
redirects.each { |uri| validate_safe_url!(uri, field: "redirect_uris") }
|
|
@@ -116,6 +128,7 @@ module BetterAuth
|
|
|
116
128
|
grant_types = Array(body["grant_types"] || [AUTH_CODE_GRANT]).map(&:to_s)
|
|
117
129
|
response_types = Array(body["response_types"] || ["code"]).map(&:to_s)
|
|
118
130
|
validate_client_registration!(auth_method, grant_types, response_types, body, unauthenticated: unauthenticated, dynamic_registration: dynamic_registration)
|
|
131
|
+
validate_redirect_scheme_for_client!(auth_method, body, redirects)
|
|
119
132
|
validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
120
133
|
|
|
121
134
|
scopes = parse_scopes(body["scope"] || body["scopes"])
|
|
@@ -139,6 +152,12 @@ module BetterAuth
|
|
|
139
152
|
else
|
|
140
153
|
body["type"] || (public_client ? nil : "web")
|
|
141
154
|
end
|
|
155
|
+
issued_at = Time.now.to_i
|
|
156
|
+
secret_expires_at = if client_secret && dynamic_registration && !client_registration_client_secret_expiration.nil?
|
|
157
|
+
resolve_client_secret_expires_at(client_registration_client_secret_expiration, issued_at)
|
|
158
|
+
elsif admin
|
|
159
|
+
body["client_secret_expires_at"] || 0
|
|
160
|
+
end
|
|
142
161
|
data = {
|
|
143
162
|
"clientId" => client_id,
|
|
144
163
|
"clientSecret" => client_secret ? store_client_secret_value(ctx, client_secret, store_client_secret) : nil,
|
|
@@ -156,7 +175,7 @@ module BetterAuth
|
|
|
156
175
|
"redirectUris" => redirects,
|
|
157
176
|
"redirectUrls" => redirects.join(","),
|
|
158
177
|
"postLogoutRedirectUris" => Array(body["post_logout_redirect_uris"]).map(&:to_s),
|
|
159
|
-
"clientSecretExpiresAt" =>
|
|
178
|
+
"clientSecretExpiresAt" => secret_expires_at,
|
|
160
179
|
"tokenEndpointAuthMethod" => auth_method,
|
|
161
180
|
"grantTypes" => grant_types,
|
|
162
181
|
"responseTypes" => response_types,
|
|
@@ -176,7 +195,10 @@ module BetterAuth
|
|
|
176
195
|
client_id_issued_at: Time.now.to_i
|
|
177
196
|
).compact
|
|
178
197
|
response[:require_pkce] = require_pkce unless require_pkce.nil?
|
|
179
|
-
|
|
198
|
+
if client_secret
|
|
199
|
+
expires = stringify_keys(created || {})["clientSecretExpiresAt"] || secret_expires_at
|
|
200
|
+
response[:client_secret_expires_at] = timestamp_seconds(expires) || 0
|
|
201
|
+
end
|
|
180
202
|
response
|
|
181
203
|
end
|
|
182
204
|
|
|
@@ -237,6 +259,20 @@ module BetterAuth
|
|
|
237
259
|
end
|
|
238
260
|
end
|
|
239
261
|
|
|
262
|
+
def validate_redirect_scheme_for_client!(auth_method, body, redirects)
|
|
263
|
+
return if auth_method == "none" && body["type"] != "web"
|
|
264
|
+
|
|
265
|
+
redirects.each do |value|
|
|
266
|
+
uri = URI.parse(value.to_s)
|
|
267
|
+
next if uri.scheme == "https"
|
|
268
|
+
next if uri.scheme == "http" && loopback_host?(uri.hostname || uri.host)
|
|
269
|
+
|
|
270
|
+
raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
|
|
271
|
+
end
|
|
272
|
+
rescue URI::InvalidURIError
|
|
273
|
+
raise APIError.new("BAD_REQUEST", message: "redirect_uris is invalid")
|
|
274
|
+
end
|
|
275
|
+
|
|
240
276
|
def validate_pairwise_client!(body, redirects, pairwise_secret)
|
|
241
277
|
subject_type = body["subject_type"] || body["subjectType"]
|
|
242
278
|
return unless subject_type == "pairwise"
|
|
@@ -244,7 +280,12 @@ module BetterAuth
|
|
|
244
280
|
raise APIError.new("BAD_REQUEST", message: "pairwise subject_type requires pairwise_secret") if pairwise_secret.to_s.empty?
|
|
245
281
|
|
|
246
282
|
hosts = redirects.map { |uri| URI.parse(uri).host }.uniq
|
|
247
|
-
|
|
283
|
+
if hosts.length > 1
|
|
284
|
+
raise APIError.new(
|
|
285
|
+
"BAD_REQUEST",
|
|
286
|
+
message: "pairwise clients with redirect_uris on different hosts require a sector_identifier_uri, which is not yet supported. All redirect_uris must share the same host."
|
|
287
|
+
)
|
|
288
|
+
end
|
|
248
289
|
rescue URI::InvalidURIError
|
|
249
290
|
raise APIError.new("BAD_REQUEST", message: "invalid redirect_uris")
|
|
250
291
|
end
|
|
@@ -266,7 +307,11 @@ module BetterAuth
|
|
|
266
307
|
end
|
|
267
308
|
|
|
268
309
|
def client_metadata(body, strip_unknown: false)
|
|
269
|
-
|
|
310
|
+
raw_metadata = body["metadata"]
|
|
311
|
+
unless raw_metadata.nil? || raw_metadata.is_a?(Hash)
|
|
312
|
+
raise APIError.new("BAD_REQUEST", message: "metadata must be an object")
|
|
313
|
+
end
|
|
314
|
+
metadata = stringify_keys(raw_metadata || {})
|
|
270
315
|
metadata = metadata.slice("software_id", "software_version", "software_statement", "tos_uri", "policy_uri") if strip_unknown
|
|
271
316
|
metadata["software_id"] = body["software_id"] if body["software_id"]
|
|
272
317
|
metadata["software_version"] = body["software_version"] if body["software_version"]
|
|
@@ -309,19 +354,36 @@ module BetterAuth
|
|
|
309
354
|
end
|
|
310
355
|
end
|
|
311
356
|
|
|
312
|
-
def find_client(ctx, model, client_id)
|
|
313
|
-
|
|
357
|
+
def find_client(ctx, model, client_id, cached_trusted_clients: nil)
|
|
358
|
+
client_id = client_id.to_s
|
|
359
|
+
if (cached = trusted_client_cache[client_id])
|
|
360
|
+
return cached
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
client = ctx.context.adapter.find_one(model: model, where: [{field: "clientId", value: client_id}])
|
|
364
|
+
if client && cached_trusted_clients&.include?(client_id)
|
|
365
|
+
trusted_client_cache[client_id] = client
|
|
366
|
+
end
|
|
367
|
+
client
|
|
314
368
|
end
|
|
315
369
|
|
|
316
|
-
def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {})
|
|
317
|
-
body =
|
|
370
|
+
def authenticate_client!(ctx, model, store_client_secret: "plain", prefix: {}, require_confidential: false)
|
|
371
|
+
body = request_body!(ctx.body || {})
|
|
318
372
|
client_id = body["client_id"]
|
|
319
373
|
client_secret = strip_prefix(body["client_secret"], prefix, :client_secret) || body["client_secret"]
|
|
320
374
|
|
|
321
375
|
authorization = ctx.headers["authorization"]
|
|
322
|
-
|
|
323
|
-
|
|
376
|
+
auth_method_used = client_secret.to_s.empty? ? nil : "client_secret_post"
|
|
377
|
+
if authorization.to_s.start_with?("Basic ")
|
|
378
|
+
decoded = Base64.strict_decode64(authorization.delete_prefix("Basic "))
|
|
379
|
+
unless decoded.include?(":")
|
|
380
|
+
raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
|
|
381
|
+
end
|
|
324
382
|
client_id, client_secret = decoded.split(":", 2)
|
|
383
|
+
if client_id.to_s.empty? || client_secret.to_s.empty?
|
|
384
|
+
raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
|
|
385
|
+
end
|
|
386
|
+
auth_method_used = "client_secret_basic"
|
|
325
387
|
end
|
|
326
388
|
|
|
327
389
|
client = find_client(ctx, model, client_id)
|
|
@@ -332,20 +394,35 @@ module BetterAuth
|
|
|
332
394
|
|
|
333
395
|
method = client_data["tokenEndpointAuthMethod"] || "client_secret_basic"
|
|
334
396
|
if method == "none"
|
|
397
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") if require_confidential
|
|
335
398
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless client_secret.to_s.empty?
|
|
336
399
|
return client
|
|
337
400
|
end
|
|
401
|
+
expected_method = (method == "client_secret_post") ? "client_secret_post" : "client_secret_basic"
|
|
402
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client") unless auth_method_used == expected_method
|
|
403
|
+
if client_secret_expired?(client_data["clientSecretExpiresAt"])
|
|
404
|
+
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
405
|
+
end
|
|
338
406
|
if method != "none" && !verify_client_secret(ctx, stringify_keys(client)["clientSecret"], client_secret, store_client_secret)
|
|
339
407
|
raise APIError.new("UNAUTHORIZED", message: "invalid_client")
|
|
340
408
|
end
|
|
341
409
|
|
|
342
|
-
client
|
|
410
|
+
client.merge("__providedClientSecret" => client_secret)
|
|
343
411
|
rescue ArgumentError
|
|
344
|
-
raise APIError.new("
|
|
412
|
+
raise APIError.new("BAD_REQUEST", message: "invalid authorization header format", body: {error: "invalid_client", error_description: "invalid authorization header format"})
|
|
345
413
|
end
|
|
346
414
|
|
|
347
|
-
def
|
|
348
|
-
|
|
415
|
+
def client_secret_expired?(value)
|
|
416
|
+
return false if value.nil? || value.to_i == 0
|
|
417
|
+
|
|
418
|
+
seconds = timestamp_seconds(value)
|
|
419
|
+
seconds && seconds <= Time.now.to_i
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def store_code(internal_adapter, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil, nonce: nil, reference_id: nil, auth_time: nil, expires_in: 600, store_tokens: "hashed")
|
|
423
|
+
stored_code = get_stored_token(store_tokens, code, "authorization_code")
|
|
424
|
+
expires_at = Time.now + expires_in.to_i
|
|
425
|
+
data = {
|
|
349
426
|
client_id: client_id,
|
|
350
427
|
redirect_uri: redirect_uri,
|
|
351
428
|
session: session,
|
|
@@ -355,14 +432,19 @@ module BetterAuth
|
|
|
355
432
|
nonce: nonce,
|
|
356
433
|
reference_id: reference_id,
|
|
357
434
|
auth_time: auth_time || session_auth_time(session),
|
|
358
|
-
expires_at:
|
|
435
|
+
expires_at: expires_at.iso8601(6)
|
|
359
436
|
}
|
|
437
|
+
internal_adapter.create_verification_value(identifier: stored_code, value: JSON.generate(data), expiresAt: expires_at)
|
|
360
438
|
end
|
|
361
439
|
|
|
362
|
-
def consume_code!(
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
440
|
+
def consume_code!(internal_adapter, code, client_id:, redirect_uri:, code_verifier: nil, store_tokens: "hashed")
|
|
441
|
+
stored_code = get_stored_token(store_tokens, code.to_s, "authorization_code")
|
|
442
|
+
verification = internal_adapter.consume_verification_value(stored_code)
|
|
443
|
+
verification ||= internal_adapter.consume_verification_value(code.to_s) if stored_code != code.to_s
|
|
444
|
+
data = verification && JSON.parse(verification.fetch("value"), symbolize_names: true)
|
|
445
|
+
unless data.is_a?(Hash) && !data[:client_id].to_s.empty? && !data[:redirect_uri].to_s.empty? && data[:session].is_a?(Hash) && data[:scopes].is_a?(Array)
|
|
446
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant")
|
|
447
|
+
end
|
|
366
448
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:client_id] == client_id.to_s
|
|
367
449
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data[:redirect_uri] == redirect_uri.to_s
|
|
368
450
|
if data[:code_challenge]
|
|
@@ -372,6 +454,8 @@ module BetterAuth
|
|
|
372
454
|
end
|
|
373
455
|
|
|
374
456
|
data
|
|
457
|
+
rescue JSON::ParserError, KeyError, TypeError
|
|
458
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant")
|
|
375
459
|
end
|
|
376
460
|
|
|
377
461
|
def verify_pkce!(code_data, verifier)
|
|
@@ -396,6 +480,7 @@ module BetterAuth
|
|
|
396
480
|
|
|
397
481
|
def pkce_required?(client, scopes)
|
|
398
482
|
data = stringify_keys(client)
|
|
483
|
+
return true if data["public"] || data["tokenEndpointAuthMethod"] == "none" || ["native", "user-agent-based"].include?(data["type"])
|
|
399
484
|
return true if parse_scopes(scopes).include?("offline_access")
|
|
400
485
|
require_pkce = client_require_pkce(data)
|
|
401
486
|
return require_pkce unless require_pkce.nil?
|
|
@@ -403,7 +488,7 @@ module BetterAuth
|
|
|
403
488
|
true
|
|
404
489
|
end
|
|
405
490
|
|
|
406
|
-
def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false)
|
|
491
|
+
def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false, store_tokens: "hashed", generate_opaque_access_token: nil, generate_refresh_token: nil, format_refresh_token: nil)
|
|
407
492
|
data = stringify_keys(session || {})
|
|
408
493
|
user = stringify_keys(data["user"] || data[:user] || {})
|
|
409
494
|
session_data = stringify_keys(data["session"] || data[:session] || {})
|
|
@@ -411,9 +496,11 @@ module BetterAuth
|
|
|
411
496
|
subject = subject_identifier(user["id"], client_data, pairwise_secret)
|
|
412
497
|
token_auth_time = auth_time || session_auth_time({"session" => session_data})
|
|
413
498
|
token_reference_id = reference_id || client_data["referenceId"]
|
|
414
|
-
access_token_value = Crypto.random_string(32)
|
|
415
|
-
refresh_token_value = include_refresh
|
|
416
|
-
|
|
499
|
+
access_token_value = generate_opaque_access_token.respond_to?(:call) ? generate_opaque_access_token.call : Crypto.random_string(32)
|
|
500
|
+
refresh_token_value = if include_refresh
|
|
501
|
+
generate_refresh_token.respond_to?(:call) ? generate_refresh_token.call : Crypto.random_string(32)
|
|
502
|
+
end
|
|
503
|
+
refresh_token = refresh_token_value ? encode_refresh_token(refresh_token_value, prefix: prefix, format_refresh_token: format_refresh_token, session_id: session_data["id"]) : nil
|
|
417
504
|
scope = scope_string(scopes)
|
|
418
505
|
expires_at = Time.now + access_token_expires_in.to_i
|
|
419
506
|
access_token = if jwt_access_token && audience
|
|
@@ -424,7 +511,7 @@ module BetterAuth
|
|
|
424
511
|
refresh_record = nil
|
|
425
512
|
if refresh_token_value
|
|
426
513
|
refresh_record = {
|
|
427
|
-
"token" => refresh_token_value,
|
|
514
|
+
"token" => store_token_value(store_tokens, refresh_token_value, "refresh_token"),
|
|
428
515
|
"clientId" => client_data["clientId"],
|
|
429
516
|
"sessionId" => session_data["id"],
|
|
430
517
|
"userId" => user["id"],
|
|
@@ -440,13 +527,13 @@ module BetterAuth
|
|
|
440
527
|
"issuedAt" => Time.now
|
|
441
528
|
}
|
|
442
529
|
created_refresh = schema_model?(ctx, "oauthRefreshToken") ? ctx.context.adapter.create(model: "oauthRefreshToken", data: refresh_record) : nil
|
|
443
|
-
refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
|
|
530
|
+
refresh_record = refresh_record.merge("id" => stringify_keys(created_refresh || {})["id"], "token" => refresh_token_value, "user" => user, "session" => session_data, "client" => client_data, "scope" => scope)
|
|
444
531
|
store[:refresh_tokens][refresh_token_value] = refresh_record
|
|
445
532
|
store[:refresh_tokens][refresh_token] = refresh_record
|
|
446
533
|
end
|
|
447
534
|
unless jwt_access_token && audience
|
|
448
535
|
record = {
|
|
449
|
-
"token" => access_token_value,
|
|
536
|
+
"token" => store_token_value(store_tokens, access_token_value, "access_token"),
|
|
450
537
|
"expiresAt" => expires_at,
|
|
451
538
|
"clientId" => client_data["clientId"],
|
|
452
539
|
"userId" => user["id"],
|
|
@@ -464,7 +551,7 @@ module BetterAuth
|
|
|
464
551
|
created_access = ctx.context.adapter.create(model: model, data: record)
|
|
465
552
|
created = stringify_keys(created_access || {})
|
|
466
553
|
record = record.merge("id" => created["id"]) if created["id"]
|
|
467
|
-
stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
|
|
554
|
+
stored_record = record.merge("token" => access_token_value, "user" => user, "session" => session_data, "client" => client_data)
|
|
468
555
|
store[:tokens][access_token_value] = stored_record
|
|
469
556
|
store[:tokens][access_token] = stored_record
|
|
470
557
|
end
|
|
@@ -478,7 +565,8 @@ module BetterAuth
|
|
|
478
565
|
}
|
|
479
566
|
response[:audience] = audience if audience
|
|
480
567
|
response[:refresh_token] = refresh_token if refresh_token
|
|
481
|
-
|
|
568
|
+
id_token_client_data = client_data.merge("clientSecret" => client_data["__providedClientSecret"] || client_data["clientSecret"])
|
|
569
|
+
response[:id_token] = id_token(user.merge("id" => subject), client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer, session_id: session_data["id"], include_sid: !!client_data["enableEndSession"], nonce: nonce, auth_time: token_auth_time, custom_claims: custom_id_token_claims, scopes: parse_scopes(scope), client: id_token_client_data, filter_claims_by_scope: filter_id_token_claims_by_scope, expires_in: id_token_expires_in, use_jwt_plugin: use_jwt_plugin) if parse_scopes(scope).include?("openid")
|
|
482
570
|
if custom_token_response_fields.respond_to?(:call)
|
|
483
571
|
extra = custom_token_response_fields.call({grant_type: grant_type, user: user.empty? ? nil : user, scopes: parse_scopes(scope), metadata: stringify_keys(client_data["metadata"] || {})})
|
|
484
572
|
response.merge!(stringify_keys(extra).reject { |key, _value| standard_token_response_field?(key) }.transform_keys(&:to_sym)) if extra.is_a?(Hash)
|
|
@@ -486,9 +574,10 @@ module BetterAuth
|
|
|
486
574
|
response
|
|
487
575
|
end
|
|
488
576
|
|
|
489
|
-
def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false)
|
|
490
|
-
refresh_token_value =
|
|
491
|
-
|
|
577
|
+
def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false, store_tokens: "hashed", generate_opaque_access_token: nil, generate_refresh_token: nil, format_refresh_token: nil)
|
|
578
|
+
refresh_token_value = decode_refresh_token(refresh_token, prefix: prefix, format_refresh_token: format_refresh_token)
|
|
579
|
+
raise APIError.new("BAD_REQUEST", message: "invalid_grant") if refresh_token_value.to_s.empty?
|
|
580
|
+
data = store[:refresh_tokens][refresh_token_value]
|
|
492
581
|
raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
|
|
493
582
|
if data["revoked"]
|
|
494
583
|
revoke_refresh_family!(ctx, store, data)
|
|
@@ -532,7 +621,11 @@ module BetterAuth
|
|
|
532
621
|
id_token_expires_in: id_token_expires_in,
|
|
533
622
|
auth_time: data["authTime"],
|
|
534
623
|
reference_id: data["referenceId"],
|
|
535
|
-
filter_id_token_claims_by_scope: filter_id_token_claims_by_scope
|
|
624
|
+
filter_id_token_claims_by_scope: filter_id_token_claims_by_scope,
|
|
625
|
+
store_tokens: store_tokens,
|
|
626
|
+
generate_opaque_access_token: generate_opaque_access_token,
|
|
627
|
+
generate_refresh_token: generate_refresh_token,
|
|
628
|
+
format_refresh_token: format_refresh_token
|
|
536
629
|
)
|
|
537
630
|
end
|
|
538
631
|
|
|
@@ -565,8 +658,7 @@ module BetterAuth
|
|
|
565
658
|
"exp" => expires_at.to_i
|
|
566
659
|
).compact
|
|
567
660
|
if use_jwt_plugin
|
|
568
|
-
|
|
569
|
-
return signed if signed
|
|
661
|
+
return sign_oauth_jwt!(ctx, payload, issuer: issuer_value, audience: audience)
|
|
570
662
|
end
|
|
571
663
|
|
|
572
664
|
::JWT.encode(payload, ctx.context.secret, "HS256")
|
|
@@ -643,9 +735,12 @@ module BetterAuth
|
|
|
643
735
|
)
|
|
644
736
|
end
|
|
645
737
|
|
|
646
|
-
def find_token_by_hint(store, token, hint, prefix: {})
|
|
738
|
+
def find_token_by_hint(store, token, hint, prefix: {}, format_refresh_token: nil)
|
|
647
739
|
access = -> { (value = strip_prefix(token, prefix, :access_token)) && store[:tokens][value] }
|
|
648
|
-
refresh =
|
|
740
|
+
refresh = lambda {
|
|
741
|
+
value = decode_refresh_token(token, prefix: prefix, format_refresh_token: format_refresh_token)
|
|
742
|
+
value && store[:refresh_tokens][value]
|
|
743
|
+
}
|
|
649
744
|
|
|
650
745
|
case hint.to_s
|
|
651
746
|
when "access_token"
|
|
@@ -695,6 +790,65 @@ module BetterAuth
|
|
|
695
790
|
"#{token_prefix(prefix, kind)}#{value}"
|
|
696
791
|
end
|
|
697
792
|
|
|
793
|
+
def encode_refresh_token(token_value, prefix:, format_refresh_token: nil, session_id: nil)
|
|
794
|
+
formatted = if format_refresh_token.is_a?(Hash) && format_refresh_token[:encrypt].respond_to?(:call)
|
|
795
|
+
format_refresh_token[:encrypt].call(token_value, session_id)
|
|
796
|
+
else
|
|
797
|
+
token_value
|
|
798
|
+
end
|
|
799
|
+
apply_prefix(formatted, prefix, :refresh_token)
|
|
800
|
+
end
|
|
801
|
+
|
|
802
|
+
def decode_refresh_token(token, prefix:, format_refresh_token: nil)
|
|
803
|
+
value = strip_prefix(token, prefix, :refresh_token)
|
|
804
|
+
return nil if value.nil?
|
|
805
|
+
|
|
806
|
+
if format_refresh_token.is_a?(Hash) && format_refresh_token[:decrypt].respond_to?(:call)
|
|
807
|
+
decrypted = format_refresh_token[:decrypt].call(value)
|
|
808
|
+
if decrypted.is_a?(Hash)
|
|
809
|
+
decrypted[:token] || decrypted["token"]
|
|
810
|
+
else
|
|
811
|
+
decrypted.to_s
|
|
812
|
+
end
|
|
813
|
+
else
|
|
814
|
+
value
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
|
|
818
|
+
def trusted_client_cache
|
|
819
|
+
@trusted_client_cache ||= {}
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
def resolve_client_secret_expires_at(value, issued_at)
|
|
823
|
+
return value.to_i if value.is_a?(Numeric)
|
|
824
|
+
return value.to_i if value.is_a?(Time)
|
|
825
|
+
|
|
826
|
+
seconds = timestamp_seconds(value)
|
|
827
|
+
return seconds if seconds && !value.is_a?(String)
|
|
828
|
+
|
|
829
|
+
issued_at.to_i + parse_duration(value.to_s)
|
|
830
|
+
rescue TypeError
|
|
831
|
+
0
|
|
832
|
+
end
|
|
833
|
+
|
|
834
|
+
def parse_duration(value)
|
|
835
|
+
match = value.strip.match(/\A(-?\d+)\s*(s|sec|secs|second|seconds|m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days|w|week|weeks|y|yr|yrs|year|years)(?:\s+from now|\s+ago)?\z/i)
|
|
836
|
+
raise TypeError, "Invalid time string" unless match
|
|
837
|
+
|
|
838
|
+
amount = match[1].to_i
|
|
839
|
+
amount = -amount if value.include?("ago")
|
|
840
|
+
unit = match[2].downcase
|
|
841
|
+
multiplier = case unit
|
|
842
|
+
when "s", "sec", "secs", "second", "seconds" then 1
|
|
843
|
+
when "m", "min", "mins", "minute", "minutes" then 60
|
|
844
|
+
when "h", "hr", "hrs", "hour", "hours" then 3600
|
|
845
|
+
when "d", "day", "days" then 86_400
|
|
846
|
+
when "w", "week", "weeks" then 604_800
|
|
847
|
+
else 31_557_600
|
|
848
|
+
end
|
|
849
|
+
amount * multiplier
|
|
850
|
+
end
|
|
851
|
+
|
|
698
852
|
def strip_prefix(value, prefix, kind)
|
|
699
853
|
token = value.to_s
|
|
700
854
|
expected = token_prefix(prefix, kind)
|
|
@@ -745,8 +899,7 @@ module BetterAuth
|
|
|
745
899
|
return signer.call(ctx, payload) if signer.respond_to?(:call)
|
|
746
900
|
|
|
747
901
|
if use_jwt_plugin && ctx
|
|
748
|
-
|
|
749
|
-
return signed if signed
|
|
902
|
+
return sign_oauth_jwt!(ctx, payload, issuer: issuer_value, audience: audience)
|
|
750
903
|
end
|
|
751
904
|
|
|
752
905
|
Crypto.sign_jwt(
|
|
@@ -757,6 +910,11 @@ module BetterAuth
|
|
|
757
910
|
end
|
|
758
911
|
|
|
759
912
|
def id_token_hs256_key(ctx, client_id, client_secret = nil)
|
|
913
|
+
oauth_provider = ctx&.context&.options&.plugins&.find { |plugin| plugin.id == "oauth-provider" }
|
|
914
|
+
if oauth_provider&.options&.fetch(:store_client_secret, nil).to_s == "hashed"
|
|
915
|
+
label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
|
|
916
|
+
return OpenSSL::HMAC.hexdigest("SHA256", ctx.context.secret.to_s, "oidc.id_token.#{label}")
|
|
917
|
+
end
|
|
760
918
|
return client_secret.to_s unless client_secret.to_s.empty?
|
|
761
919
|
|
|
762
920
|
label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
|
|
@@ -782,6 +940,15 @@ module BetterAuth
|
|
|
782
940
|
BetterAuth::Plugins.sign_jwt_payload(ctx, stringify_keys(payload), config)
|
|
783
941
|
end
|
|
784
942
|
|
|
943
|
+
def sign_oauth_jwt!(ctx, payload, issuer:, audience:)
|
|
944
|
+
raise BetterAuth::Error, "jwt_config" unless jwt_plugin_options(ctx)
|
|
945
|
+
|
|
946
|
+
signed = sign_oauth_jwt(ctx, payload, issuer: issuer, audience: audience)
|
|
947
|
+
raise BetterAuth::Error, "jwt_config" unless signed
|
|
948
|
+
|
|
949
|
+
signed
|
|
950
|
+
end
|
|
951
|
+
|
|
785
952
|
def verify_oauth_jwt(ctx, token, issuer:, hs256_secret:)
|
|
786
953
|
payload = ::JWT.decode(token.to_s, nil, false).first
|
|
787
954
|
audience = payload["aud"]
|
|
@@ -857,10 +1024,29 @@ module BetterAuth
|
|
|
857
1024
|
secret
|
|
858
1025
|
end
|
|
859
1026
|
|
|
1027
|
+
def store_token_value(storage_method, token, type)
|
|
1028
|
+
case storage_method
|
|
1029
|
+
when "hashed", :hashed
|
|
1030
|
+
Crypto.sha256(token.to_s, encoding: :base64url)
|
|
1031
|
+
else
|
|
1032
|
+
mode = normalize_secret_storage_mode(storage_method)
|
|
1033
|
+
return mode[:hash].call(token.to_s, type) if mode.is_a?(Hash) && mode[:hash].respond_to?(:call)
|
|
1034
|
+
|
|
1035
|
+
raise Error, "storeToken: unsupported storageMethod type '#{storage_method}'"
|
|
1036
|
+
end
|
|
1037
|
+
end
|
|
1038
|
+
|
|
1039
|
+
def get_stored_token(storage_method, token, type)
|
|
1040
|
+
store_token_value(storage_method, token, type)
|
|
1041
|
+
end
|
|
1042
|
+
|
|
860
1043
|
def verify_client_secret(ctx, stored_secret, provided_secret, mode)
|
|
861
1044
|
mode = normalize_secret_storage_mode(mode)
|
|
862
1045
|
return Crypto.constant_time_compare(Crypto.sha256(provided_secret, encoding: :base64url), stored_secret.to_s) if mode == "hashed"
|
|
863
|
-
|
|
1046
|
+
if mode == "encrypted"
|
|
1047
|
+
decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_secret)
|
|
1048
|
+
return Crypto.constant_time_compare(decrypted.to_s, provided_secret.to_s)
|
|
1049
|
+
end
|
|
864
1050
|
|
|
865
1051
|
if mode.is_a?(Hash)
|
|
866
1052
|
return Crypto.constant_time_compare(mode[:hash].call(provided_secret).to_s, stored_secret.to_s) if mode[:hash].respond_to?(:call)
|
|
@@ -868,6 +1054,8 @@ module BetterAuth
|
|
|
868
1054
|
end
|
|
869
1055
|
|
|
870
1056
|
Crypto.constant_time_compare(stored_secret.to_s, provided_secret.to_s)
|
|
1057
|
+
rescue Error, ArgumentError
|
|
1058
|
+
false
|
|
871
1059
|
end
|
|
872
1060
|
|
|
873
1061
|
def normalize_secret_storage_mode(mode)
|
|
@@ -878,7 +1066,6 @@ module BetterAuth
|
|
|
878
1066
|
|
|
879
1067
|
def stores
|
|
880
1068
|
{
|
|
881
|
-
codes: {},
|
|
882
1069
|
tokens: {},
|
|
883
1070
|
refresh_tokens: {},
|
|
884
1071
|
consents: {}
|
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
return if defined?(BetterAuth::Plugins::OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION)
|
|
4
|
+
|
|
3
5
|
module BetterAuth
|
|
4
6
|
module Plugins
|
|
5
7
|
module_function
|
|
6
8
|
|
|
7
|
-
def oauth_provider(*args)
|
|
8
|
-
|
|
9
|
-
BetterAuth::Plugins.oauth_provider(*args)
|
|
10
|
-
rescue LoadError => error
|
|
11
|
-
raise if error.path && error.path != "better_auth/oauth_provider"
|
|
12
|
-
|
|
13
|
-
raise LoadError, "BetterAuth::Plugins.oauth_provider requires the better_auth-oauth-provider gem. Add `gem \"better_auth-oauth-provider\"` and `require \"better_auth/oauth_provider\"`."
|
|
9
|
+
def oauth_provider(*args, &block)
|
|
10
|
+
call_external_plugin!(:oauth_provider, *args, implementation_constant: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-oauth-provider", entry: "lib/better_auth/oauth_provider.rb", &block)
|
|
14
11
|
end
|
|
15
12
|
end
|
|
16
13
|
end
|