better_auth 0.8.0 → 0.10.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 +9 -0
- data/README.md +4 -4
- data/lib/better_auth/adapters/memory.rb +131 -17
- data/lib/better_auth/adapters/sql.rb +139 -57
- data/lib/better_auth/configuration.rb +7 -1
- data/lib/better_auth/cookies.rb +11 -3
- data/lib/better_auth/doctor.rb +97 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/http_client.rb +46 -0
- data/lib/better_auth/migration_plan.rb +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- data/lib/better_auth/plugins/admin.rb +6 -1
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- data/lib/better_auth/plugins/captcha.rb +1 -1
- data/lib/better_auth/plugins/device_authorization.rb +34 -0
- data/lib/better_auth/plugins/dub.rb +8 -0
- data/lib/better_auth/plugins/generic_oauth.rb +34 -7
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/jwt.rb +10 -3
- data/lib/better_auth/plugins/mcp/schema.rb +13 -13
- data/lib/better_auth/plugins/mcp.rb +41 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +98 -21
- data/lib/better_auth/plugins/oidc_provider.rb +62 -3
- data/lib/better_auth/plugins/one_tap.rb +17 -5
- data/lib/better_auth/plugins/open_api.rb +42 -2
- data/lib/better_auth/plugins/organization.rb +122 -11
- data/lib/better_auth/plugins/phone_number.rb +1 -1
- data/lib/better_auth/plugins/two_factor.rb +21 -0
- data/lib/better_auth/rate_limiter.rb +7 -2
- data/lib/better_auth/routes/account.rb +4 -0
- data/lib/better_auth/routes/email_verification.rb +5 -1
- data/lib/better_auth/routes/password.rb +1 -0
- data/lib/better_auth/routes/social.rb +29 -1
- data/lib/better_auth/routes/user.rb +6 -2
- data/lib/better_auth/schema/sql.rb +104 -15
- data/lib/better_auth/schema.rb +35 -2
- data/lib/better_auth/session.rb +2 -1
- data/lib/better_auth/social_providers/base.rb +4 -9
- data/lib/better_auth/social_providers/facebook.rb +1 -1
- data/lib/better_auth/social_providers/github.rb +2 -0
- data/lib/better_auth/social_providers/line.rb +1 -1
- data/lib/better_auth/social_providers/paypal.rb +1 -1
- data/lib/better_auth/sql_migration.rb +566 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +3 -0
- metadata +10 -6
|
@@ -0,0 +1,97 @@
|
|
|
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, keyword_init: true) 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 check_secret(config, result)
|
|
35
|
+
secret = config.secret.to_s
|
|
36
|
+
if secret.empty?
|
|
37
|
+
result.errors << "secret is missing"
|
|
38
|
+
elsif secret == BetterAuth::Configuration::DEFAULT_SECRET
|
|
39
|
+
result.errors << "secret uses the default development value"
|
|
40
|
+
elsif secret.length < 32
|
|
41
|
+
result.errors << "secret should be at least 32 characters"
|
|
42
|
+
elsif entropy(secret) < 120
|
|
43
|
+
result.errors << "secret appears low-entropy; use a random production secret"
|
|
44
|
+
else
|
|
45
|
+
result.ok << "secret length and entropy look acceptable"
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def check_base_url(config, result)
|
|
50
|
+
base_url = config.base_url.to_s
|
|
51
|
+
if base_url.empty?
|
|
52
|
+
result.warnings << "base_url is not configured; set it explicitly in production"
|
|
53
|
+
elsif !base_url.start_with?("https://")
|
|
54
|
+
result.warnings << "base_url is not HTTPS"
|
|
55
|
+
else
|
|
56
|
+
result.ok << "base_url uses HTTPS"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def check_rate_limit(config, result)
|
|
61
|
+
rate_limit = config.rate_limit || {}
|
|
62
|
+
result.warnings << "rate_limit is disabled" unless rate_limit[:enabled]
|
|
63
|
+
if rate_limit[:storage].to_s == "memory"
|
|
64
|
+
result.warnings << "rate_limit uses memory storage; use database or secondary-storage for multi-process production deployments"
|
|
65
|
+
else
|
|
66
|
+
result.ok << "rate_limit storage is #{rate_limit[:storage]}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_database(config, result)
|
|
71
|
+
auth = BetterAuth.auth(config.to_h)
|
|
72
|
+
adapter = auth.context.adapter
|
|
73
|
+
unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
|
|
74
|
+
result.warnings << "database adapter does not expose SQL migration introspection; schema drift check skipped"
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
result.ok << "database adapter supports SQL migrations"
|
|
79
|
+
plan = BetterAuth::SQLMigration.plan(config, connection: adapter.connection, dialect: adapter.dialect)
|
|
80
|
+
if plan.empty?
|
|
81
|
+
result.ok << "database schema is up to date"
|
|
82
|
+
else
|
|
83
|
+
result.warnings << "database has pending Better Auth migrations"
|
|
84
|
+
plan.warnings.each { |warning| result.warnings << warning }
|
|
85
|
+
end
|
|
86
|
+
rescue BetterAuth::SQLMigration::UnsupportedAdapterError => error
|
|
87
|
+
result.warnings << error.message
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def entropy(value)
|
|
91
|
+
unique = value.chars.uniq.length
|
|
92
|
+
return 0 if unique.zero?
|
|
93
|
+
|
|
94
|
+
Math.log2(unique**value.length)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
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
|
|
|
@@ -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
|
|
@@ -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, keyword_init: true)
|
|
6
|
+
FieldChange = Struct.new(:logical_name, :table_name, :fields, :table, :order, keyword_init: true)
|
|
7
|
+
IndexChange = Struct.new(:table_name, :field_name, :name, :unique, :field, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
Plan = Struct.new(:to_create, :to_add, :to_index, :warnings, :dialect, :tables, keyword_init: true) 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
|
|
|
@@ -66,7 +66,11 @@ module BetterAuth
|
|
|
66
66
|
after: [
|
|
67
67
|
{
|
|
68
68
|
matcher: ->(ctx) { ctx.path == "/list-sessions" },
|
|
69
|
-
handler:
|
|
69
|
+
handler: lambda do |ctx|
|
|
70
|
+
next unless ctx.returned.is_a?(Array)
|
|
71
|
+
|
|
72
|
+
ctx.json(ctx.returned.reject { |session| session["impersonatedBy"] || session[:impersonatedBy] })
|
|
73
|
+
end
|
|
70
74
|
}
|
|
71
75
|
]
|
|
72
76
|
},
|
|
@@ -436,6 +440,7 @@ module BetterAuth
|
|
|
436
440
|
openapi: {
|
|
437
441
|
operationId: "stopImpersonating",
|
|
438
442
|
description: "Stop impersonating a user",
|
|
443
|
+
requestBody: OpenAPI.empty_request_body,
|
|
439
444
|
responses: {
|
|
440
445
|
"200" => OpenAPI.json_response("Impersonation stopped", OpenAPI.session_response_schema_pair)
|
|
441
446
|
}
|
|
@@ -47,6 +47,7 @@ module BetterAuth
|
|
|
47
47
|
openapi: {
|
|
48
48
|
operationId: "signInAnonymous",
|
|
49
49
|
description: "Sign in anonymously",
|
|
50
|
+
requestBody: OpenAPI.empty_request_body,
|
|
50
51
|
responses: {
|
|
51
52
|
"200" => OpenAPI.json_response(
|
|
52
53
|
"Anonymous session created",
|
|
@@ -96,6 +97,7 @@ module BetterAuth
|
|
|
96
97
|
openapi: {
|
|
97
98
|
operationId: "deleteAnonymousUser",
|
|
98
99
|
description: "Delete the current anonymous user",
|
|
100
|
+
requestBody: OpenAPI.empty_request_body,
|
|
99
101
|
responses: {
|
|
100
102
|
"200" => OpenAPI.json_response("Anonymous user deleted", OpenAPI.success_response_schema)
|
|
101
103
|
}
|
|
@@ -103,7 +103,7 @@ module BetterAuth
|
|
|
103
103
|
else
|
|
104
104
|
URI.encode_www_form(verifier[:payload])
|
|
105
105
|
end
|
|
106
|
-
response =
|
|
106
|
+
response = HTTPClient.request(uri, request)
|
|
107
107
|
raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess)
|
|
108
108
|
|
|
109
109
|
JSON.parse(response.body.to_s)
|
|
@@ -54,6 +54,14 @@ module BetterAuth
|
|
|
54
54
|
openapi: {
|
|
55
55
|
operationId: "requestDeviceCode",
|
|
56
56
|
description: "Request a device and user code",
|
|
57
|
+
requestBody: OpenAPI.json_request_body(
|
|
58
|
+
OpenAPI.object_schema(
|
|
59
|
+
{
|
|
60
|
+
client_id: {type: "string", description: "OAuth client ID"},
|
|
61
|
+
scope: {type: "string", description: "Requested scopes"}
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
),
|
|
57
65
|
responses: {
|
|
58
66
|
"200" => OpenAPI.json_response("Success", device_code_response_schema)
|
|
59
67
|
}
|
|
@@ -106,6 +114,16 @@ module BetterAuth
|
|
|
106
114
|
openapi: {
|
|
107
115
|
operationId: "exchangeDeviceToken",
|
|
108
116
|
description: "Exchange device code for access token",
|
|
117
|
+
requestBody: OpenAPI.json_request_body(
|
|
118
|
+
OpenAPI.object_schema(
|
|
119
|
+
{
|
|
120
|
+
grant_type: {type: "string", enum: [OAuthProtocol::DEVICE_CODE_GRANT]},
|
|
121
|
+
device_code: {type: "string"},
|
|
122
|
+
client_id: {type: "string"}
|
|
123
|
+
},
|
|
124
|
+
required: ["grant_type", "device_code"]
|
|
125
|
+
)
|
|
126
|
+
),
|
|
109
127
|
responses: {
|
|
110
128
|
"200" => OpenAPI.json_response("Success", device_token_response_schema)
|
|
111
129
|
}
|
|
@@ -197,6 +215,14 @@ module BetterAuth
|
|
|
197
215
|
openapi: {
|
|
198
216
|
operationId: "approveDevice",
|
|
199
217
|
description: "Approve a device authorization request",
|
|
218
|
+
requestBody: OpenAPI.json_request_body(
|
|
219
|
+
OpenAPI.object_schema(
|
|
220
|
+
{
|
|
221
|
+
user_code: {type: "string", description: "User code shown on the device"},
|
|
222
|
+
userCode: {type: "string", description: "User code shown on the device"}
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
),
|
|
200
226
|
responses: {
|
|
201
227
|
"200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema)
|
|
202
228
|
}
|
|
@@ -218,6 +244,14 @@ module BetterAuth
|
|
|
218
244
|
openapi: {
|
|
219
245
|
operationId: "denyDevice",
|
|
220
246
|
description: "Deny a device authorization request",
|
|
247
|
+
requestBody: OpenAPI.json_request_body(
|
|
248
|
+
OpenAPI.object_schema(
|
|
249
|
+
{
|
|
250
|
+
user_code: {type: "string", description: "User code shown on the device"},
|
|
251
|
+
userCode: {type: "string", description: "User code shown on the device"}
|
|
252
|
+
}
|
|
253
|
+
)
|
|
254
|
+
),
|
|
221
255
|
responses: {
|
|
222
256
|
"200" => OpenAPI.json_response("Success", OpenAPI.success_response_schema)
|
|
223
257
|
}
|
|
@@ -38,6 +38,14 @@ module BetterAuth
|
|
|
38
38
|
openapi: {
|
|
39
39
|
operationId: "dubLink",
|
|
40
40
|
description: "Link a Dub OAuth account",
|
|
41
|
+
requestBody: OpenAPI.json_request_body(
|
|
42
|
+
OpenAPI.object_schema(
|
|
43
|
+
{
|
|
44
|
+
callbackURL: {type: "string", description: "The URL to redirect to after linking"},
|
|
45
|
+
callback_url: {type: "string", description: "The URL to redirect to after linking"}
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
),
|
|
41
49
|
responses: {
|
|
42
50
|
"200" => OpenAPI.json_response(
|
|
43
51
|
"Authorization URL generated successfully for linking a Dub account",
|
|
@@ -220,6 +220,7 @@ module BetterAuth
|
|
|
220
220
|
openapi: {
|
|
221
221
|
operationId: "signInOAuth2",
|
|
222
222
|
description: "Sign in with OAuth2",
|
|
223
|
+
requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
|
|
223
224
|
responses: {
|
|
224
225
|
"200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
|
|
225
226
|
}
|
|
@@ -242,6 +243,7 @@ module BetterAuth
|
|
|
242
243
|
openapi: {
|
|
243
244
|
operationId: "linkOAuth2",
|
|
244
245
|
description: "Link an OAuth2 account to the current user session",
|
|
246
|
+
requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
|
|
245
247
|
responses: {
|
|
246
248
|
"200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
|
|
247
249
|
}
|
|
@@ -352,6 +354,28 @@ module BetterAuth
|
|
|
352
354
|
)
|
|
353
355
|
end
|
|
354
356
|
|
|
357
|
+
def generic_oauth_start_body_schema
|
|
358
|
+
OpenAPI.object_schema(
|
|
359
|
+
{
|
|
360
|
+
providerId: {type: "string", description: "OAuth provider ID"},
|
|
361
|
+
provider_id: {type: "string", description: "OAuth provider ID"},
|
|
362
|
+
callbackURL: {type: "string", description: "URL to redirect to after success"},
|
|
363
|
+
callback_url: {type: "string", description: "URL to redirect to after success"},
|
|
364
|
+
errorCallbackURL: {type: "string", description: "URL to redirect to after error"},
|
|
365
|
+
error_callback_url: {type: "string", description: "URL to redirect to after error"},
|
|
366
|
+
newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"},
|
|
367
|
+
new_user_callback_url: {type: "string", description: "URL to redirect to for new users"},
|
|
368
|
+
requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"},
|
|
369
|
+
request_sign_up: {type: "boolean", description: "Whether this request is a sign-up flow"},
|
|
370
|
+
scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"},
|
|
371
|
+
disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"},
|
|
372
|
+
disable_redirect: {type: "boolean", description: "Return the URL instead of redirecting"},
|
|
373
|
+
additionalData: {type: "object", additionalProperties: true},
|
|
374
|
+
additional_data: {type: "object", additionalProperties: true}
|
|
375
|
+
}
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
|
|
355
379
|
def generic_oauth_authorization_url(ctx, provider, body, link:)
|
|
356
380
|
authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
|
|
357
381
|
token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
|
|
@@ -487,14 +511,17 @@ module BetterAuth
|
|
|
487
511
|
if verification
|
|
488
512
|
cookie = ctx.context.create_auth_cookie("state")
|
|
489
513
|
cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
|
|
490
|
-
if
|
|
514
|
+
if ctx.request && cookie_state != state
|
|
515
|
+
Cookies.expire_cookie(ctx, cookie)
|
|
516
|
+
raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
|
|
517
|
+
elsif !ctx.request && cookie_state && cookie_state != state
|
|
491
518
|
Cookies.expire_cookie(ctx, cookie)
|
|
492
519
|
raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
|
|
493
520
|
end
|
|
494
521
|
|
|
495
522
|
parsed = JSON.parse(verification.fetch("value"))
|
|
496
523
|
ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
|
|
497
|
-
Cookies.expire_cookie(ctx, cookie) if cookie_state
|
|
524
|
+
Cookies.expire_cookie(ctx, cookie) if ctx.request || cookie_state
|
|
498
525
|
return parsed
|
|
499
526
|
end
|
|
500
527
|
end
|
|
@@ -521,7 +548,7 @@ module BetterAuth
|
|
|
521
548
|
uri = URI(user_info_url)
|
|
522
549
|
request = Net::HTTP::Get.new(uri)
|
|
523
550
|
request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}"
|
|
524
|
-
response =
|
|
551
|
+
response = HTTPClient.request(uri, request)
|
|
525
552
|
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
526
553
|
|
|
527
554
|
generic_oauth_normalize_user_info(JSON.parse(response.body))
|
|
@@ -615,7 +642,7 @@ module BetterAuth
|
|
|
615
642
|
normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value|
|
|
616
643
|
request[key.to_s.tr("_", "-")] = value.to_s
|
|
617
644
|
end
|
|
618
|
-
response =
|
|
645
|
+
response = HTTPClient.request(uri, request)
|
|
619
646
|
provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
|
|
620
647
|
rescue
|
|
621
648
|
{}
|
|
@@ -649,7 +676,7 @@ module BetterAuth
|
|
|
649
676
|
form_data[key] = value unless form_data.key?(key)
|
|
650
677
|
end
|
|
651
678
|
request.set_form_data(form_data)
|
|
652
|
-
response =
|
|
679
|
+
response = HTTPClient.request(uri, request)
|
|
653
680
|
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
654
681
|
|
|
655
682
|
generic_oauth_normalize_tokens(JSON.parse(response.body))
|
|
@@ -714,7 +741,7 @@ module BetterAuth
|
|
|
714
741
|
uri = URI(url)
|
|
715
742
|
request = Net::HTTP::Get.new(uri)
|
|
716
743
|
normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s }
|
|
717
|
-
response =
|
|
744
|
+
response = HTTPClient.request(uri, request)
|
|
718
745
|
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
719
746
|
|
|
720
747
|
JSON.parse(response.body)
|
|
@@ -790,7 +817,7 @@ module BetterAuth
|
|
|
790
817
|
token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call)
|
|
791
818
|
normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value }
|
|
792
819
|
request.set_form_data(form_data.compact)
|
|
793
|
-
response =
|
|
820
|
+
response = HTTPClient.request(uri, request)
|
|
794
821
|
raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)
|
|
795
822
|
|
|
796
823
|
generic_oauth_normalize_tokens(JSON.parse(response.body))
|
|
@@ -83,7 +83,7 @@ module BetterAuth
|
|
|
83
83
|
request = Net::HTTP::Get.new(uri)
|
|
84
84
|
request["Add-Padding"] = "true"
|
|
85
85
|
request["User-Agent"] = "BetterAuth Password Checker"
|
|
86
|
-
response =
|
|
86
|
+
response = HTTPClient.request(uri, request)
|
|
87
87
|
unless response.is_a?(Net::HTTPSuccess)
|
|
88
88
|
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
|
|
89
89
|
end
|
|
@@ -63,6 +63,7 @@ module BetterAuth
|
|
|
63
63
|
schema: {
|
|
64
64
|
jwks: {
|
|
65
65
|
fields: {
|
|
66
|
+
id: {type: "string", required: true},
|
|
66
67
|
publicKey: {type: "string", required: true},
|
|
67
68
|
privateKey: {type: "string", required: true},
|
|
68
69
|
createdAt: {type: "date", required: true},
|
|
@@ -279,9 +280,15 @@ module BetterAuth
|
|
|
279
280
|
payload = if fetcher.respond_to?(:call)
|
|
280
281
|
fetcher.call(url)
|
|
281
282
|
else
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
283
|
+
cached = @jwt_remote_jwks_cache ||= {}
|
|
284
|
+
entry = cached[url.to_s]
|
|
285
|
+
if entry && entry[:expires_at] > Time.now
|
|
286
|
+
entry[:payload]
|
|
287
|
+
else
|
|
288
|
+
fetched = HTTPClient.get_json(url)
|
|
289
|
+
cached[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
|
|
290
|
+
fetched
|
|
291
|
+
end
|
|
285
292
|
end
|
|
286
293
|
keys = fetch_value(payload, "keys")
|
|
287
294
|
Array(keys).map { |entry| normalize_remote_jwk(entry) }
|
|
@@ -8,7 +8,7 @@ module BetterAuth
|
|
|
8
8
|
def schema
|
|
9
9
|
{
|
|
10
10
|
oauthClient: {
|
|
11
|
-
|
|
11
|
+
model_name: "oauth_clients",
|
|
12
12
|
fields: {
|
|
13
13
|
clientId: {type: "string", unique: true, required: true},
|
|
14
14
|
clientSecret: {type: "string", required: false},
|
|
@@ -17,7 +17,7 @@ module BetterAuth
|
|
|
17
17
|
enableEndSession: {type: "boolean", required: false},
|
|
18
18
|
clientSecretExpiresAt: {type: "number", required: false},
|
|
19
19
|
scopes: {type: "string[]", required: false},
|
|
20
|
-
userId: {type: "string", required: false},
|
|
20
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
21
21
|
createdAt: {type: "date", required: true, default_value: -> { Time.now }},
|
|
22
22
|
updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }},
|
|
23
23
|
name: {type: "string", required: false},
|
|
@@ -45,9 +45,9 @@ module BetterAuth
|
|
|
45
45
|
oauthRefreshToken: {
|
|
46
46
|
fields: {
|
|
47
47
|
token: {type: "string", required: true},
|
|
48
|
-
clientId: {type: "string", required: true},
|
|
49
|
-
sessionId: {type: "string", required: false},
|
|
50
|
-
userId: {type: "string", required: false},
|
|
48
|
+
clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
|
|
49
|
+
sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
|
|
50
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
51
51
|
referenceId: {type: "string", required: false},
|
|
52
52
|
authTime: {type: "date", required: false},
|
|
53
53
|
expiresAt: {type: "date", required: false},
|
|
@@ -57,27 +57,27 @@ module BetterAuth
|
|
|
57
57
|
}
|
|
58
58
|
},
|
|
59
59
|
oauthAccessToken: {
|
|
60
|
-
|
|
60
|
+
model_name: "oauth_access_tokens",
|
|
61
61
|
fields: {
|
|
62
62
|
token: {type: "string", unique: true, required: true},
|
|
63
63
|
expiresAt: {type: "date", required: true},
|
|
64
|
-
clientId: {type: "string", required: true},
|
|
65
|
-
userId: {type: "string", required: false},
|
|
66
|
-
sessionId: {type: "string", required: false},
|
|
64
|
+
clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
|
|
65
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
66
|
+
sessionId: {type: "string", required: false, references: {model: "session", field: "id", on_delete: "set null"}},
|
|
67
67
|
scopes: {type: "string[]", required: true},
|
|
68
68
|
revoked: {type: "date", required: false},
|
|
69
69
|
referenceId: {type: "string", required: false},
|
|
70
70
|
authTime: {type: "date", required: false},
|
|
71
|
-
refreshId: {type: "string", required: false},
|
|
71
|
+
refreshId: {type: "string", required: false, references: {model: "oauthRefreshToken", field: "id"}},
|
|
72
72
|
createdAt: {type: "date", required: true, default_value: -> { Time.now }},
|
|
73
73
|
updatedAt: {type: "date", required: true, default_value: -> { Time.now }, on_update: -> { Time.now }}
|
|
74
74
|
}
|
|
75
75
|
},
|
|
76
76
|
oauthConsent: {
|
|
77
|
-
|
|
77
|
+
model_name: "oauth_consents",
|
|
78
78
|
fields: {
|
|
79
|
-
clientId: {type: "string", required: true},
|
|
80
|
-
userId: {type: "string", required: false},
|
|
79
|
+
clientId: {type: "string", required: true, references: {model: "oauthClient", field: "clientId"}},
|
|
80
|
+
userId: {type: "string", required: false, references: {model: "user", field: "id"}},
|
|
81
81
|
referenceId: {type: "string", required: false},
|
|
82
82
|
scopes: {type: "string[]", required: true},
|
|
83
83
|
createdAt: {type: "date", required: true, default_value: -> { Time.now }},
|