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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "better_auth/sql_migration"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
module Doctor
|
|
7
|
+
Result = Struct.new(:ok, :warnings, :errors) do
|
|
8
|
+
def success?
|
|
9
|
+
errors.empty?
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def check(config_or_options)
|
|
16
|
+
config = BetterAuth::SQLMigration.configuration_for(config_or_options)
|
|
17
|
+
result = Result.new(ok: ["config loaded"], warnings: [], errors: [])
|
|
18
|
+
|
|
19
|
+
check_secret(config, result)
|
|
20
|
+
check_base_url(config, result)
|
|
21
|
+
check_rate_limit(config, result)
|
|
22
|
+
check_database(config, result)
|
|
23
|
+
|
|
24
|
+
result
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def print(result, stdout:, stderr:)
|
|
28
|
+
result.ok.each { |message| stdout.puts "OK #{message}" }
|
|
29
|
+
result.warnings.each { |message| stdout.puts "WARN #{message}" }
|
|
30
|
+
result.errors.each { |message| stderr.puts "ERROR #{message}" }
|
|
31
|
+
result.success? ? 0 : 1
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def as_json(result)
|
|
35
|
+
{
|
|
36
|
+
"ok" => result.ok,
|
|
37
|
+
"warnings" => result.warnings,
|
|
38
|
+
"errors" => result.errors,
|
|
39
|
+
"success" => result.success?
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def check_secret(config, result)
|
|
44
|
+
secret = config.secret.to_s
|
|
45
|
+
if secret.empty?
|
|
46
|
+
result.errors << "secret is missing"
|
|
47
|
+
elsif secret == BetterAuth::Configuration::DEFAULT_SECRET
|
|
48
|
+
result.errors << "secret uses the default development value"
|
|
49
|
+
elsif secret.length < 32
|
|
50
|
+
result.errors << "secret should be at least 32 characters"
|
|
51
|
+
elsif entropy(secret) < 120
|
|
52
|
+
result.errors << "secret appears low-entropy; use a random production secret"
|
|
53
|
+
else
|
|
54
|
+
result.ok << "secret length and entropy look acceptable"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def check_base_url(config, result)
|
|
59
|
+
base_url = config.base_url.to_s
|
|
60
|
+
if base_url.empty?
|
|
61
|
+
result.errors << "base_url is not configured"
|
|
62
|
+
elsif !base_url.start_with?("https://")
|
|
63
|
+
result.warnings << "base_url is not HTTPS"
|
|
64
|
+
else
|
|
65
|
+
result.ok << "base_url uses HTTPS"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_rate_limit(config, result)
|
|
70
|
+
rate_limit = config.rate_limit || {}
|
|
71
|
+
result.warnings << "rate_limit is disabled" unless rate_limit[:enabled]
|
|
72
|
+
if rate_limit[:storage].to_s == "memory"
|
|
73
|
+
result.warnings << "rate_limit uses memory storage; use database or secondary-storage for multi-process production deployments"
|
|
74
|
+
else
|
|
75
|
+
result.ok << "rate_limit storage is #{rate_limit[:storage]}"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def check_database(config, result)
|
|
80
|
+
auth = BetterAuth.auth(config.to_h)
|
|
81
|
+
adapter = auth.context.adapter
|
|
82
|
+
unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
|
|
83
|
+
warning = "database adapter does not expose SQL migration introspection; schema drift check skipped"
|
|
84
|
+
warning += "; run `better-auth mongo indexes` to ensure MongoDB indexes" if adapter.respond_to?(:ensure_indexes!)
|
|
85
|
+
result.warnings << warning
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
result.ok << "database adapter supports SQL migrations"
|
|
90
|
+
plan = BetterAuth::SQLMigration.plan(config, connection: adapter.connection, dialect: adapter.dialect)
|
|
91
|
+
if plan.empty?
|
|
92
|
+
result.ok << "database schema is up to date"
|
|
93
|
+
else
|
|
94
|
+
result.warnings << "database has pending Better Auth migrations"
|
|
95
|
+
plan.warnings.each { |warning| result.warnings << warning }
|
|
96
|
+
end
|
|
97
|
+
rescue BetterAuth::SQLMigration::UnsupportedAdapterError => error
|
|
98
|
+
result.warnings << error.message
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def entropy(value)
|
|
102
|
+
unique = value.chars.uniq.length
|
|
103
|
+
return 0 if unique.zero?
|
|
104
|
+
|
|
105
|
+
Math.log2(unique**value.length)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
data/lib/better_auth/endpoint.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "uri"
|
|
4
5
|
|
|
5
6
|
module BetterAuth
|
|
6
7
|
class Endpoint
|
|
@@ -24,6 +25,7 @@ module BetterAuth
|
|
|
24
25
|
@headers_schema = headers_schema
|
|
25
26
|
@metadata = metadata || {}
|
|
26
27
|
apply_default_open_api_metadata!
|
|
28
|
+
apply_open_api_defaults!
|
|
27
29
|
apply_open_api_schemas!
|
|
28
30
|
@options = endpoint_options
|
|
29
31
|
@use = Array(use)
|
|
@@ -40,13 +42,12 @@ module BetterAuth
|
|
|
40
42
|
end
|
|
41
43
|
|
|
42
44
|
def call(context)
|
|
43
|
-
apply_schemas!(context)
|
|
44
|
-
|
|
45
45
|
use.each do |middleware|
|
|
46
46
|
middleware_result = middleware.call(context)
|
|
47
47
|
return Result.from_value(middleware_result, context) if middleware_result
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
apply_schemas!(context)
|
|
50
51
|
Result.from_value(handler.call(context), context)
|
|
51
52
|
end
|
|
52
53
|
|
|
@@ -72,6 +73,41 @@ module BetterAuth
|
|
|
72
73
|
metadata[:openapi] = BetterAuth::OpenAPI.default_metadata(path, methods)
|
|
73
74
|
end
|
|
74
75
|
|
|
76
|
+
def apply_open_api_defaults!
|
|
77
|
+
return unless path
|
|
78
|
+
return unless defined?(BetterAuth::OpenAPI)
|
|
79
|
+
|
|
80
|
+
openapi = fetch_key(metadata, :openapi)
|
|
81
|
+
return unless openapi.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
defaults = BetterAuth::OpenAPI.default_metadata(path, methods)
|
|
84
|
+
openapi[:operationId] = defaults[:operationId] if fetch_key(openapi, :operationId).to_s.empty?
|
|
85
|
+
openapi[:description] = defaults[:description] if default_open_api_description?(fetch_key(openapi, :description))
|
|
86
|
+
openapi[:parameters] = merge_open_api_parameters(defaults[:parameters], fetch_key(openapi, :parameters))
|
|
87
|
+
openapi[:responses] = defaults[:responses].merge(fetch_key(openapi, :responses) || {})
|
|
88
|
+
if request_body_method? && !fetch_key(openapi, :requestBody).is_a?(Hash)
|
|
89
|
+
openapi[:requestBody] = defaults[:requestBody] || BetterAuth::OpenAPI.default_request_body
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def default_open_api_description?(description)
|
|
94
|
+
methods.any? { |method| description.to_s == "#{method} #{path}" }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def request_body_method?
|
|
98
|
+
methods.any? { |method| %w[POST PUT PATCH].include?(method) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def merge_open_api_parameters(default_parameters, custom_parameters)
|
|
102
|
+
merged = Array(custom_parameters).dup
|
|
103
|
+
Array(default_parameters).each do |parameter|
|
|
104
|
+
next if merged.any? { |entry| fetch_key(entry, :name).to_s == fetch_key(parameter, :name).to_s && fetch_key(entry, :in).to_s == fetch_key(parameter, :in).to_s }
|
|
105
|
+
|
|
106
|
+
merged << parameter
|
|
107
|
+
end
|
|
108
|
+
merged
|
|
109
|
+
end
|
|
110
|
+
|
|
75
111
|
def apply_open_api_schemas!
|
|
76
112
|
openapi = fetch_key(metadata, :openapi)
|
|
77
113
|
return unless openapi.is_a?(Hash)
|
|
@@ -172,6 +208,42 @@ module BetterAuth
|
|
|
172
208
|
class Result
|
|
173
209
|
attr_accessor :response, :status, :headers
|
|
174
210
|
|
|
211
|
+
class SetCookieHeader < Array
|
|
212
|
+
def self.from(value)
|
|
213
|
+
new(value.lines.map(&:chomp))
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def include?(value)
|
|
217
|
+
return super unless value.is_a?(String)
|
|
218
|
+
|
|
219
|
+
any? { |line| line.include?(value) }
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def lines
|
|
223
|
+
self
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def match(...)
|
|
227
|
+
to_s.match(...)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def =~(pattern)
|
|
231
|
+
to_s =~ pattern
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def split(...)
|
|
235
|
+
to_s.split(...)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def to_s
|
|
239
|
+
join("\n")
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def to_str
|
|
243
|
+
to_s
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
175
247
|
def initialize(response:, status: 200, headers: {}, raw_response: nil)
|
|
176
248
|
@response = response
|
|
177
249
|
@status = status
|
|
@@ -223,7 +295,7 @@ module BetterAuth
|
|
|
223
295
|
end
|
|
224
296
|
|
|
225
297
|
def to_response
|
|
226
|
-
return Response.from_rack(@raw_response) if raw_response?
|
|
298
|
+
return Response.from_rack([@raw_response[0], rack_headers(@raw_response[1]), @raw_response[2]]) if raw_response?
|
|
227
299
|
|
|
228
300
|
body = if response.nil?
|
|
229
301
|
[JSON.generate(nil)]
|
|
@@ -232,12 +304,23 @@ module BetterAuth
|
|
|
232
304
|
else
|
|
233
305
|
[JSON.generate(response)]
|
|
234
306
|
end
|
|
235
|
-
response_headers = {"content-type" => "application/json"}.merge(headers)
|
|
307
|
+
response_headers = rack_headers({"content-type" => "application/json"}.merge(headers))
|
|
236
308
|
Response.new(status: status, headers: response_headers, body: body)
|
|
237
309
|
end
|
|
238
310
|
|
|
239
311
|
private
|
|
240
312
|
|
|
313
|
+
def rack_headers(value)
|
|
314
|
+
value.each_with_object({}) do |(key, header_value), result|
|
|
315
|
+
normalized = key.to_s.downcase
|
|
316
|
+
result[normalized] = if normalized == "set-cookie" && header_value.is_a?(String) && header_value.include?("\n")
|
|
317
|
+
SetCookieHeader.from(header_value)
|
|
318
|
+
else
|
|
319
|
+
header_value
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
241
324
|
def normalize_headers(headers)
|
|
242
325
|
headers.each_with_object({}) do |(key, value), result|
|
|
243
326
|
result[key.to_s.downcase] = value
|
|
@@ -290,7 +373,7 @@ module BetterAuth
|
|
|
290
373
|
|
|
291
374
|
def set_cookie(name, value, options = {})
|
|
292
375
|
attributes = cookie_attributes(options)
|
|
293
|
-
cookie = (["#{name}=#{value}"] + attributes).join("; ")
|
|
376
|
+
cookie = (["#{name}=#{URI.encode_uri_component(value.to_s)}"] + attributes).join("; ")
|
|
294
377
|
set_header("set-cookie", cookie)
|
|
295
378
|
end
|
|
296
379
|
|
data/lib/better_auth/error.rb
CHANGED
|
@@ -17,6 +17,7 @@ module BetterAuth
|
|
|
17
17
|
"PROVIDER_NOT_FOUND" => "Provider not found",
|
|
18
18
|
"INVALID_TOKEN" => "Invalid token",
|
|
19
19
|
"TOKEN_EXPIRED" => "Token expired",
|
|
20
|
+
"TOKEN_ALREADY_USED" => "Token has already been used",
|
|
20
21
|
"ID_TOKEN_NOT_SUPPORTED" => "id_token not supported",
|
|
21
22
|
"FAILED_TO_GET_USER_INFO" => "Failed to get user info",
|
|
22
23
|
"USER_EMAIL_NOT_FOUND" => "User email not found",
|
|
@@ -54,6 +55,7 @@ module BetterAuth
|
|
|
54
55
|
"PASSWORD_ALREADY_SET" => "User already has a password set",
|
|
55
56
|
"RESET_PASSWORD_DISABLED" => "Reset password isn't enabled",
|
|
56
57
|
"EMAIL_PASSWORD_DISABLED" => "Email and password is not enabled",
|
|
57
|
-
"EMAIL_PASSWORD_SIGN_UP_DISABLED" => "Email and password sign up is not enabled"
|
|
58
|
+
"EMAIL_PASSWORD_SIGN_UP_DISABLED" => "Email and password sign up is not enabled",
|
|
59
|
+
"CHANGE_EMAIL_DISABLED" => "Change email is disabled"
|
|
58
60
|
}.freeze
|
|
59
61
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module BetterAuth
|
|
8
|
+
module HTTPClient
|
|
9
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
10
|
+
DEFAULT_READ_TIMEOUT = 5
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def request(uri, request, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
15
|
+
Net::HTTP.start(
|
|
16
|
+
uri.hostname || uri.host,
|
|
17
|
+
uri.port,
|
|
18
|
+
use_ssl: uri.scheme == "https",
|
|
19
|
+
open_timeout: open_timeout,
|
|
20
|
+
read_timeout: read_timeout
|
|
21
|
+
) do |http|
|
|
22
|
+
http.request(request)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def get_response(uri, headers = {})
|
|
27
|
+
request = Net::HTTP::Get.new(uri)
|
|
28
|
+
headers.each { |key, value| request[key.to_s] = value.to_s }
|
|
29
|
+
request(uri, request)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def post_form(uri, form_body, headers = {})
|
|
33
|
+
request = Net::HTTP::Post.new(uri)
|
|
34
|
+
headers.each { |key, value| request[key.to_s] = value.to_s }
|
|
35
|
+
request["Content-Type"] ||= "application/x-www-form-urlencoded"
|
|
36
|
+
request.body = form_body
|
|
37
|
+
request(uri, request)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def get_json(url, headers = {})
|
|
41
|
+
uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
|
|
42
|
+
response = get_response(uri, headers)
|
|
43
|
+
response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body.to_s) : nil
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/better_auth/logger.rb
CHANGED
|
@@ -4,7 +4,7 @@ module BetterAuth
|
|
|
4
4
|
module Logger
|
|
5
5
|
LEVELS = [:debug, :info, :success, :warn, :error].freeze
|
|
6
6
|
|
|
7
|
-
Internal = Struct.new(:level, :disabled, :handler
|
|
7
|
+
Internal = Struct.new(:level, :disabled, :handler) do
|
|
8
8
|
LEVELS.each do |log_level|
|
|
9
9
|
define_method(log_level) do |message, *args|
|
|
10
10
|
return if disabled || !Logger.should_publish?(level, log_level)
|
|
@@ -55,7 +55,11 @@ module BetterAuth
|
|
|
55
55
|
mode = headers["sec-fetch-mode"]
|
|
56
56
|
dest = headers["sec-fetch-dest"]
|
|
57
57
|
has_metadata = [site, mode, dest].any? { |value| value && !value.to_s.strip.empty? }
|
|
58
|
-
|
|
58
|
+
unless has_metadata
|
|
59
|
+
origin = headers["origin"] || headers["referer"]
|
|
60
|
+
validate_origin(endpoint_context, force: true) if login_or_signup_path?(endpoint_context.path) && origin && !origin.to_s.empty?
|
|
61
|
+
return
|
|
62
|
+
end
|
|
59
63
|
|
|
60
64
|
if site == "cross-site" && mode == "navigate"
|
|
61
65
|
log(endpoint_context.context, :error, "Blocked cross-site navigation login attempt (CSRF protection)")
|
|
@@ -109,6 +113,10 @@ module BetterAuth
|
|
|
109
113
|
skip.any? { |path| endpoint_context.path.start_with?(path.to_s) }
|
|
110
114
|
end
|
|
111
115
|
|
|
116
|
+
def login_or_signup_path?(path)
|
|
117
|
+
path.to_s.match?(%r{/(?:sign-in|sign-up)(?:/|$)})
|
|
118
|
+
end
|
|
119
|
+
|
|
112
120
|
def fetch_data(data, key)
|
|
113
121
|
return unless data.is_a?(Hash)
|
|
114
122
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module MigrationPlan
|
|
5
|
+
TableChange = Struct.new(:logical_name, :table_name, :table, :order)
|
|
6
|
+
FieldChange = Struct.new(:logical_name, :table_name, :fields, :table, :order)
|
|
7
|
+
IndexChange = Struct.new(:table_name, :field_name, :name, :unique, :field)
|
|
8
|
+
|
|
9
|
+
Plan = Struct.new(:to_create, :to_add, :to_index, :warnings, :dialect, :tables) do
|
|
10
|
+
def empty?
|
|
11
|
+
to_create.empty? && to_add.empty? && to_index.empty?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/better_auth/oauth2.rb
CHANGED
|
@@ -81,7 +81,7 @@ module BetterAuth
|
|
|
81
81
|
|
|
82
82
|
def post_form(token_endpoint, request)
|
|
83
83
|
uri = URI.parse(token_endpoint)
|
|
84
|
-
response =
|
|
84
|
+
response = HTTPClient.post_form(uri, URI.encode_www_form(request[:body]), request[:headers])
|
|
85
85
|
JSON.parse(response.body)
|
|
86
86
|
end
|
|
87
87
|
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
# Shared OAuth state storage matching upstream's opaque-state adaptation.
|
|
7
|
+
# State data is encrypted client-side for the cookie strategy or consumed
|
|
8
|
+
# from verification storage for the database strategy. Signed JWT state is
|
|
9
|
+
# still accepted so in-flight legacy sign-in and link flows keep working.
|
|
10
|
+
module OAuthState
|
|
11
|
+
INTERNAL_KEYS = %w[
|
|
12
|
+
callbackURL codeVerifier errorURL newUserURL expiresAt oauthState link requestSignUp
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
class Error < BetterAuth::Error
|
|
16
|
+
attr_reader :code, :error_url
|
|
17
|
+
|
|
18
|
+
def initialize(code, error_url: nil)
|
|
19
|
+
@code = code
|
|
20
|
+
@error_url = error_url
|
|
21
|
+
super(code)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
def generate(ctx, state_data)
|
|
28
|
+
state = Crypto.random_string(32)
|
|
29
|
+
payload = stringify_keys(state_data).merge("oauthState" => state)
|
|
30
|
+
|
|
31
|
+
if cookie_strategy?(ctx)
|
|
32
|
+
cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
|
|
33
|
+
encrypted = Crypto.symmetric_encrypt(
|
|
34
|
+
key: ctx.context.secret_config,
|
|
35
|
+
data: JSON.generate(payload)
|
|
36
|
+
)
|
|
37
|
+
ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
|
|
38
|
+
else
|
|
39
|
+
cookie = ctx.context.create_auth_cookie("state", max_age: 300)
|
|
40
|
+
ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
|
|
41
|
+
verification = ctx.context.internal_adapter.create_verification_value(
|
|
42
|
+
identifier: state,
|
|
43
|
+
value: JSON.generate(payload),
|
|
44
|
+
expiresAt: Time.now + 600
|
|
45
|
+
)
|
|
46
|
+
raise Error, "state_generation_error" unless verification
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
state
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def parse(ctx, state)
|
|
53
|
+
raise Error, "state_not_found" if state.to_s.empty?
|
|
54
|
+
|
|
55
|
+
legacy = Crypto.verify_jwt(state.to_s, ctx.context.secret)
|
|
56
|
+
return parse_legacy(ctx, state, legacy) if legacy
|
|
57
|
+
|
|
58
|
+
cookie_strategy?(ctx) ? parse_cookie_state(ctx, state) : parse_database_state(ctx, state)
|
|
59
|
+
rescue JSON::ParserError
|
|
60
|
+
raise Error, "state_invalid"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_legacy(ctx, state, data)
|
|
64
|
+
cookie = ctx.context.create_auth_cookie("state", max_age: 600)
|
|
65
|
+
stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
|
|
66
|
+
Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
|
|
67
|
+
valid = ctx.request ? stored == state : (stored.nil? || stored == state)
|
|
68
|
+
return data if valid
|
|
69
|
+
|
|
70
|
+
raise Error.new("state_mismatch", error_url: recovered_error_url(data))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def parse_cookie_state(ctx, state)
|
|
74
|
+
cookie = ctx.context.create_auth_cookie("oauth_state")
|
|
75
|
+
encrypted = ctx.get_cookie(cookie.name)
|
|
76
|
+
raise Error, "state_mismatch" if encrypted.to_s.empty?
|
|
77
|
+
|
|
78
|
+
data = begin
|
|
79
|
+
decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
|
|
80
|
+
JSON.parse(decrypted)
|
|
81
|
+
rescue JSON::ParserError, ArgumentError, TypeError
|
|
82
|
+
raise Error, "state_invalid"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
expected = data["oauthState"] || data["state"]
|
|
86
|
+
unless expected == state
|
|
87
|
+
raise Error.new("state_mismatch", error_url: recovered_error_url(data))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
Cookies.expire_cookie(ctx, cookie)
|
|
91
|
+
validate_expiration!(data)
|
|
92
|
+
data
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def parse_database_state(ctx, state)
|
|
96
|
+
preview = ctx.context.internal_adapter.find_verification_value(state)
|
|
97
|
+
raise Error, "state_mismatch" unless preview
|
|
98
|
+
|
|
99
|
+
data = JSON.parse(preview.fetch("value"))
|
|
100
|
+
error_url = recovered_error_url(data)
|
|
101
|
+
expected = data["oauthState"] || data["state"]
|
|
102
|
+
raise Error.new("state_mismatch", error_url: error_url) if expected && expected != state
|
|
103
|
+
|
|
104
|
+
cookie = ctx.context.create_auth_cookie("state")
|
|
105
|
+
stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
|
|
106
|
+
valid = ctx.request ? stored == state : (stored.nil? || stored == state)
|
|
107
|
+
raise Error.new("state_mismatch", error_url: error_url) unless valid
|
|
108
|
+
|
|
109
|
+
consumed = ctx.context.internal_adapter.consume_verification_value(state)
|
|
110
|
+
Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
|
|
111
|
+
raise Error.new("state_mismatch", error_url: error_url) unless consumed
|
|
112
|
+
|
|
113
|
+
consumed_data = JSON.parse(consumed.fetch("value"))
|
|
114
|
+
consumed_expected = consumed_data["oauthState"] || consumed_data["state"]
|
|
115
|
+
same_payload = consumed.fetch("value") == preview.fetch("value")
|
|
116
|
+
expected_matches = !consumed_expected || consumed_expected == state
|
|
117
|
+
unless same_payload && expected_matches
|
|
118
|
+
raise Error.new("state_mismatch", error_url: recovered_error_url(consumed_data) || error_url)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
validate_expiration!(consumed_data)
|
|
122
|
+
consumed_data
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def validate_expiration!(data)
|
|
126
|
+
expires_at = data["expiresAt"].to_i
|
|
127
|
+
return data unless expires_at.positive? && expires_at < Time.now.to_i
|
|
128
|
+
|
|
129
|
+
raise Error.new("state_mismatch", error_url: recovered_error_url(data))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def recovered_error_url(data)
|
|
133
|
+
data["errorURL"] || data["errorCallbackURL"]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def cookie_strategy?(ctx)
|
|
137
|
+
ctx.context.options.account[:store_state_strategy].to_s == "cookie"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def stringify_keys(value)
|
|
141
|
+
return value.each_with_object({}) { |(key, object), result| result[key.to_s] = stringify_keys(object) } if value.is_a?(Hash)
|
|
142
|
+
return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
|
|
143
|
+
|
|
144
|
+
value
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|