better_auth 0.1.1 → 0.2.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 +6 -0
- data/README.md +106 -16
- data/lib/better_auth/adapters/base.rb +49 -0
- data/lib/better_auth/adapters/internal_adapter.rb +439 -0
- data/lib/better_auth/adapters/memory.rb +232 -0
- data/lib/better_auth/adapters/mongodb.rb +369 -0
- data/lib/better_auth/adapters/mssql.rb +42 -0
- data/lib/better_auth/adapters/mysql.rb +33 -0
- data/lib/better_auth/adapters/postgres.rb +17 -0
- data/lib/better_auth/adapters/sql.rb +425 -0
- data/lib/better_auth/adapters/sqlite.rb +20 -0
- data/lib/better_auth/api.rb +226 -0
- data/lib/better_auth/api_error.rb +53 -0
- data/lib/better_auth/auth.rb +42 -0
- data/lib/better_auth/configuration.rb +399 -0
- data/lib/better_auth/context.rb +210 -0
- data/lib/better_auth/cookies.rb +278 -0
- data/lib/better_auth/core.rb +37 -1
- data/lib/better_auth/crypto/jwe.rb +76 -0
- data/lib/better_auth/crypto.rb +191 -0
- data/lib/better_auth/database_hooks.rb +114 -0
- data/lib/better_auth/endpoint.rb +326 -0
- data/lib/better_auth/error.rb +52 -0
- data/lib/better_auth/middleware/origin_check.rb +128 -0
- data/lib/better_auth/password.rb +120 -0
- data/lib/better_auth/plugin.rb +129 -0
- data/lib/better_auth/plugin_context.rb +16 -0
- data/lib/better_auth/plugin_registry.rb +67 -0
- data/lib/better_auth/plugins/access.rb +87 -0
- data/lib/better_auth/plugins/additional_fields.rb +29 -0
- data/lib/better_auth/plugins/admin/schema.rb +28 -0
- data/lib/better_auth/plugins/admin.rb +518 -0
- data/lib/better_auth/plugins/anonymous.rb +198 -0
- data/lib/better_auth/plugins/api_key.rb +16 -0
- data/lib/better_auth/plugins/bearer.rb +128 -0
- data/lib/better_auth/plugins/captcha.rb +159 -0
- data/lib/better_auth/plugins/custom_session.rb +84 -0
- data/lib/better_auth/plugins/device_authorization.rb +302 -0
- data/lib/better_auth/plugins/email_otp.rb +536 -0
- data/lib/better_auth/plugins/expo.rb +88 -0
- data/lib/better_auth/plugins/generic_oauth.rb +780 -0
- data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
- data/lib/better_auth/plugins/jwt.rb +482 -0
- data/lib/better_auth/plugins/last_login_method.rb +92 -0
- data/lib/better_auth/plugins/magic_link.rb +181 -0
- data/lib/better_auth/plugins/mcp.rb +342 -0
- data/lib/better_auth/plugins/multi_session.rb +173 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +348 -0
- data/lib/better_auth/plugins/oauth_provider.rb +16 -0
- data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
- data/lib/better_auth/plugins/oidc_provider.rb +597 -0
- data/lib/better_auth/plugins/one_tap.rb +154 -0
- data/lib/better_auth/plugins/one_time_token.rb +106 -0
- data/lib/better_auth/plugins/open_api.rb +489 -0
- data/lib/better_auth/plugins/organization/schema.rb +106 -0
- data/lib/better_auth/plugins/organization.rb +990 -0
- data/lib/better_auth/plugins/passkey.rb +16 -0
- data/lib/better_auth/plugins/phone_number.rb +321 -0
- data/lib/better_auth/plugins/scim.rb +16 -0
- data/lib/better_auth/plugins/siwe.rb +242 -0
- data/lib/better_auth/plugins/sso.rb +16 -0
- data/lib/better_auth/plugins/stripe.rb +16 -0
- data/lib/better_auth/plugins/two_factor.rb +514 -0
- data/lib/better_auth/plugins/username.rb +278 -0
- data/lib/better_auth/plugins.rb +46 -0
- data/lib/better_auth/rate_limiter.rb +215 -0
- data/lib/better_auth/request_ip.rb +70 -0
- data/lib/better_auth/router.rb +365 -0
- data/lib/better_auth/routes/account.rb +211 -0
- data/lib/better_auth/routes/email_verification.rb +108 -0
- data/lib/better_auth/routes/error.rb +102 -0
- data/lib/better_auth/routes/ok.rb +15 -0
- data/lib/better_auth/routes/password.rb +164 -0
- data/lib/better_auth/routes/session.rb +137 -0
- data/lib/better_auth/routes/sign_in.rb +90 -0
- data/lib/better_auth/routes/sign_out.rb +15 -0
- data/lib/better_auth/routes/sign_up.rb +145 -0
- data/lib/better_auth/routes/social.rb +188 -0
- data/lib/better_auth/routes/user.rb +193 -0
- data/lib/better_auth/schema/sql.rb +191 -0
- data/lib/better_auth/schema.rb +275 -0
- data/lib/better_auth/session.rb +122 -0
- data/lib/better_auth/session_store.rb +91 -0
- data/lib/better_auth/social_providers/apple.rb +55 -0
- data/lib/better_auth/social_providers/base.rb +67 -0
- data/lib/better_auth/social_providers/discord.rb +59 -0
- data/lib/better_auth/social_providers/github.rb +59 -0
- data/lib/better_auth/social_providers/gitlab.rb +54 -0
- data/lib/better_auth/social_providers/google.rb +65 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
- data/lib/better_auth/social_providers.rb +9 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +87 -2
- metadata +218 -21
- data/.ruby-version +0 -1
- data/.standard.yml +0 -12
- data/.vscode/settings.json +0 -22
- data/AGENTS.md +0 -50
- data/CLAUDE.md +0 -1
- data/CODE_OF_CONDUCT.md +0 -173
- data/CONTRIBUTING.md +0 -187
- data/Gemfile +0 -12
- data/Makefile +0 -207
- data/Rakefile +0 -25
- data/SECURITY.md +0 -28
- data/docker-compose.yml +0 -63
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
class DatabaseHooks
|
|
5
|
+
attr_reader :adapter, :options
|
|
6
|
+
|
|
7
|
+
def initialize(adapter, options)
|
|
8
|
+
@adapter = adapter
|
|
9
|
+
@options = options
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def create(data, model, custom: nil, context: nil)
|
|
13
|
+
run_before(model, :create, data, context) do |actual_data|
|
|
14
|
+
created = custom ? custom.call(actual_data) : adapter.create(model: model, data: actual_data, force_allow_id: true)
|
|
15
|
+
run_after(model, :create, created)
|
|
16
|
+
created
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update(data, where, model, custom: nil, context: nil)
|
|
21
|
+
run_before(model, :update, data, context) do |actual_data|
|
|
22
|
+
updated = custom ? custom.call(actual_data) : adapter.update(model: model, where: where, update: actual_data)
|
|
23
|
+
run_after(model, :update, updated) if updated
|
|
24
|
+
updated
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update_many(data, where, model, custom: nil, context: nil)
|
|
29
|
+
run_before(model, :update, data, context) do |actual_data|
|
|
30
|
+
updated = custom ? custom.call(actual_data) : adapter.update_many(model: model, where: where, update: actual_data)
|
|
31
|
+
run_after(model, :update, updated) if updated
|
|
32
|
+
updated
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(where, model, custom: nil, context: nil)
|
|
37
|
+
entity = adapter.find_one(model: model, where: where)
|
|
38
|
+
return custom ? custom.call(where) : adapter.delete(model: model, where: where) unless entity
|
|
39
|
+
|
|
40
|
+
return nil if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
|
|
41
|
+
|
|
42
|
+
deleted = custom ? custom.call(where) : adapter.delete(model: model, where: where)
|
|
43
|
+
after_hooks(model, :delete).each { |hook| hook.call(entity, context) }
|
|
44
|
+
deleted
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete_many(where, model, custom: nil, context: nil)
|
|
48
|
+
entities = adapter.find_many(model: model, where: where)
|
|
49
|
+
entities.each do |entity|
|
|
50
|
+
return nil if before_hooks(model, :delete).any? { |hook| hook.call(entity, context) == false }
|
|
51
|
+
end
|
|
52
|
+
deleted = custom ? custom.call(where) : adapter.delete_many(model: model, where: where)
|
|
53
|
+
entities.each { |entity| after_hooks(model, :delete).each { |hook| hook.call(entity, context) } }
|
|
54
|
+
deleted
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def run_before(model, action, data, context)
|
|
60
|
+
actual_data = stringify_keys(data)
|
|
61
|
+
before_hooks(model, action).each do |hook|
|
|
62
|
+
result = hook.call(actual_data, context)
|
|
63
|
+
return nil if result == false
|
|
64
|
+
|
|
65
|
+
hook_data = result.is_a?(Hash) ? (result[:data] || result["data"]) : nil
|
|
66
|
+
actual_data = actual_data.merge(stringify_keys(hook_data)) if hook_data
|
|
67
|
+
end
|
|
68
|
+
yield actual_data
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run_after(model, action, data)
|
|
72
|
+
after_hooks(model, action).each { |hook| hook.call(data, nil) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def before_hooks(model, action)
|
|
76
|
+
hooks_for(model, action, :before)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def after_hooks(model, action)
|
|
80
|
+
hooks_for(model, action, :after)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def hooks_for(model, action, phase)
|
|
84
|
+
all_hooks.filter_map do |hooks|
|
|
85
|
+
model_hooks = hooks[model.to_sym] || hooks[model.to_s]
|
|
86
|
+
action_hooks = model_hooks&.fetch(action, nil) || model_hooks&.fetch(action.to_s, nil)
|
|
87
|
+
action_hooks&.fetch(phase, nil) || action_hooks&.fetch(phase.to_s, nil)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def all_hooks
|
|
92
|
+
direct = if options.database_hooks.nil?
|
|
93
|
+
[]
|
|
94
|
+
elsif options.database_hooks.is_a?(Array)
|
|
95
|
+
options.database_hooks
|
|
96
|
+
else
|
|
97
|
+
[options.database_hooks]
|
|
98
|
+
end
|
|
99
|
+
plugin_hooks = options.plugins.filter_map do |plugin|
|
|
100
|
+
init_options = plugin.dig(:options, :database_hooks) || plugin.dig("options", "databaseHooks")
|
|
101
|
+
plugin[:database_hooks] || plugin["databaseHooks"] || init_options
|
|
102
|
+
end
|
|
103
|
+
direct + plugin_hooks
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def stringify_keys(data)
|
|
107
|
+
return {} unless data
|
|
108
|
+
|
|
109
|
+
data.each_with_object({}) do |(key, value), result|
|
|
110
|
+
result[Schema.storage_key(key)] = value
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module BetterAuth
|
|
6
|
+
class Endpoint
|
|
7
|
+
attr_reader :path,
|
|
8
|
+
:body_schema,
|
|
9
|
+
:query_schema,
|
|
10
|
+
:headers_schema,
|
|
11
|
+
:metadata,
|
|
12
|
+
:use,
|
|
13
|
+
:handler
|
|
14
|
+
|
|
15
|
+
def initialize(path: nil, method: nil, body_schema: nil, query_schema: nil, headers_schema: nil, metadata: {}, use: [], &handler)
|
|
16
|
+
@path = path
|
|
17
|
+
@methods = Array(method || "*").map { |value| value.to_s.upcase }
|
|
18
|
+
@body_schema = body_schema
|
|
19
|
+
@query_schema = query_schema
|
|
20
|
+
@headers_schema = headers_schema
|
|
21
|
+
@metadata = metadata || {}
|
|
22
|
+
@use = Array(use)
|
|
23
|
+
@handler = handler || ->(_ctx) {}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def methods
|
|
27
|
+
@methods.empty? ? ["*"] : @methods
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def matches_method?(method)
|
|
31
|
+
methods.include?("*") || methods.include?(method.to_s.upcase)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(context)
|
|
35
|
+
apply_schemas!(context)
|
|
36
|
+
|
|
37
|
+
use.each do |middleware|
|
|
38
|
+
middleware_result = middleware.call(context)
|
|
39
|
+
return Result.from_value(middleware_result, context) if middleware_result
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
Result.from_value(handler.call(context), context)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def apply_schemas!(context)
|
|
48
|
+
context.body = validate_schema(:body, body_schema, context.body)
|
|
49
|
+
context.query = validate_schema(:query, query_schema, context.query)
|
|
50
|
+
context.headers = context.send(:normalize_headers, validate_schema(:headers, headers_schema, context.headers))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def validate_schema(_label, schema, value)
|
|
54
|
+
return value unless schema
|
|
55
|
+
|
|
56
|
+
parsed = parse_schema(schema, value)
|
|
57
|
+
return value if parsed.nil?
|
|
58
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if parsed == false
|
|
59
|
+
|
|
60
|
+
parsed
|
|
61
|
+
rescue APIError
|
|
62
|
+
raise
|
|
63
|
+
rescue => error
|
|
64
|
+
raise APIError.new("BAD_REQUEST", message: error.message.empty? ? BASE_ERROR_CODES["VALIDATION_ERROR"] : error.message)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_schema(schema, value)
|
|
68
|
+
if schema.respond_to?(:parse)
|
|
69
|
+
schema.parse(value)
|
|
70
|
+
elsif schema.respond_to?(:call)
|
|
71
|
+
normalize_schema_result(schema.call(value))
|
|
72
|
+
else
|
|
73
|
+
value
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def normalize_schema_result(result)
|
|
78
|
+
if result.respond_to?(:success?)
|
|
79
|
+
return result.to_h if result.success? && result.respond_to?(:to_h)
|
|
80
|
+
return false unless result.success?
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
result
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
class Result
|
|
87
|
+
attr_accessor :response, :status, :headers
|
|
88
|
+
|
|
89
|
+
def initialize(response:, status: 200, headers: {}, raw_response: nil)
|
|
90
|
+
@response = response
|
|
91
|
+
@status = status
|
|
92
|
+
@headers = normalize_headers(headers)
|
|
93
|
+
@raw_response = raw_response
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.from_value(value, context)
|
|
97
|
+
return value if value.is_a?(self)
|
|
98
|
+
|
|
99
|
+
if value.is_a?(APIError)
|
|
100
|
+
return new(response: value, status: value.status_code, headers: value.headers)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if rack_response?(value)
|
|
104
|
+
return new(response: nil, status: value[0], headers: value[1], raw_response: value)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
headers = context.response_headers.dup
|
|
108
|
+
if value.is_a?(self)
|
|
109
|
+
headers = merge_headers(headers, value.headers)
|
|
110
|
+
return new(response: value.response, status: value.status, headers: headers)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
new(response: value, status: context.status, headers: headers)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def self.rack_response?(value)
|
|
117
|
+
value.is_a?(Array) && value.length == 3 && value[0].is_a?(Integer) && value[1].is_a?(Hash)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def self.merge_headers(base, extra)
|
|
121
|
+
extra.each_with_object(base.dup) do |(key, value), result|
|
|
122
|
+
normalized = key.to_s.downcase
|
|
123
|
+
result[normalized] = if normalized == "set-cookie" && result[normalized]
|
|
124
|
+
[result[normalized], value].join("\n")
|
|
125
|
+
else
|
|
126
|
+
value
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def raw_response?
|
|
132
|
+
!@raw_response.nil?
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def to_rack_response
|
|
136
|
+
return @raw_response if raw_response?
|
|
137
|
+
|
|
138
|
+
body = if response.nil?
|
|
139
|
+
[""]
|
|
140
|
+
elsif response.is_a?(String)
|
|
141
|
+
[response]
|
|
142
|
+
else
|
|
143
|
+
[JSON.generate(response)]
|
|
144
|
+
end
|
|
145
|
+
response_headers = {"content-type" => "application/json"}.merge(headers)
|
|
146
|
+
[status, response_headers, body]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def normalize_headers(headers)
|
|
152
|
+
headers.each_with_object({}) do |(key, value), result|
|
|
153
|
+
result[key.to_s.downcase] = value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
class Context
|
|
159
|
+
attr_accessor :path,
|
|
160
|
+
:method,
|
|
161
|
+
:query,
|
|
162
|
+
:body,
|
|
163
|
+
:params,
|
|
164
|
+
:headers,
|
|
165
|
+
:context,
|
|
166
|
+
:request,
|
|
167
|
+
:status,
|
|
168
|
+
:returned,
|
|
169
|
+
:response_headers
|
|
170
|
+
|
|
171
|
+
def initialize(path:, method:, query:, body:, params:, headers:, context:, request: nil)
|
|
172
|
+
@path = path
|
|
173
|
+
@method = method.to_s.upcase
|
|
174
|
+
@query = query || {}
|
|
175
|
+
@body = body || {}
|
|
176
|
+
@params = params || {}
|
|
177
|
+
@headers = normalize_headers(headers || {})
|
|
178
|
+
@context = context
|
|
179
|
+
@request = request
|
|
180
|
+
@status = 200
|
|
181
|
+
@response_headers = {}
|
|
182
|
+
@returned = nil
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def set_status(value)
|
|
186
|
+
@status = value
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def set_header(key, value)
|
|
190
|
+
normalized = safe_header_name(key)
|
|
191
|
+
safe_value = safe_header_value(value)
|
|
192
|
+
response_headers[normalized] = if normalized == "set-cookie" && response_headers[normalized]
|
|
193
|
+
[response_headers[normalized], safe_value].join("\n")
|
|
194
|
+
else
|
|
195
|
+
safe_value
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def set_cookie(name, value, options = {})
|
|
200
|
+
attributes = cookie_attributes(options)
|
|
201
|
+
cookie = (["#{name}=#{value}"] + attributes).join("; ")
|
|
202
|
+
set_header("set-cookie", cookie)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def get_cookie(name)
|
|
206
|
+
cookies[name.to_s]
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def cookies
|
|
210
|
+
BetterAuth::Cookies.parse_cookies(headers["cookie"])
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def set_signed_cookie(name, value, secret, options = {})
|
|
214
|
+
signature = BetterAuth::Crypto.hmac_signature(value, secret, encoding: :base64url)
|
|
215
|
+
set_cookie(name, "#{value}.#{signature}", options)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def get_signed_cookie(name, secret)
|
|
219
|
+
value = get_cookie(name)
|
|
220
|
+
return nil unless value
|
|
221
|
+
|
|
222
|
+
payload, signature = value.rpartition(".").values_at(0, 2)
|
|
223
|
+
return nil if payload.empty? || signature.empty?
|
|
224
|
+
|
|
225
|
+
BetterAuth::Crypto.verify_hmac_signature(payload, signature, secret, encoding: :base64url) ? payload : nil
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def json(value, status: nil, headers: {})
|
|
229
|
+
set_status(status) if status
|
|
230
|
+
headers.each { |key, header_value| set_header(key, header_value) }
|
|
231
|
+
Result.new(response: value, status: self.status, headers: response_headers)
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def error(status, message: nil, headers: {})
|
|
235
|
+
APIError.new(status, message: message, headers: headers)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def redirect(location, status: 302)
|
|
239
|
+
code = (status == 302) ? "FOUND" : status
|
|
240
|
+
APIError.new(code, message: "Redirect", headers: {"location" => location})
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def merge_context!(data)
|
|
244
|
+
data.each do |key, value|
|
|
245
|
+
case key.to_sym
|
|
246
|
+
when :query
|
|
247
|
+
@query = deep_merge(query, value)
|
|
248
|
+
when :body
|
|
249
|
+
@body = deep_merge(body, value)
|
|
250
|
+
when :params
|
|
251
|
+
@params = deep_merge(params, value)
|
|
252
|
+
when :headers
|
|
253
|
+
@headers = normalize_headers(deep_merge(headers, value))
|
|
254
|
+
else
|
|
255
|
+
public_send("#{key}=", value) if respond_to?("#{key}=")
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
private
|
|
261
|
+
|
|
262
|
+
def normalize_headers(value)
|
|
263
|
+
value.each_with_object({}) do |(key, header_value), result|
|
|
264
|
+
result[key.to_s.downcase.tr("_", "-")] = header_value
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def deep_merge(base, override)
|
|
269
|
+
return override unless base.is_a?(Hash) && override.is_a?(Hash)
|
|
270
|
+
|
|
271
|
+
base.merge(override) do |_key, old_value, new_value|
|
|
272
|
+
if old_value.is_a?(Hash) && new_value.is_a?(Hash)
|
|
273
|
+
deep_merge(old_value, new_value)
|
|
274
|
+
else
|
|
275
|
+
new_value
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def safe_header_name(value)
|
|
281
|
+
name = value.to_s.downcase
|
|
282
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Invalid header name") if name.match?(/[\r\n]/)
|
|
283
|
+
|
|
284
|
+
name
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def safe_header_value(value)
|
|
288
|
+
header_value = value.to_s
|
|
289
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Invalid header value") if header_value.match?(/[\r\n]/)
|
|
290
|
+
|
|
291
|
+
header_value
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def cookie_attributes(options)
|
|
295
|
+
options.compact.filter_map do |key, option_value|
|
|
296
|
+
next if option_value == false
|
|
297
|
+
|
|
298
|
+
name = cookie_attribute_name(key)
|
|
299
|
+
if option_value == true
|
|
300
|
+
name
|
|
301
|
+
else
|
|
302
|
+
"#{name}=#{cookie_attribute_value(key, option_value)}"
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def cookie_attribute_name(key)
|
|
308
|
+
case key.to_sym
|
|
309
|
+
when :max_age then "Max-Age"
|
|
310
|
+
when :http_only, :httponly then "HttpOnly"
|
|
311
|
+
when :same_site, :samesite then "SameSite"
|
|
312
|
+
else
|
|
313
|
+
key.to_s.split("_").map(&:capitalize).join("-")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def cookie_attribute_value(key, value)
|
|
318
|
+
if [:same_site, :samesite].include?(key.to_sym)
|
|
319
|
+
return value.to_s.capitalize
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
value
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
BASE_ERROR_CODES = {
|
|
8
|
+
"USER_NOT_FOUND" => "User not found",
|
|
9
|
+
"FAILED_TO_CREATE_USER" => "Failed to create user",
|
|
10
|
+
"FAILED_TO_CREATE_SESSION" => "Failed to create session",
|
|
11
|
+
"FAILED_TO_UPDATE_USER" => "Failed to update user",
|
|
12
|
+
"FAILED_TO_GET_SESSION" => "Failed to get session",
|
|
13
|
+
"INVALID_PASSWORD" => "Invalid password",
|
|
14
|
+
"INVALID_EMAIL" => "Invalid email",
|
|
15
|
+
"INVALID_EMAIL_OR_PASSWORD" => "Invalid email or password",
|
|
16
|
+
"SOCIAL_ACCOUNT_ALREADY_LINKED" => "Social account already linked",
|
|
17
|
+
"PROVIDER_NOT_FOUND" => "Provider not found",
|
|
18
|
+
"INVALID_TOKEN" => "Invalid token",
|
|
19
|
+
"ID_TOKEN_NOT_SUPPORTED" => "id_token not supported",
|
|
20
|
+
"FAILED_TO_GET_USER_INFO" => "Failed to get user info",
|
|
21
|
+
"USER_EMAIL_NOT_FOUND" => "User email not found",
|
|
22
|
+
"EMAIL_NOT_VERIFIED" => "Email not verified",
|
|
23
|
+
"PASSWORD_TOO_SHORT" => "Password too short",
|
|
24
|
+
"PASSWORD_TOO_LONG" => "Password too long",
|
|
25
|
+
"USER_ALREADY_EXISTS" => "User already exists.",
|
|
26
|
+
"USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL" => "User already exists. Use another email.",
|
|
27
|
+
"EMAIL_CAN_NOT_BE_UPDATED" => "Email can not be updated",
|
|
28
|
+
"CREDENTIAL_ACCOUNT_NOT_FOUND" => "Credential account not found",
|
|
29
|
+
"SESSION_EXPIRED" => "Session expired. Re-authenticate to perform this action.",
|
|
30
|
+
"FAILED_TO_UNLINK_LAST_ACCOUNT" => "You can't unlink your last account",
|
|
31
|
+
"ACCOUNT_NOT_FOUND" => "Account not found",
|
|
32
|
+
"USER_ALREADY_HAS_PASSWORD" => "User already has a password. Provide that to delete the account.",
|
|
33
|
+
"CROSS_SITE_NAVIGATION_LOGIN_BLOCKED" => "Cross-site navigation login blocked. This request appears to be a CSRF attack.",
|
|
34
|
+
"VERIFICATION_EMAIL_NOT_ENABLED" => "Verification email isn't enabled",
|
|
35
|
+
"EMAIL_ALREADY_VERIFIED" => "Email is already verified",
|
|
36
|
+
"EMAIL_MISMATCH" => "Email mismatch",
|
|
37
|
+
"SESSION_NOT_FRESH" => "Session is not fresh",
|
|
38
|
+
"LINKED_ACCOUNT_ALREADY_EXISTS" => "Linked account already exists",
|
|
39
|
+
"INVALID_ORIGIN" => "Invalid origin",
|
|
40
|
+
"INVALID_CALLBACK_URL" => "Invalid callbackURL",
|
|
41
|
+
"INVALID_REDIRECT_URL" => "Invalid redirectURL",
|
|
42
|
+
"INVALID_ERROR_CALLBACK_URL" => "Invalid errorCallbackURL",
|
|
43
|
+
"INVALID_NEW_USER_CALLBACK_URL" => "Invalid newUserCallbackURL",
|
|
44
|
+
"MISSING_OR_NULL_ORIGIN" => "Missing or null Origin",
|
|
45
|
+
"CALLBACK_URL_REQUIRED" => "callbackURL is required",
|
|
46
|
+
"FAILED_TO_CREATE_VERIFICATION" => "Unable to create verification",
|
|
47
|
+
"FIELD_NOT_ALLOWED" => "Field not allowed to be set",
|
|
48
|
+
"ASYNC_VALIDATION_NOT_SUPPORTED" => "Async validation is not supported",
|
|
49
|
+
"VALIDATION_ERROR" => "Validation Error",
|
|
50
|
+
"MISSING_FIELD" => "Field is required"
|
|
51
|
+
}.freeze
|
|
52
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Middleware
|
|
5
|
+
class OriginCheck
|
|
6
|
+
DEPRECATION_WARNING = "[Deprecation] disableOriginCheck: true currently also disables CSRF checks. In a future version, disableOriginCheck will ONLY disable URL validation. To keep CSRF disabled, add disableCSRFCheck: true to your config."
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@warned_backward_compat = false
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(endpoint_context)
|
|
13
|
+
return if %w[GET OPTIONS HEAD].include?(endpoint_context.method)
|
|
14
|
+
|
|
15
|
+
validate_origin(endpoint_context)
|
|
16
|
+
validate_fetch_metadata(endpoint_context)
|
|
17
|
+
return if skip_origin_check?(endpoint_context)
|
|
18
|
+
|
|
19
|
+
validate_callback_urls(endpoint_context)
|
|
20
|
+
nil
|
|
21
|
+
rescue APIError => error
|
|
22
|
+
Endpoint::Result.new(response: error.to_h, status: error.status_code, headers: error.headers).to_rack_response
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def validate_origin(endpoint_context, force: false)
|
|
28
|
+
return if skip_csrf_check?(endpoint_context)
|
|
29
|
+
return if skip_csrf_for_backward_compat?(endpoint_context)
|
|
30
|
+
return if skip_origin_path?(endpoint_context)
|
|
31
|
+
|
|
32
|
+
headers = endpoint_context.headers
|
|
33
|
+
should_validate = force || headers.key?("cookie")
|
|
34
|
+
return unless should_validate
|
|
35
|
+
|
|
36
|
+
origin = headers["origin"] || headers["referer"] || ""
|
|
37
|
+
if origin.empty? || origin == "null"
|
|
38
|
+
raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["MISSING_OR_NULL_ORIGIN"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
unless endpoint_context.context.trusted_origin?(origin)
|
|
42
|
+
log(endpoint_context.context, :error, "Invalid origin: #{origin}")
|
|
43
|
+
raise APIError.new("FORBIDDEN", message: "Invalid origin")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def validate_fetch_metadata(endpoint_context)
|
|
48
|
+
return if skip_csrf_check?(endpoint_context)
|
|
49
|
+
return if skip_csrf_for_backward_compat?(endpoint_context)
|
|
50
|
+
|
|
51
|
+
headers = endpoint_context.headers
|
|
52
|
+
return if headers.key?("cookie")
|
|
53
|
+
|
|
54
|
+
site = headers["sec-fetch-site"]
|
|
55
|
+
mode = headers["sec-fetch-mode"]
|
|
56
|
+
dest = headers["sec-fetch-dest"]
|
|
57
|
+
has_metadata = [site, mode, dest].any? { |value| value && !value.to_s.strip.empty? }
|
|
58
|
+
return unless has_metadata
|
|
59
|
+
|
|
60
|
+
if site == "cross-site" && mode == "navigate"
|
|
61
|
+
log(endpoint_context.context, :error, "Blocked cross-site navigation login attempt (CSRF protection)")
|
|
62
|
+
raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["CROSS_SITE_NAVIGATION_LOGIN_BLOCKED"])
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
validate_origin(endpoint_context, force: true)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def validate_callback_urls(endpoint_context)
|
|
69
|
+
{
|
|
70
|
+
"callbackURL" => "callbackURL",
|
|
71
|
+
"redirectTo" => "redirectURL",
|
|
72
|
+
"errorCallbackURL" => "errorCallbackURL",
|
|
73
|
+
"newUserCallbackURL" => "newUserCallbackURL"
|
|
74
|
+
}.each do |key, label|
|
|
75
|
+
value = fetch_data(endpoint_context.body, key) || fetch_data(endpoint_context.query, key)
|
|
76
|
+
next if value.nil? || value == ""
|
|
77
|
+
|
|
78
|
+
unless endpoint_context.context.trusted_origin?(value, allow_relative_paths: label != "origin")
|
|
79
|
+
log(endpoint_context.context, :error, "Invalid #{label}: #{value}")
|
|
80
|
+
raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def skip_csrf_check?(endpoint_context)
|
|
86
|
+
endpoint_context.context.options.advanced[:disable_csrf_check] == true
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def skip_origin_check?(endpoint_context)
|
|
90
|
+
!!endpoint_context.context.options.advanced[:disable_origin_check]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def skip_csrf_for_backward_compat?(endpoint_context)
|
|
94
|
+
advanced = endpoint_context.context.options.advanced
|
|
95
|
+
return false unless advanced[:disable_origin_check] == true
|
|
96
|
+
return false if advanced.key?(:disable_csrf_check)
|
|
97
|
+
|
|
98
|
+
unless @warned_backward_compat
|
|
99
|
+
log(endpoint_context.context, :warn, DEPRECATION_WARNING)
|
|
100
|
+
@warned_backward_compat = true
|
|
101
|
+
end
|
|
102
|
+
true
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def skip_origin_path?(endpoint_context)
|
|
106
|
+
skip = endpoint_context.context.options.advanced[:disable_origin_check]
|
|
107
|
+
return false unless skip.is_a?(Array)
|
|
108
|
+
|
|
109
|
+
skip.any? { |path| endpoint_context.path.start_with?(path.to_s) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fetch_data(data, key)
|
|
113
|
+
return unless data.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
data[key] || data[key.to_sym]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def log(context, level, message)
|
|
119
|
+
logger = context.logger
|
|
120
|
+
if logger.respond_to?(:call)
|
|
121
|
+
logger.call(level, message)
|
|
122
|
+
elsif logger.respond_to?(level)
|
|
123
|
+
logger.public_send(level, message)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|