better_auth 0.4.0 → 0.5.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 +2 -0
- data/README.md +24 -0
- data/lib/better_auth/adapters/internal_adapter.rb +5 -5
- data/lib/better_auth/adapters/sql.rb +96 -18
- data/lib/better_auth/api.rb +113 -13
- data/lib/better_auth/configuration.rb +97 -7
- data/lib/better_auth/context.rb +165 -12
- data/lib/better_auth/cookies.rb +6 -4
- data/lib/better_auth/core.rb +2 -0
- data/lib/better_auth/crypto/jwe.rb +27 -5
- data/lib/better_auth/crypto.rb +32 -0
- data/lib/better_auth/database_hooks.rb +5 -5
- data/lib/better_auth/endpoint.rb +87 -3
- data/lib/better_auth/error.rb +8 -1
- data/lib/better_auth/plugins/admin/schema.rb +2 -2
- data/lib/better_auth/plugins/admin.rb +344 -16
- data/lib/better_auth/plugins/anonymous.rb +37 -3
- data/lib/better_auth/plugins/device_authorization.rb +102 -5
- data/lib/better_auth/plugins/dub.rb +148 -0
- data/lib/better_auth/plugins/email_otp.rb +246 -15
- data/lib/better_auth/plugins/expo.rb +17 -1
- data/lib/better_auth/plugins/generic_oauth.rb +53 -7
- data/lib/better_auth/plugins/jwt.rb +37 -4
- data/lib/better_auth/plugins/last_login_method.rb +2 -2
- data/lib/better_auth/plugins/magic_link.rb +66 -3
- data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
- data/lib/better_auth/plugins/mcp/config.rb +51 -0
- data/lib/better_auth/plugins/mcp/consent.rb +31 -0
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
- data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
- data/lib/better_auth/plugins/mcp/registration.rb +31 -0
- data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
- data/lib/better_auth/plugins/mcp/schema.rb +91 -0
- data/lib/better_auth/plugins/mcp/token.rb +108 -0
- data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
- data/lib/better_auth/plugins/mcp.rb +111 -263
- data/lib/better_auth/plugins/multi_session.rb +61 -3
- data/lib/better_auth/plugins/oauth_protocol.rb +2 -2
- data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
- data/lib/better_auth/plugins/oidc_provider.rb +118 -14
- data/lib/better_auth/plugins/one_tap.rb +7 -2
- data/lib/better_auth/plugins/one_time_token.rb +42 -2
- data/lib/better_auth/plugins/open_api.rb +163 -318
- data/lib/better_auth/plugins/organization.rb +135 -36
- data/lib/better_auth/plugins/phone_number.rb +141 -6
- data/lib/better_auth/plugins/siwe.rb +69 -3
- data/lib/better_auth/plugins/two_factor.rb +65 -23
- data/lib/better_auth/plugins/username.rb +57 -2
- data/lib/better_auth/rate_limiter.rb +20 -0
- data/lib/better_auth/response.rb +42 -0
- data/lib/better_auth/router.rb +7 -1
- data/lib/better_auth/routes/account.rb +204 -38
- data/lib/better_auth/routes/email_verification.rb +98 -14
- data/lib/better_auth/routes/password.rb +125 -8
- data/lib/better_auth/routes/session.rb +128 -13
- data/lib/better_auth/routes/sign_in.rb +24 -2
- data/lib/better_auth/routes/sign_out.rb +13 -1
- data/lib/better_auth/routes/sign_up.rb +62 -4
- data/lib/better_auth/routes/social.rb +102 -7
- data/lib/better_auth/routes/user.rb +222 -20
- data/lib/better_auth/routes/validation.rb +50 -0
- data/lib/better_auth/secret_config.rb +115 -0
- data/lib/better_auth/session.rb +1 -1
- data/lib/better_auth/url_helpers.rb +12 -1
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +4 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cd7630e5e1e1775f9db5545b1f81cbe0fed67da4e38ac59e0aa92ec3a896da3a
|
|
4
|
+
data.tar.gz: 24bfbe49981f12d00533f0db6acc9e899bdeb3edea1145f2e6c38d5cc9e8493d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e168ce722c56f26dddc5e0e4eb34626ae6b4523aae547768970bde07b68f56ba31db302a2e1ea8abf0e4d1e5a5ad98018a7f0129174cc3ef09486294c7bfa28a
|
|
7
|
+
data.tar.gz: b7b9e1bbbab49acc51d876dadeebf067976658782af41a1b82e7c78d3fc2b2d15106d0e90ac7699a380e8b7f4b9211ad105bb388d8f95db17f8624b537e29d7b
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
- Modernized the MCP plugin to use OAuth Provider-style client, token, metadata, and protected-resource behavior while keeping legacy MCP routes as aliases.
|
|
11
|
+
|
|
10
12
|
## [0.4.0] - 2026-04-30
|
|
11
13
|
|
|
12
14
|
### Added
|
data/README.md
CHANGED
|
@@ -66,6 +66,30 @@ auth = BetterAuth.auth(
|
|
|
66
66
|
)
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
+
### Secret Rotation
|
|
70
|
+
|
|
71
|
+
Better Auth Ruby supports upstream-style non-destructive rotation for encrypted data through versioned secrets. The first entry is used for new encrypted payloads; older entries remain available for decrypting existing data.
|
|
72
|
+
|
|
73
|
+
```ruby
|
|
74
|
+
auth = BetterAuth.auth(
|
|
75
|
+
secret: ENV["BETTER_AUTH_SECRET"], # legacy fallback for older encrypted data
|
|
76
|
+
secrets: [
|
|
77
|
+
{ version: 2, value: ENV.fetch("BETTER_AUTH_SECRET_V2") },
|
|
78
|
+
{ version: 1, value: ENV.fetch("BETTER_AUTH_SECRET_V1") }
|
|
79
|
+
],
|
|
80
|
+
database: :memory
|
|
81
|
+
)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
You can also configure the same list via `BETTER_AUTH_SECRETS`:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
BETTER_AUTH_SECRETS="2:new-secret-base64,1:old-secret-base64"
|
|
88
|
+
BETTER_AUTH_SECRET="legacy-secret-for-pre-rotation-data"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Signed cookies and HMAC/JWT signatures continue to use the current `secret`, matching upstream Better Auth behavior.
|
|
92
|
+
|
|
69
93
|
### Password Hashing
|
|
70
94
|
|
|
71
95
|
Better Auth Ruby uses upstream-compatible `scrypt` password hashes by default through Ruby's `OpenSSL::KDF.scrypt`, so no extra password-hashing gem is required for the default setup.
|
|
@@ -15,18 +15,18 @@ module BetterAuth
|
|
|
15
15
|
@hooks = DatabaseHooks.new(adapter, options)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def create_oauth_user(user, account)
|
|
18
|
+
def create_oauth_user(user, account, context: nil)
|
|
19
19
|
adapter.transaction do
|
|
20
|
-
created_user = create_user(user)
|
|
20
|
+
created_user = create_user(user, context: context)
|
|
21
21
|
created_account = create_account(stringify_keys(account).merge("userId" => created_user["id"]))
|
|
22
22
|
{user: created_user, account: created_account}
|
|
23
23
|
end
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def create_user(user)
|
|
27
|
-
data = timestamps.merge(stringify_keys(user))
|
|
26
|
+
def create_user(user = nil, context: nil, **keywords)
|
|
27
|
+
data = timestamps.merge(stringify_keys((user || {}).merge(keywords)))
|
|
28
28
|
data["email"] = data["email"].to_s.downcase if data["email"]
|
|
29
|
-
hooks.create(data, "user")
|
|
29
|
+
hooks.create(data, "user", context: context)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def create_account(account)
|
|
@@ -173,8 +173,7 @@ module BetterAuth
|
|
|
173
173
|
end
|
|
174
174
|
|
|
175
175
|
def join_select_sql(model, join)
|
|
176
|
-
join.flat_map do |join_model,
|
|
177
|
-
join_model = join_model.to_s
|
|
176
|
+
normalized_join(model, join).flat_map do |join_model, _config|
|
|
178
177
|
schema_for(join_model).fetch(:fields).map do |field, attributes|
|
|
179
178
|
column = attributes[:field_name] || physical_name(field)
|
|
180
179
|
"#{quote(join_model)}.#{quote(column)} AS #{quote("#{join_model}__#{column}")}"
|
|
@@ -185,17 +184,79 @@ module BetterAuth
|
|
|
185
184
|
def join_sql(model, join)
|
|
186
185
|
return "" unless join
|
|
187
186
|
|
|
188
|
-
join.map do |join_model,
|
|
187
|
+
normalized_join(model, join).map do |join_model, config|
|
|
188
|
+
local_field = storage_field(model, config.fetch(:from))
|
|
189
|
+
foreign_field = storage_field(join_model, config.fetch(:to))
|
|
190
|
+
" LEFT JOIN #{quote(table_for(join_model))} AS #{quote(join_model)} ON #{quote(join_model)}.#{quote(foreign_field)} = #{quote(table_for(model))}.#{quote(local_field)}"
|
|
191
|
+
end.join
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def normalized_join(model, join)
|
|
195
|
+
return {} unless join
|
|
196
|
+
|
|
197
|
+
join.each_with_object({}) do |(join_model, config), result|
|
|
189
198
|
join_model = join_model.to_s
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
199
|
+
result[join_model] = normalize_join_config(model.to_s, join_model, config)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def normalize_join_config(model, join_model, config)
|
|
204
|
+
if config.is_a?(Hash) && (config.key?(:on) || config.key?("on"))
|
|
205
|
+
on = config[:on] || config["on"]
|
|
206
|
+
from = storage_key(fetch_key(on, :from))
|
|
207
|
+
to = storage_key(fetch_key(on, :to))
|
|
208
|
+
relation = config[:relation] || config["relation"]
|
|
209
|
+
limit = config[:limit] || config["limit"]
|
|
210
|
+
return {from: from, to: to, relation: relation, limit: limit, unique: unique_join_field?(join_model, to)}
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
inferred = inferred_join_config(model, join_model)
|
|
214
|
+
if config.is_a?(Hash)
|
|
215
|
+
relation = config[:relation] || config["relation"]
|
|
216
|
+
limit = config[:limit] || config["limit"]
|
|
217
|
+
inferred = inferred.merge(relation: relation) if relation
|
|
218
|
+
inferred = inferred.merge(limit: limit) if limit
|
|
219
|
+
end
|
|
220
|
+
inferred
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def inferred_join_config(model, join_model)
|
|
224
|
+
foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
|
|
225
|
+
reference_model_matches?(attributes, model)
|
|
226
|
+
end
|
|
227
|
+
forward_join = true
|
|
228
|
+
|
|
229
|
+
if foreign_keys.empty?
|
|
230
|
+
foreign_keys = schema_for(model).fetch(:fields).select do |_field, attributes|
|
|
231
|
+
reference_model_matches?(attributes, join_model)
|
|
197
232
|
end
|
|
198
|
-
|
|
233
|
+
forward_join = false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
raise Error, "No foreign key found for model #{join_model} and base model #{model} while performing join operation." if foreign_keys.empty?
|
|
237
|
+
raise Error, "Multiple foreign keys found for model #{join_model} and base model #{model} while performing join operation. Only one foreign key is supported." if foreign_keys.length > 1
|
|
238
|
+
|
|
239
|
+
foreign_key, attributes = foreign_keys.first
|
|
240
|
+
reference = attributes.fetch(:references)
|
|
241
|
+
if forward_join
|
|
242
|
+
unique = attributes[:unique] == true
|
|
243
|
+
{from: reference.fetch(:field).to_s, to: foreign_key, relation: unique ? "one-to-one" : "one-to-many", unique: unique}
|
|
244
|
+
else
|
|
245
|
+
{from: foreign_key, to: reference.fetch(:field).to_s, relation: "one-to-one", unique: true}
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def reference_model_matches?(attributes, model)
|
|
250
|
+
reference = attributes[:references]
|
|
251
|
+
return false unless reference
|
|
252
|
+
|
|
253
|
+
reference_model = reference[:model] || reference["model"]
|
|
254
|
+
reference_model.to_s == model.to_s || reference_model.to_s == table_for(model)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def unique_join_field?(model, field)
|
|
258
|
+
field = storage_key(field)
|
|
259
|
+
field == "id" || schema_for(model).fetch(:fields).dig(field, :unique) == true
|
|
199
260
|
end
|
|
200
261
|
|
|
201
262
|
def build_where(model, where, params)
|
|
@@ -303,8 +364,7 @@ module BetterAuth
|
|
|
303
364
|
output[field] = coerce_output_value(fetch_row(row, column), attributes) if row_key?(row, column)
|
|
304
365
|
end
|
|
305
366
|
|
|
306
|
-
join
|
|
307
|
-
join_model = join_model.to_s
|
|
367
|
+
normalized_join(model, join).each_key do |join_model|
|
|
308
368
|
record[join_model] = normalize_joined_record(join_model, row)
|
|
309
369
|
end
|
|
310
370
|
|
|
@@ -320,16 +380,34 @@ module BetterAuth
|
|
|
320
380
|
end
|
|
321
381
|
|
|
322
382
|
def collection_join?(model, join)
|
|
323
|
-
model
|
|
383
|
+
normalized_join(model, join).any? do |_join_model, config|
|
|
384
|
+
config[:relation] != "one-to-one" && config[:unique] != true
|
|
385
|
+
end
|
|
324
386
|
end
|
|
325
387
|
|
|
326
|
-
def aggregate_collection_joins(
|
|
388
|
+
def aggregate_collection_joins(model, records, join)
|
|
389
|
+
join_config = normalized_join(model, join)
|
|
327
390
|
grouped = {}
|
|
328
391
|
records.each do |record|
|
|
329
392
|
key = record.fetch("id")
|
|
330
|
-
grouped[key] ||=
|
|
331
|
-
|
|
332
|
-
|
|
393
|
+
grouped[key] ||= begin
|
|
394
|
+
base = record.reject { |field, _value| join_config.key?(field) }
|
|
395
|
+
join_defaults = join_config.each_with_object({}) do |(join_model, config), defaults|
|
|
396
|
+
defaults[join_model] = (config[:relation] == "one-to-one" || config[:unique] == true) ? nil : []
|
|
397
|
+
end
|
|
398
|
+
base.merge(join_defaults)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
join_config.each do |join_model, config|
|
|
402
|
+
joined = record[join_model]
|
|
403
|
+
next unless joined&.values&.any?
|
|
404
|
+
|
|
405
|
+
if config[:relation] == "one-to-one" || config[:unique] == true
|
|
406
|
+
grouped[key][join_model] = joined
|
|
407
|
+
else
|
|
408
|
+
grouped[key][join_model] << joined
|
|
409
|
+
end
|
|
410
|
+
end
|
|
333
411
|
end
|
|
334
412
|
grouped.values
|
|
335
413
|
end
|
data/lib/better_auth/api.rb
CHANGED
|
@@ -14,18 +14,34 @@ module BetterAuth
|
|
|
14
14
|
context.reset_runtime! if context.respond_to?(:reset_runtime!)
|
|
15
15
|
endpoint = endpoints.fetch(key.to_sym)
|
|
16
16
|
input = symbolize_keys(input || {})
|
|
17
|
+
request = input[:request]
|
|
18
|
+
headers = request_headers(request).merge(normalize_headers_hash(input[:headers] || {}))
|
|
19
|
+
begin
|
|
20
|
+
prepare_context_for_call!(request, headers)
|
|
21
|
+
rescue APIError => error
|
|
22
|
+
result = Endpoint::Result.new(response: error, status: error.status_code, headers: error.headers)
|
|
23
|
+
return format_result(result, input)
|
|
24
|
+
rescue Error => error
|
|
25
|
+
api_error = APIError.new("INTERNAL_SERVER_ERROR", message: error.message)
|
|
26
|
+
result = Endpoint::Result.new(response: api_error, status: api_error.status_code, headers: api_error.headers)
|
|
27
|
+
return format_result(result, input)
|
|
28
|
+
end
|
|
29
|
+
input[:as_response] = true if request_like?(request) && !input.key?(:as_response)
|
|
17
30
|
endpoint_context = Endpoint::Context.new(
|
|
18
31
|
path: endpoint.path,
|
|
19
|
-
method: input[:method] || Array(endpoint.methods).first,
|
|
32
|
+
method: input[:method] || request_method(request) || Array(endpoint.methods).first,
|
|
20
33
|
query: input[:query] || {},
|
|
21
34
|
body: input[:body] || {},
|
|
22
35
|
params: input[:params] || {},
|
|
23
|
-
headers:
|
|
24
|
-
context: context
|
|
36
|
+
headers: headers,
|
|
37
|
+
context: context,
|
|
38
|
+
request: request_like?(request) ? request : nil
|
|
25
39
|
)
|
|
26
40
|
|
|
27
41
|
result = run_endpoint_with_hooks(endpoint, endpoint_context)
|
|
28
42
|
format_result(result, input)
|
|
43
|
+
ensure
|
|
44
|
+
context.clear_runtime! if context.respond_to?(:clear_runtime!)
|
|
29
45
|
end
|
|
30
46
|
|
|
31
47
|
def execute(endpoint, endpoint_context)
|
|
@@ -51,6 +67,34 @@ module BetterAuth
|
|
|
51
67
|
.to_sym
|
|
52
68
|
end
|
|
53
69
|
|
|
70
|
+
def prepare_context_for_call!(request, headers)
|
|
71
|
+
return unless context.respond_to?(:prepare_for_api_call!)
|
|
72
|
+
|
|
73
|
+
source = direct_call_source(request, headers)
|
|
74
|
+
if context.options.dynamic_base_url? && source.nil? && !dynamic_base_url_fallback?
|
|
75
|
+
raise APIError.new(
|
|
76
|
+
"INTERNAL_SERVER_ERROR",
|
|
77
|
+
message: "Dynamic baseURL could not be resolved for this direct auth.api call. Pass `headers: request.headers` (or `request`) to the call, or add `fallback` to your baseURL config."
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
context.prepare_for_api_call!(source)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def direct_call_source(request, headers)
|
|
85
|
+
return request if request_like?(request)
|
|
86
|
+
return headers if headers.key?("host") || headers.key?("x-forwarded-host")
|
|
87
|
+
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def dynamic_base_url_fallback?
|
|
92
|
+
config = context.options.base_url_config
|
|
93
|
+
return false unless config.is_a?(Hash)
|
|
94
|
+
|
|
95
|
+
!!(config[:fallback] || config["fallback"])
|
|
96
|
+
end
|
|
97
|
+
|
|
54
98
|
def run_endpoint_with_hooks(endpoint, endpoint_context)
|
|
55
99
|
before = run_before_hooks(endpoint_context)
|
|
56
100
|
return normalize_short_circuit(before, endpoint_context) if before
|
|
@@ -136,15 +180,16 @@ module BetterAuth
|
|
|
136
180
|
end
|
|
137
181
|
|
|
138
182
|
def format_result(result, input)
|
|
139
|
-
return result.
|
|
183
|
+
return result.to_response if result.raw_response?
|
|
184
|
+
as_response = input[:as_response] || request_like?(input[:request])
|
|
140
185
|
|
|
141
186
|
if result.response.is_a?(APIError)
|
|
142
|
-
return error_response(result.response, headers: result.headers) if
|
|
187
|
+
return error_response(result.response, headers: result.headers) if as_response
|
|
143
188
|
|
|
144
|
-
raise result.response
|
|
189
|
+
raise error_with_headers(result.response, result.headers)
|
|
145
190
|
end
|
|
146
191
|
|
|
147
|
-
return result.
|
|
192
|
+
return result.to_response if as_response
|
|
148
193
|
|
|
149
194
|
if input[:return_headers]
|
|
150
195
|
output = {
|
|
@@ -165,7 +210,17 @@ module BetterAuth
|
|
|
165
210
|
response: error.to_h,
|
|
166
211
|
status: error.status_code,
|
|
167
212
|
headers: Endpoint::Result.merge_headers(headers, error.headers)
|
|
168
|
-
).
|
|
213
|
+
).to_response
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def error_with_headers(error, headers)
|
|
217
|
+
APIError.new(
|
|
218
|
+
error.status,
|
|
219
|
+
message: error.message,
|
|
220
|
+
headers: Endpoint::Result.merge_headers(headers, error.headers),
|
|
221
|
+
code: error.code,
|
|
222
|
+
body: error.body
|
|
223
|
+
)
|
|
169
224
|
end
|
|
170
225
|
|
|
171
226
|
def before_hooks
|
|
@@ -207,16 +262,61 @@ module BetterAuth
|
|
|
207
262
|
hash[key] || hash[key.to_s]
|
|
208
263
|
end
|
|
209
264
|
|
|
265
|
+
def request_like?(value)
|
|
266
|
+
return false unless value
|
|
267
|
+
|
|
268
|
+
value.respond_to?(:request_method) || request_method(value) || !request_headers(value).empty?
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def request_method(request)
|
|
272
|
+
return unless request
|
|
273
|
+
return request.request_method if request.respond_to?(:request_method)
|
|
274
|
+
|
|
275
|
+
request.public_send(:method)
|
|
276
|
+
rescue ArgumentError, NoMethodError
|
|
277
|
+
nil
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def request_headers(request)
|
|
281
|
+
return {} unless request
|
|
282
|
+
|
|
283
|
+
if request.respond_to?(:env)
|
|
284
|
+
return headers_from_env(request.env)
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
return normalize_headers_hash(request.headers) if request.respond_to?(:headers)
|
|
288
|
+
|
|
289
|
+
{}
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def headers_from_env(env)
|
|
293
|
+
env.each_with_object({}) do |(key, value), headers|
|
|
294
|
+
case key
|
|
295
|
+
when "CONTENT_TYPE"
|
|
296
|
+
headers["content-type"] = value if value
|
|
297
|
+
when "CONTENT_LENGTH"
|
|
298
|
+
headers["content-length"] = value if value
|
|
299
|
+
else
|
|
300
|
+
next unless key.start_with?("HTTP_")
|
|
301
|
+
|
|
302
|
+
header = key.delete_prefix("HTTP_").downcase.tr("_", "-")
|
|
303
|
+
headers[header] = value
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def normalize_headers_hash(headers)
|
|
309
|
+
headers.each_with_object({}) do |(key, value), result|
|
|
310
|
+
result[key.to_s.downcase.tr("_", "-")] = value
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
210
314
|
def symbolize_keys(value)
|
|
211
315
|
return value unless value.is_a?(Hash)
|
|
212
316
|
|
|
213
317
|
value.each_with_object({}) do |(key, object_value), result|
|
|
214
318
|
normalized_key = normalize_key(key)
|
|
215
|
-
result[normalized_key] =
|
|
216
|
-
object_value
|
|
217
|
-
else
|
|
218
|
-
object_value.is_a?(Hash) ? symbolize_keys(object_value) : object_value
|
|
219
|
-
end
|
|
319
|
+
result[normalized_key] = object_value
|
|
220
320
|
end
|
|
221
321
|
end
|
|
222
322
|
|
|
@@ -31,10 +31,11 @@ module BetterAuth
|
|
|
31
31
|
}.freeze
|
|
32
32
|
|
|
33
33
|
attr_reader :app_name,
|
|
34
|
-
:
|
|
34
|
+
:base_url_config,
|
|
35
35
|
:base_path,
|
|
36
36
|
:context_base_url,
|
|
37
37
|
:secret,
|
|
38
|
+
:secret_config,
|
|
38
39
|
:database,
|
|
39
40
|
:plugins,
|
|
40
41
|
:trusted_origins,
|
|
@@ -73,8 +74,19 @@ module BetterAuth
|
|
|
73
74
|
@hooks = options[:hooks]
|
|
74
75
|
@on_api_error = symbolize_keys(options[:on_api_error] || options[:on_apierror] || {})
|
|
75
76
|
@social_providers = symbolize_keys(options[:social_providers] || {})
|
|
76
|
-
@
|
|
77
|
-
@
|
|
77
|
+
@trusted_origins_callbacks = []
|
|
78
|
+
@trusted_origins_callbacks << options[:trusted_origins] if options[:trusted_origins].respond_to?(:call)
|
|
79
|
+
@trusted_origins_callback = combined_trusted_origins_callback
|
|
80
|
+
legacy_secret = resolve_secret(options, allow_test_default: false)
|
|
81
|
+
secrets = options.key?(:secrets) ? options[:secrets] : SecretConfig.parse_env(ENV["BETTER_AUTH_SECRETS"])
|
|
82
|
+
if secrets
|
|
83
|
+
@secret_config = SecretConfig.build(secrets, legacy_secret, logger: logger)
|
|
84
|
+
@secret = @secret_config.current_secret
|
|
85
|
+
else
|
|
86
|
+
@secret = legacy_secret || (test_environment? ? DEFAULT_SECRET : nil)
|
|
87
|
+
@secret_config = @secret
|
|
88
|
+
end
|
|
89
|
+
@base_url_config = options[:base_url]
|
|
78
90
|
@base_url, @context_base_url = normalize_base_url(options[:base_url])
|
|
79
91
|
@session = normalize_session(options[:session])
|
|
80
92
|
@account = normalize_account(options[:account])
|
|
@@ -96,16 +108,33 @@ module BetterAuth
|
|
|
96
108
|
end
|
|
97
109
|
end
|
|
98
110
|
|
|
111
|
+
def base_url
|
|
112
|
+
Thread.current[base_url_runtime_key] || @base_url
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def set_runtime_base_url(value)
|
|
116
|
+
Thread.current[base_url_runtime_key] = value
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def clear_runtime_base_url!
|
|
120
|
+
Thread.current[base_url_runtime_key] = nil
|
|
121
|
+
end
|
|
122
|
+
|
|
99
123
|
def production?
|
|
100
124
|
production_environment?
|
|
101
125
|
end
|
|
102
126
|
|
|
127
|
+
def dynamic_base_url?
|
|
128
|
+
URLHelpers.dynamic_config?(base_url_config)
|
|
129
|
+
end
|
|
130
|
+
|
|
103
131
|
def to_h
|
|
104
132
|
{
|
|
105
133
|
app_name: app_name,
|
|
106
134
|
base_url: base_url,
|
|
107
135
|
base_path: base_path,
|
|
108
136
|
secret: secret,
|
|
137
|
+
secret_config: secret_config,
|
|
109
138
|
database: database,
|
|
110
139
|
plugins: plugins,
|
|
111
140
|
trusted_origins: trusted_origins,
|
|
@@ -134,6 +163,11 @@ module BetterAuth
|
|
|
134
163
|
next unless respond_to?(key)
|
|
135
164
|
next if key == :database_hooks
|
|
136
165
|
|
|
166
|
+
if key == :trusted_origins
|
|
167
|
+
merge_trusted_origins_default(value)
|
|
168
|
+
next
|
|
169
|
+
end
|
|
170
|
+
|
|
137
171
|
instance_variable_set("@#{key}", merge_default_value([key], public_send(key), value))
|
|
138
172
|
end
|
|
139
173
|
end
|
|
@@ -146,7 +180,12 @@ module BetterAuth
|
|
|
146
180
|
return false unless uri
|
|
147
181
|
|
|
148
182
|
if pattern.include?("*") || pattern.include?("?")
|
|
149
|
-
|
|
183
|
+
if pattern.include?("://")
|
|
184
|
+
origin = origin_for(uri)
|
|
185
|
+
return true if origin && wildcard_match?(pattern, origin)
|
|
186
|
+
|
|
187
|
+
return wildcard_match?(pattern, url)
|
|
188
|
+
end
|
|
150
189
|
|
|
151
190
|
return wildcard_match?(pattern, uri.host.to_s)
|
|
152
191
|
end
|
|
@@ -191,6 +230,15 @@ module BetterAuth
|
|
|
191
230
|
configured = value || env_base_url
|
|
192
231
|
return ["", ""] unless configured && !configured.empty?
|
|
193
232
|
|
|
233
|
+
if URLHelpers.dynamic_config?(configured)
|
|
234
|
+
validate_dynamic_base_url!(configured)
|
|
235
|
+
resolved = URLHelpers.resolve_base_url(configured, base_path)
|
|
236
|
+
return ["", ""] unless resolved
|
|
237
|
+
|
|
238
|
+
uri = URI.parse(resolved)
|
|
239
|
+
return [self.class.origin_for(uri), resolved.sub(%r{/+\z}, "")]
|
|
240
|
+
end
|
|
241
|
+
|
|
194
242
|
with_path = append_base_path(configured.to_s)
|
|
195
243
|
uri = URI.parse(with_path)
|
|
196
244
|
validate_http_url!(uri, configured)
|
|
@@ -199,6 +247,11 @@ module BetterAuth
|
|
|
199
247
|
raise Error, "Invalid base URL: #{configured}. Please provide a valid base URL."
|
|
200
248
|
end
|
|
201
249
|
|
|
250
|
+
def validate_dynamic_base_url!(value)
|
|
251
|
+
allowed_hosts = value[:allowed_hosts] || value["allowed_hosts"] || value[:allowedHosts] || value["allowedHosts"]
|
|
252
|
+
raise Error, "baseURL.allowedHosts cannot be empty" if allowed_hosts.respond_to?(:empty?) && allowed_hosts.empty?
|
|
253
|
+
end
|
|
254
|
+
|
|
202
255
|
def normalize_base_path(value)
|
|
203
256
|
return "" if value.nil? || value == "" || value == "/"
|
|
204
257
|
|
|
@@ -235,8 +288,9 @@ module BetterAuth
|
|
|
235
288
|
].find { |value| value && !value.empty? }
|
|
236
289
|
end
|
|
237
290
|
|
|
238
|
-
def resolve_secret(options)
|
|
239
|
-
options[:secret]
|
|
291
|
+
def resolve_secret(options, allow_test_default: true)
|
|
292
|
+
[options[:secret], ENV["BETTER_AUTH_SECRET"], ENV["AUTH_SECRET"]].find { |value| value && !value.empty? } ||
|
|
293
|
+
((allow_test_default && test_environment?) ? DEFAULT_SECRET : nil)
|
|
240
294
|
end
|
|
241
295
|
|
|
242
296
|
def validate_secret
|
|
@@ -244,6 +298,10 @@ module BetterAuth
|
|
|
244
298
|
raise Error, "BETTER_AUTH_SECRET is missing. Set it in your environment or pass `secret` to BetterAuth.auth(secret: ...)."
|
|
245
299
|
end
|
|
246
300
|
|
|
301
|
+
if production_environment? && secret == DEFAULT_SECRET
|
|
302
|
+
raise Error, "You are using the default secret. Please set `BETTER_AUTH_SECRET` in your environment variables or pass `secret` in your auth config."
|
|
303
|
+
end
|
|
304
|
+
|
|
247
305
|
return if test_environment? && secret == DEFAULT_SECRET
|
|
248
306
|
|
|
249
307
|
warn("[better-auth] Warning: your BETTER_AUTH_SECRET should be at least 32 characters long for adequate security.") if secret.length < 32
|
|
@@ -263,7 +321,8 @@ module BetterAuth
|
|
|
263
321
|
session = deep_merge(DEFAULT_SESSION, configured)
|
|
264
322
|
|
|
265
323
|
if database.nil?
|
|
266
|
-
session = deep_merge(session, DEFAULT_STATELESS_SESSION)
|
|
324
|
+
session = deep_merge(session, deep_dup(DEFAULT_STATELESS_SESSION))
|
|
325
|
+
session[:cookie_cache][:max_age] ||= session[:expires_in]
|
|
267
326
|
else
|
|
268
327
|
session[:cookie_cache] = cookie_cache unless cookie_cache.empty?
|
|
269
328
|
end
|
|
@@ -316,11 +375,38 @@ module BetterAuth
|
|
|
316
375
|
def normalize_trusted_origins(value)
|
|
317
376
|
origins = []
|
|
318
377
|
origins << base_url unless base_url.nil? || base_url.empty?
|
|
378
|
+
origins.concat(dynamic_base_url_trusted_origins)
|
|
319
379
|
origins.concat(Array(value).compact) unless value.respond_to?(:call)
|
|
320
380
|
origins.concat(env_trusted_origins)
|
|
321
381
|
origins.map(&:to_s).reject(&:empty?).uniq
|
|
322
382
|
end
|
|
323
383
|
|
|
384
|
+
def dynamic_base_url_trusted_origins
|
|
385
|
+
return [] unless URLHelpers.dynamic_config?(base_url_config)
|
|
386
|
+
|
|
387
|
+
protocol = base_url_config[:protocol] || base_url_config["protocol"] || "https"
|
|
388
|
+
allowed_hosts = base_url_config[:allowed_hosts] || base_url_config["allowed_hosts"] || base_url_config[:allowedHosts] || base_url_config["allowedHosts"] || []
|
|
389
|
+
Array(allowed_hosts).map do |host|
|
|
390
|
+
host = host.to_s
|
|
391
|
+
host.match?(%r{\Ahttps?://}i) ? host : "#{protocol}://#{host}"
|
|
392
|
+
end
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def merge_trusted_origins_default(value)
|
|
396
|
+
if value.respond_to?(:call)
|
|
397
|
+
@trusted_origins_callbacks << value
|
|
398
|
+
@trusted_origins_callback = combined_trusted_origins_callback
|
|
399
|
+
else
|
|
400
|
+
@trusted_origins = (trusted_origins + Array(value).compact.map(&:to_s).reject(&:empty?)).uniq
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def combined_trusted_origins_callback
|
|
405
|
+
return nil if @trusted_origins_callbacks.empty?
|
|
406
|
+
|
|
407
|
+
->(request) { @trusted_origins_callbacks.flat_map { |callback| Array(callback.call(request)) }.compact }
|
|
408
|
+
end
|
|
409
|
+
|
|
324
410
|
def env_trusted_origins
|
|
325
411
|
ENV.fetch("BETTER_AUTH_TRUSTED_ORIGINS", "").split(",").map(&:strip).reject(&:empty?)
|
|
326
412
|
end
|
|
@@ -388,6 +474,10 @@ module BetterAuth
|
|
|
388
474
|
end
|
|
389
475
|
end
|
|
390
476
|
|
|
477
|
+
def base_url_runtime_key
|
|
478
|
+
:"better_auth_configuration_base_url_#{object_id}"
|
|
479
|
+
end
|
|
480
|
+
|
|
391
481
|
def test_environment?
|
|
392
482
|
ENV["RACK_ENV"] == "test" || ENV["RAILS_ENV"] == "test" || ENV["APP_ENV"] == "test"
|
|
393
483
|
end
|