better_auth 0.10.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 +83 -0
- data/LICENSE.md +1 -1
- data/README.md +138 -91
- 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 +191 -56
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +294 -49
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +41 -46
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +110 -14
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +14 -3
- data/lib/better_auth/error.rb +3 -1
- 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 +4 -4
- 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 +79 -28
- 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 +47 -9
- data/lib/better_auth/plugins/dub.rb +2 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +141 -146
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +37 -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 +142 -32
- 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 +61 -60
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +35 -7
- data/lib/better_auth/plugins/organization.rb +455 -122
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +24 -26
- 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 +186 -21
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +297 -135
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +40 -8
- data/lib/better_auth/routes/email_verification.rb +88 -5
- data/lib/better_auth/routes/password.rb +5 -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 +169 -54
- data/lib/better_auth/routes/user.rb +11 -8
- data/lib/better_auth/schema/sql.rb +90 -24
- data/lib/better_auth/schema.rb +124 -3
- data/lib/better_auth/session.rb +27 -9
- 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 +41 -14
- 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/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 +5 -5
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +11 -41
- metadata +5 -13
- 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 -256
- data/lib/better_auth/plugins/oidc_provider.rb +0 -769
|
@@ -8,24 +8,60 @@ module BetterAuth
|
|
|
8
8
|
|
|
9
9
|
module_function
|
|
10
10
|
|
|
11
|
-
def client_ip(
|
|
11
|
+
def client_ip(source, options)
|
|
12
12
|
ip_options = options.advanced[:ip_address] || {}
|
|
13
13
|
return nil if ip_options[:disable_ip_tracking]
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
request = unwrap_request(source)
|
|
16
|
+
configured_headers = ip_options[:ip_address_headers]
|
|
17
|
+
if configured_headers
|
|
18
|
+
Array(configured_headers).each do |header|
|
|
19
|
+
value = header_value(source, header)
|
|
20
|
+
next unless value.is_a?(String)
|
|
21
|
+
|
|
22
|
+
ip = ip_from_header(
|
|
23
|
+
value,
|
|
24
|
+
trusted_proxies: ip_options[:trusted_proxies],
|
|
25
|
+
peer_ip: peer_ip(request),
|
|
26
|
+
ipv6_subnet: ip_options[:ipv6_subnet]
|
|
27
|
+
)
|
|
28
|
+
return ip if ip
|
|
29
|
+
end
|
|
21
30
|
end
|
|
22
31
|
|
|
23
|
-
ip = fallback_ip(request)
|
|
32
|
+
ip = fallback_ip(request, allow_forwarded_headers: configured_headers.nil?)
|
|
24
33
|
return normalize_ip(ip, ipv6_subnet: ip_options[:ipv6_subnet]) if valid_ip?(ip)
|
|
25
34
|
|
|
26
35
|
LOCALHOST_IP if test_or_development?
|
|
27
36
|
end
|
|
28
37
|
|
|
38
|
+
def ip_from_header(value, trusted_proxies: nil, peer_ip: nil, ipv6_subnet: nil)
|
|
39
|
+
forwarded_ips = value.split(",").map(&:strip).reject(&:empty?)
|
|
40
|
+
return nil if forwarded_ips.empty?
|
|
41
|
+
|
|
42
|
+
proxy_entries = Array(trusted_proxies).map(&:to_s).reject(&:empty?)
|
|
43
|
+
if proxy_entries.any?
|
|
44
|
+
proxies = proxy_entries.map { |entry| parse_trusted_proxy(entry) }
|
|
45
|
+
return nil if proxies.any?(&:nil?)
|
|
46
|
+
|
|
47
|
+
peer_address = parse_ip(peer_ip)
|
|
48
|
+
return nil unless peer_address && proxies.any? { |proxy| proxy.include?(native_address(peer_address)) }
|
|
49
|
+
|
|
50
|
+
forwarded_ips.reverse_each do |ip|
|
|
51
|
+
address = parse_ip(ip)
|
|
52
|
+
return nil unless address
|
|
53
|
+
next if proxies.any? { |proxy| proxy.include?(native_address(address)) }
|
|
54
|
+
|
|
55
|
+
return normalize_ip(ip, ipv6_subnet: ipv6_subnet)
|
|
56
|
+
end
|
|
57
|
+
return nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return nil unless forwarded_ips.one? && valid_ip?(forwarded_ips.first)
|
|
61
|
+
|
|
62
|
+
normalize_ip(forwarded_ips.first, ipv6_subnet: ipv6_subnet)
|
|
63
|
+
end
|
|
64
|
+
|
|
29
65
|
def header_value(request, header)
|
|
30
66
|
return request.get_header(rack_header_name(header)) if request.respond_to?(:get_header)
|
|
31
67
|
return request.headers[header.to_s.downcase] if request.respond_to?(:headers)
|
|
@@ -34,12 +70,55 @@ module BetterAuth
|
|
|
34
70
|
nil
|
|
35
71
|
end
|
|
36
72
|
|
|
37
|
-
def fallback_ip(request)
|
|
38
|
-
return
|
|
73
|
+
def fallback_ip(request, allow_forwarded_headers:)
|
|
74
|
+
return nil unless request
|
|
75
|
+
|
|
76
|
+
remote_ip = request.remote_ip.to_s if request.respond_to?(:remote_ip)
|
|
77
|
+
return remote_ip if valid_ip?(remote_ip)
|
|
78
|
+
|
|
79
|
+
direct_peer_ip = peer_ip(request)
|
|
80
|
+
return nil unless valid_ip?(direct_peer_ip)
|
|
81
|
+
|
|
82
|
+
if allow_forwarded_headers && request.respond_to?(:ip)
|
|
83
|
+
ip = request.ip.to_s
|
|
84
|
+
return ip if valid_ip?(ip)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
direct_peer_ip
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def peer_ip(request)
|
|
91
|
+
if request.respond_to?(:get_header)
|
|
92
|
+
value = request.get_header("REMOTE_ADDR")
|
|
93
|
+
return value.to_s unless value.nil?
|
|
94
|
+
end
|
|
95
|
+
if request.respond_to?(:env)
|
|
96
|
+
value = request.env["REMOTE_ADDR"]
|
|
97
|
+
return value.to_s unless value.nil?
|
|
98
|
+
end
|
|
99
|
+
if request.is_a?(Hash)
|
|
100
|
+
value = request["REMOTE_ADDR"] || request[:REMOTE_ADDR] || request[:remote_addr]
|
|
101
|
+
return value.to_s unless value.nil?
|
|
102
|
+
end
|
|
39
103
|
|
|
40
104
|
nil
|
|
41
105
|
end
|
|
42
106
|
|
|
107
|
+
def unwrap_request(source)
|
|
108
|
+
current = source
|
|
109
|
+
seen = {}
|
|
110
|
+
while current&.respond_to?(:request)
|
|
111
|
+
break if seen[current.object_id]
|
|
112
|
+
|
|
113
|
+
seen[current.object_id] = true
|
|
114
|
+
nested = current.request
|
|
115
|
+
break unless nested && !nested.equal?(current)
|
|
116
|
+
|
|
117
|
+
current = nested
|
|
118
|
+
end
|
|
119
|
+
current
|
|
120
|
+
end
|
|
121
|
+
|
|
43
122
|
def rack_header_name(header)
|
|
44
123
|
"HTTP_#{header.to_s.upcase.tr("-", "_")}"
|
|
45
124
|
end
|
|
@@ -47,10 +126,41 @@ module BetterAuth
|
|
|
47
126
|
def valid_ip?(ip)
|
|
48
127
|
return false if ip.to_s.empty? || ip.to_s.match?(/\s/)
|
|
49
128
|
|
|
129
|
+
!parse_ip(ip).nil?
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def parse_ip(ip)
|
|
50
133
|
IPAddr.new(ip)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
134
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
135
|
+
nil
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def parse_trusted_proxy(entry)
|
|
139
|
+
value = entry.to_s
|
|
140
|
+
return nil if value.empty? || value.match?(/\s/)
|
|
141
|
+
|
|
142
|
+
if value.include?("/")
|
|
143
|
+
address, prefix = value.split("/", -1)
|
|
144
|
+
return nil unless value.count("/") == 1 && prefix.match?(/\A\d+\z/)
|
|
145
|
+
|
|
146
|
+
parsed_address = parse_ip(address)
|
|
147
|
+
return nil unless parsed_address
|
|
148
|
+
|
|
149
|
+
max_prefix = parsed_address.ipv4? ? 32 : 128
|
|
150
|
+
return nil if prefix.to_i > max_prefix
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
IPAddr.new(value)
|
|
154
|
+
rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def native_address(address)
|
|
159
|
+
if address.respond_to?(:ipv4_mapped?) && address.ipv4_mapped?
|
|
160
|
+
address.native
|
|
161
|
+
else
|
|
162
|
+
address
|
|
163
|
+
end
|
|
54
164
|
end
|
|
55
165
|
|
|
56
166
|
def normalize_ip(ip, ipv6_subnet: nil)
|
data/lib/better_auth/router.rb
CHANGED
|
@@ -75,12 +75,12 @@ module BetterAuth
|
|
|
75
75
|
|
|
76
76
|
return run_on_response_chain(not_found) if disabled_path?(route_path)
|
|
77
77
|
|
|
78
|
-
request = run_on_request_chain(request)
|
|
79
|
-
return run_on_response_chain(request) if rack_response?(request)
|
|
80
|
-
|
|
81
78
|
response = rate_limiter.call(request, context, route_path)
|
|
82
79
|
return run_on_response_chain(response) if response
|
|
83
80
|
|
|
81
|
+
request = run_on_request_chain(request)
|
|
82
|
+
return run_on_response_chain(request) if rack_response?(request)
|
|
83
|
+
|
|
84
84
|
endpoint_context = rebuild_endpoint_context(endpoint_context, request, route_path, params, endpoint)
|
|
85
85
|
result = API.new(context, endpoints).execute(endpoint, endpoint_context)
|
|
86
86
|
response = result.response.is_a?(APIError) ? error_response(result.response, headers: result.headers) : result.to_rack_response
|
|
@@ -230,6 +230,18 @@ module BetterAuth
|
|
|
230
230
|
in: "query",
|
|
231
231
|
required: false,
|
|
232
232
|
schema: {type: "string"}
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: "providerId",
|
|
236
|
+
in: "query",
|
|
237
|
+
required: false,
|
|
238
|
+
schema: {type: "string"}
|
|
239
|
+
},
|
|
240
|
+
{
|
|
241
|
+
name: "userId",
|
|
242
|
+
in: "query",
|
|
243
|
+
required: false,
|
|
244
|
+
schema: {type: "string"}
|
|
233
245
|
}
|
|
234
246
|
],
|
|
235
247
|
responses: {
|
|
@@ -260,23 +272,43 @@ module BetterAuth
|
|
|
260
272
|
}
|
|
261
273
|
}
|
|
262
274
|
) do |ctx|
|
|
263
|
-
session = current_session(ctx)
|
|
275
|
+
session = current_session(ctx, allow_nil: true)
|
|
276
|
+
raise APIError.new("UNAUTHORIZED") if !session && (ctx.request || !ctx.headers.empty?)
|
|
277
|
+
|
|
278
|
+
user_id = session&.dig(:user, "id") || fetch_value(ctx.query, "userId")
|
|
279
|
+
raise APIError.new("BAD_REQUEST", code: "USER_ID_OR_SESSION_REQUIRED", message: "Either userId or session is required") if user_id.to_s.empty?
|
|
280
|
+
|
|
264
281
|
account_id = fetch_value(ctx.query, "accountId")
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
282
|
+
provider_id = fetch_value(ctx.query, "providerId")
|
|
283
|
+
account = if !account_id.to_s.empty?
|
|
284
|
+
matching_accounts = ctx.context.internal_adapter.find_accounts(user_id).select do |entry|
|
|
285
|
+
entry["accountId"] == account_id && (provider_id.to_s.empty? || entry["providerId"] == provider_id)
|
|
286
|
+
end
|
|
287
|
+
if matching_accounts.length > 1
|
|
288
|
+
raise APIError.new(
|
|
289
|
+
"BAD_REQUEST",
|
|
290
|
+
code: "AMBIGUOUS_ACCOUNT",
|
|
291
|
+
message: "Multiple accounts share this account ID. Pass a providerId to disambiguate."
|
|
292
|
+
)
|
|
268
293
|
end
|
|
294
|
+
matching_accounts.first
|
|
269
295
|
else
|
|
270
|
-
account_cookie(ctx,
|
|
296
|
+
account_cookie(ctx, provider_id, nil, user_id)
|
|
271
297
|
end
|
|
272
|
-
raise APIError.new("BAD_REQUEST", message: "
|
|
298
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["ACCOUNT_NOT_FOUND"]) unless account && account["userId"] == user_id
|
|
273
299
|
|
|
274
300
|
provider = social_provider(ctx.context, account["providerId"])
|
|
275
|
-
|
|
301
|
+
unless provider && provider_callable(provider, :get_user_info)
|
|
302
|
+
raise APIError.new(
|
|
303
|
+
"BAD_REQUEST",
|
|
304
|
+
code: "PROVIDER_NOT_CONFIGURED",
|
|
305
|
+
message: "Account is not associated with a configured social provider."
|
|
306
|
+
)
|
|
307
|
+
end
|
|
276
308
|
|
|
277
309
|
tokens = access_token_response(
|
|
278
310
|
ctx,
|
|
279
|
-
user_id:
|
|
311
|
+
user_id: user_id,
|
|
280
312
|
provider_id: account["providerId"],
|
|
281
313
|
account_id: account["accountId"],
|
|
282
314
|
provider: provider
|
|
@@ -29,6 +29,7 @@ module BetterAuth
|
|
|
29
29
|
) do |ctx|
|
|
30
30
|
sender = ctx.context.options.email_verification[:send_verification_email]
|
|
31
31
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
|
|
32
|
+
ctx.context.token_link_base_url
|
|
32
33
|
|
|
33
34
|
body = normalize_hash(ctx.body)
|
|
34
35
|
email = body["email"].to_s.downcase
|
|
@@ -42,16 +43,37 @@ module BetterAuth
|
|
|
42
43
|
next ctx.json({status: true})
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
start = verification_email_monotonic_clock
|
|
45
47
|
found = ctx.context.internal_adapter.find_user_by_email(email)
|
|
46
48
|
if found && !found[:user]["emailVerified"]
|
|
47
|
-
|
|
49
|
+
begin
|
|
50
|
+
send_verification_email_payload(ctx, found[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
|
|
51
|
+
rescue => error
|
|
52
|
+
enforce_verification_email_floor(start)
|
|
53
|
+
raise error
|
|
54
|
+
end
|
|
48
55
|
else
|
|
49
56
|
create_email_verification_token(ctx, email)
|
|
50
57
|
end
|
|
58
|
+
enforce_verification_email_floor(start)
|
|
59
|
+
|
|
51
60
|
ctx.json({status: true})
|
|
52
61
|
end
|
|
53
62
|
end
|
|
54
63
|
|
|
64
|
+
def self.verification_email_monotonic_clock
|
|
65
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.verification_email_sleep(seconds)
|
|
69
|
+
sleep(seconds)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def self.enforce_verification_email_floor(start)
|
|
73
|
+
remaining = 0.5 - (verification_email_monotonic_clock - start)
|
|
74
|
+
verification_email_sleep(remaining) if remaining.positive?
|
|
75
|
+
end
|
|
76
|
+
|
|
55
77
|
def self.verify_email
|
|
56
78
|
Endpoint.new(
|
|
57
79
|
path: "/verify-email",
|
|
@@ -93,8 +115,10 @@ module BetterAuth
|
|
|
93
115
|
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
94
116
|
validate_callback_url!(ctx.context, callback_url)
|
|
95
117
|
payload = verify_email_token(ctx, token, callback_url)
|
|
118
|
+
change_email_token_identifier = validate_change_email_token!(ctx, token, payload, callback_url)
|
|
96
119
|
email = payload["email"].to_s.downcase
|
|
97
120
|
update_to = payload["updateTo"] || payload["update_to"]
|
|
121
|
+
preflight_follow_up_verification_token_link!(ctx, payload, update_to)
|
|
98
122
|
user_data = ctx.context.internal_adapter.find_user_by_email(email)
|
|
99
123
|
return redirect_or_error(ctx, callback_url, "user_not_found") unless user_data
|
|
100
124
|
|
|
@@ -103,22 +127,25 @@ module BetterAuth
|
|
|
103
127
|
session = current_session(ctx, allow_nil: true)
|
|
104
128
|
return redirect_or_error(ctx, callback_url, "invalid_user") if session && session[:user]["email"] != email
|
|
105
129
|
|
|
130
|
+
consume_change_email_token!(ctx, change_email_token_identifier, callback_url)
|
|
106
131
|
request_type = payload["requestType"] || payload["request_type"]
|
|
107
132
|
case request_type
|
|
108
133
|
when "change-email-confirmation"
|
|
109
134
|
send_change_email_verification_payload(ctx, user, update_to, callback_url)
|
|
110
135
|
next redirect_or_json(ctx, callback_url, {status: true})
|
|
111
136
|
when "change-email-verification"
|
|
137
|
+
session_data = email_change_session_data(ctx, session, user)
|
|
112
138
|
updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
|
|
113
139
|
updated_user = updated || user.merge("email" => update_to, "emailVerified" => true)
|
|
114
140
|
call_option(ctx.context.options.email_verification[:after_email_verification], updated_user, ctx.request)
|
|
115
|
-
|
|
141
|
+
Cookies.set_session_cookie(ctx, {session: session_data, user: updated_user})
|
|
116
142
|
next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated_user)})
|
|
117
143
|
else
|
|
144
|
+
session_data = email_change_session_data(ctx, session, user)
|
|
118
145
|
updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
|
|
119
146
|
updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
|
|
120
147
|
send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
|
|
121
|
-
|
|
148
|
+
Cookies.set_session_cookie(ctx, {session: session_data, user: updated_user})
|
|
122
149
|
next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
|
|
123
150
|
end
|
|
124
151
|
end
|
|
@@ -139,7 +166,7 @@ module BetterAuth
|
|
|
139
166
|
def self.send_verification_email_payload(ctx, user, callback_url)
|
|
140
167
|
token = create_email_verification_token(ctx, user["email"])
|
|
141
168
|
callback = URI.encode_www_form_component(callback_url || "/")
|
|
142
|
-
url = "#{ctx.context.
|
|
169
|
+
url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
|
|
143
170
|
ctx.context.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
|
|
144
171
|
end
|
|
145
172
|
|
|
@@ -148,11 +175,20 @@ module BetterAuth
|
|
|
148
175
|
return unless sender.respond_to?(:call)
|
|
149
176
|
|
|
150
177
|
token = create_email_verification_token(ctx, user["email"], update_to: update_to, extra: {"requestType" => "change-email-verification"})
|
|
178
|
+
register_change_email_token!(ctx, token)
|
|
151
179
|
callback = URI.encode_www_form_component(callback_url || "/")
|
|
152
|
-
url = "#{ctx.context.
|
|
180
|
+
url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
|
|
153
181
|
sender.call({user: user.merge("email" => update_to), url: url, token: token}, ctx.request)
|
|
154
182
|
end
|
|
155
183
|
|
|
184
|
+
def self.preflight_follow_up_verification_token_link!(ctx, payload, update_to)
|
|
185
|
+
return unless update_to
|
|
186
|
+
return unless ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
|
|
187
|
+
|
|
188
|
+
request_type = payload["requestType"] || payload["request_type"]
|
|
189
|
+
ctx.context.token_link_base_url unless request_type == "change-email-verification"
|
|
190
|
+
end
|
|
191
|
+
|
|
156
192
|
def self.create_email_verification_token(ctx, email, update_to: nil, extra: {})
|
|
157
193
|
payload = {"email" => email.to_s.downcase}.merge(extra)
|
|
158
194
|
payload["updateTo"] = update_to if update_to
|
|
@@ -168,6 +204,43 @@ module BetterAuth
|
|
|
168
204
|
redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["INVALID_TOKEN"], code: "INVALID_TOKEN")
|
|
169
205
|
end
|
|
170
206
|
|
|
207
|
+
# Ruby hardening adaptation of upstream PR #9519. This is intentionally
|
|
208
|
+
# stricter than the pinned Better Auth v1.6.23 behavior, and hashes the JWT
|
|
209
|
+
# before storage so the verification table never contains the bearer token.
|
|
210
|
+
def self.register_change_email_token!(ctx, token)
|
|
211
|
+
ctx.context.internal_adapter.create_verification_value(
|
|
212
|
+
identifier: change_email_token_identifier(token),
|
|
213
|
+
value: "issued",
|
|
214
|
+
expiresAt: Time.now + (ctx.context.options.email_verification[:expires_in] || 3600)
|
|
215
|
+
)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def self.validate_change_email_token!(ctx, token, payload, callback_url)
|
|
219
|
+
update_to = payload["updateTo"] || payload["update_to"]
|
|
220
|
+
request_type = payload["requestType"] || payload["request_type"]
|
|
221
|
+
return unless update_to && ["change-email-confirmation", "change-email-verification"].include?(request_type)
|
|
222
|
+
|
|
223
|
+
identifier = change_email_token_identifier(token)
|
|
224
|
+
verification = ctx.context.internal_adapter.find_verification_value(identifier)
|
|
225
|
+
unless verification && !expired_time?(verification["expiresAt"])
|
|
226
|
+
redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_ALREADY_USED"], code: "TOKEN_ALREADY_USED")
|
|
227
|
+
end
|
|
228
|
+
identifier
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def self.consume_change_email_token!(ctx, identifier, callback_url)
|
|
232
|
+
return unless identifier
|
|
233
|
+
|
|
234
|
+
verification = ctx.context.internal_adapter.consume_verification_value(identifier)
|
|
235
|
+
unless verification
|
|
236
|
+
redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_ALREADY_USED"], code: "TOKEN_ALREADY_USED")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.change_email_token_identifier(token)
|
|
241
|
+
"change-email:#{Crypto.sha256(token, encoding: :base64url)}"
|
|
242
|
+
end
|
|
243
|
+
|
|
171
244
|
def self.redirect_or_error(ctx, callback_url, error, code: nil)
|
|
172
245
|
if callback_url
|
|
173
246
|
separator = callback_url.include?("?") ? "&" : "?"
|
|
@@ -192,6 +265,16 @@ module BetterAuth
|
|
|
192
265
|
Cookies.set_session_cookie(ctx, {session: session_data, user: user})
|
|
193
266
|
end
|
|
194
267
|
|
|
268
|
+
def self.email_change_session_data(ctx, session, user)
|
|
269
|
+
return session[:session] if session && session[:user]["id"] == user["id"]
|
|
270
|
+
|
|
271
|
+
session_data = ctx.context.internal_adapter.create_session(user["id"])
|
|
272
|
+
unless session_data
|
|
273
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"])
|
|
274
|
+
end
|
|
275
|
+
session_data
|
|
276
|
+
end
|
|
277
|
+
|
|
195
278
|
def self.call_option(callback, user, request)
|
|
196
279
|
callback.call(user, request) if callback.respond_to?(:call)
|
|
197
280
|
end
|
|
@@ -43,6 +43,7 @@ module BetterAuth
|
|
|
43
43
|
unless sender.respond_to?(:call)
|
|
44
44
|
raise APIError.new("BAD_REQUEST", code: "RESET_PASSWORD_DISABLED", message: BASE_ERROR_CODES["RESET_PASSWORD_DISABLED"])
|
|
45
45
|
end
|
|
46
|
+
token_link_base_url = ctx.context.token_link_base_url
|
|
46
47
|
|
|
47
48
|
body = normalize_hash(ctx.body)
|
|
48
49
|
email = body["email"].to_s.downcase
|
|
@@ -64,7 +65,7 @@ module BetterAuth
|
|
|
64
65
|
)
|
|
65
66
|
|
|
66
67
|
callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
|
|
67
|
-
url = "#{
|
|
68
|
+
url = "#{token_link_base_url}/reset-password/#{token}?callbackURL=#{callback}"
|
|
68
69
|
begin
|
|
69
70
|
sender.call({user: found[:user], url: url, token: token}, ctx.request)
|
|
70
71
|
rescue => error
|
|
@@ -151,8 +152,8 @@ module BetterAuth
|
|
|
151
152
|
password = body["newPassword"] || body["new_password"]
|
|
152
153
|
validate_password_length!(password, ctx.context.options.email_and_password)
|
|
153
154
|
|
|
154
|
-
verification = ctx.context.internal_adapter.
|
|
155
|
-
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification
|
|
155
|
+
verification = ctx.context.internal_adapter.consume_verification_value("reset-password:#{token}")
|
|
156
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification
|
|
156
157
|
|
|
157
158
|
user_id = verification["value"]
|
|
158
159
|
hashed = hash_password(ctx, password)
|
|
@@ -162,13 +163,11 @@ module BetterAuth
|
|
|
162
163
|
else
|
|
163
164
|
ctx.context.internal_adapter.create_account(userId: user_id, providerId: "credential", accountId: user_id, password: hashed)
|
|
164
165
|
end
|
|
165
|
-
ctx.context.internal_adapter.delete_verification_value(verification["id"])
|
|
166
|
-
|
|
167
166
|
if (callback = ctx.context.options.email_and_password[:on_password_reset])
|
|
168
167
|
user = ctx.context.internal_adapter.find_user_by_id(user_id)
|
|
169
168
|
callback.call({user: user}, ctx.request) if user
|
|
170
169
|
end
|
|
171
|
-
ctx.context.internal_adapter.
|
|
170
|
+
ctx.context.internal_adapter.delete_user_sessions(user_id) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
|
|
172
171
|
|
|
173
172
|
ctx.json({status: true})
|
|
174
173
|
end
|
|
@@ -56,7 +56,7 @@ module BetterAuth
|
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
58
|
) do |ctx|
|
|
59
|
-
session = current_session(ctx)
|
|
59
|
+
session = current_session(ctx, sensitive: true, fresh: true)
|
|
60
60
|
sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
|
|
61
61
|
active = sessions
|
|
62
62
|
.map { |entry| stringify_keys(entry) }
|
|
@@ -88,6 +88,11 @@ module BetterAuth
|
|
|
88
88
|
|
|
89
89
|
update = body.merge("updatedAt" => Time.now)
|
|
90
90
|
updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
|
|
91
|
+
if !updated && Session.stateful?(ctx)
|
|
92
|
+
Cookies.delete_session_cookie(ctx)
|
|
93
|
+
raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"])
|
|
94
|
+
end
|
|
95
|
+
|
|
91
96
|
merged = session[:session].merge(updated || update)
|
|
92
97
|
Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
|
|
93
98
|
ctx.json({session: Schema.parse_output(ctx.context.options, "session", merged)})
|
|
@@ -144,7 +149,7 @@ module BetterAuth
|
|
|
144
149
|
}
|
|
145
150
|
) do |ctx|
|
|
146
151
|
session = current_session(ctx, sensitive: true)
|
|
147
|
-
ctx.context.internal_adapter.
|
|
152
|
+
ctx.context.internal_adapter.delete_user_sessions(session[:user]["id"])
|
|
148
153
|
Cookies.delete_session_cookie(ctx)
|
|
149
154
|
ctx.json({status: true})
|
|
150
155
|
end
|
|
@@ -50,6 +50,7 @@ module BetterAuth
|
|
|
50
50
|
remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
|
|
51
51
|
|
|
52
52
|
validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
|
|
53
|
+
preflight_sign_in_token_link!(ctx)
|
|
53
54
|
|
|
54
55
|
unless EMAIL_PATTERN.match?(email)
|
|
55
56
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
|
|
@@ -107,8 +108,16 @@ module BetterAuth
|
|
|
107
108
|
expires_in: verification[:expires_in] || 3600
|
|
108
109
|
)
|
|
109
110
|
callback = URI.encode_www_form_component(callback_url || "/")
|
|
110
|
-
url = "#{ctx.context.
|
|
111
|
+
url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
|
|
111
112
|
sender.call({user: user, url: url, token: token}, ctx.request)
|
|
112
113
|
end
|
|
114
|
+
|
|
115
|
+
def self.preflight_sign_in_token_link!(ctx)
|
|
116
|
+
verification = ctx.context.options.email_verification
|
|
117
|
+
password_config = ctx.context.options.email_and_password
|
|
118
|
+
if password_config[:require_email_verification] && verification[:send_on_sign_in] && verification[:send_verification_email].respond_to?(:call)
|
|
119
|
+
ctx.context.token_link_base_url
|
|
120
|
+
end
|
|
121
|
+
end
|
|
113
122
|
end
|
|
114
123
|
end
|
|
@@ -58,6 +58,7 @@ module BetterAuth
|
|
|
58
58
|
if email_config[:enabled] != true || email_config[:disable_sign_up]
|
|
59
59
|
raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_SIGN_UP_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_SIGN_UP_DISABLED"])
|
|
60
60
|
end
|
|
61
|
+
preflight_sign_up_token_link!(ctx)
|
|
61
62
|
|
|
62
63
|
body = normalize_hash(ctx.body)
|
|
63
64
|
name = body["name"].to_s
|
|
@@ -69,14 +70,16 @@ module BetterAuth
|
|
|
69
70
|
|
|
70
71
|
validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
|
|
71
72
|
validate_sign_up_input!(email, password, email_config)
|
|
73
|
+
reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
|
|
74
|
+
additional_user_fields = parse_sign_up_additional_user_fields(ctx, body.except(*reserved))
|
|
72
75
|
|
|
73
76
|
ctx.context.adapter.transaction do
|
|
74
77
|
existing = ctx.context.internal_adapter.find_user_by_email(email)
|
|
75
78
|
if existing
|
|
76
|
-
if email_config[:require_email_verification]
|
|
79
|
+
if email_config[:require_email_verification] || email_config[:auto_sign_in] == false
|
|
77
80
|
hash_password(ctx, password)
|
|
78
81
|
call_existing_sign_up_callback(ctx, email_config, existing)
|
|
79
|
-
synthetic_user = synthetic_sign_up_user(ctx,
|
|
82
|
+
synthetic_user = synthetic_sign_up_user(ctx, additional_user_fields, email, name, image)
|
|
80
83
|
next ctx.json({token: nil, user: Schema.parse_output(options, "user", synthetic_user)})
|
|
81
84
|
end
|
|
82
85
|
|
|
@@ -87,7 +90,7 @@ module BetterAuth
|
|
|
87
90
|
end
|
|
88
91
|
|
|
89
92
|
hashed_password = hash_password(ctx, password)
|
|
90
|
-
created_user = create_sign_up_user(ctx,
|
|
93
|
+
created_user = create_sign_up_user(ctx, additional_user_fields, email, name, image)
|
|
91
94
|
raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless created_user
|
|
92
95
|
|
|
93
96
|
ctx.context.internal_adapter.link_account(
|
|
@@ -144,9 +147,7 @@ module BetterAuth
|
|
|
144
147
|
raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
|
|
145
148
|
end
|
|
146
149
|
|
|
147
|
-
def self.create_sign_up_user(ctx,
|
|
148
|
-
reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
|
|
149
|
-
additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
|
|
150
|
+
def self.create_sign_up_user(ctx, additional, email, name, image)
|
|
150
151
|
ctx.context.internal_adapter.create_user(
|
|
151
152
|
additional.merge(
|
|
152
153
|
"email" => email.downcase,
|
|
@@ -175,7 +176,7 @@ module BetterAuth
|
|
|
175
176
|
end
|
|
176
177
|
end
|
|
177
178
|
|
|
178
|
-
def self.synthetic_sign_up_user(ctx,
|
|
179
|
+
def self.synthetic_sign_up_user(ctx, additional, email, name, image)
|
|
179
180
|
now = Time.now
|
|
180
181
|
core_fields = {
|
|
181
182
|
"id" => SecureRandom.hex(16),
|
|
@@ -186,8 +187,6 @@ module BetterAuth
|
|
|
186
187
|
"createdAt" => now,
|
|
187
188
|
"updatedAt" => now
|
|
188
189
|
}
|
|
189
|
-
reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
|
|
190
|
-
additional = synthetic_additional_user_fields(ctx, body.except(*reserved))
|
|
191
190
|
custom = ctx.context.options.email_and_password[:custom_synthetic_user]
|
|
192
191
|
return core_fields.merge(additional) unless custom.respond_to?(:call)
|
|
193
192
|
|
|
@@ -201,20 +200,37 @@ module BetterAuth
|
|
|
201
200
|
stringify_synthetic_user(custom.call(value))
|
|
202
201
|
end
|
|
203
202
|
|
|
204
|
-
def self.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
203
|
+
def self.parse_sign_up_additional_user_fields(ctx, data)
|
|
204
|
+
input = normalize_hash(data || {})
|
|
205
|
+
fields = Schema.auth_tables(ctx.context.options).fetch("user").fetch(:fields)
|
|
206
|
+
.except(*core_model_fields("user"))
|
|
207
|
+
|
|
208
|
+
fields.each_with_object({}) do |(field, attributes), result|
|
|
209
|
+
if input.key?(field)
|
|
210
|
+
if attributes[:input] == false
|
|
211
|
+
if attributes.key?(:default_value)
|
|
212
|
+
result[field] = resolve_default(attributes[:default_value])
|
|
213
|
+
elsif js_truthy_input?(input[field])
|
|
214
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is not allowed to be set")
|
|
215
|
+
end
|
|
216
|
+
next
|
|
217
|
+
end
|
|
213
218
|
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
result[field] = coerce_input_value(input[field], attributes)
|
|
220
|
+
elsif attributes.key?(:default_value)
|
|
221
|
+
result[field] = resolve_default(attributes[:default_value])
|
|
222
|
+
elsif attributes[:required]
|
|
223
|
+
raise APIError.new("BAD_REQUEST", message: "#{field} is required")
|
|
224
|
+
end
|
|
216
225
|
end
|
|
217
|
-
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def self.js_truthy_input?(value)
|
|
229
|
+
return false if value.nil? || value == false
|
|
230
|
+
return false if value.is_a?(Numeric) && (value.zero? || (value.respond_to?(:nan?) && value.nan?))
|
|
231
|
+
return false if value.is_a?(String) && value.empty?
|
|
232
|
+
|
|
233
|
+
true
|
|
218
234
|
end
|
|
219
235
|
|
|
220
236
|
def self.resolve_default(value)
|
|
@@ -242,10 +258,17 @@ module BetterAuth
|
|
|
242
258
|
expires_in: verification[:expires_in] || 3600
|
|
243
259
|
)
|
|
244
260
|
callback = URI.encode_www_form_component(callback_url || "/")
|
|
245
|
-
url = "#{ctx.context.
|
|
261
|
+
url = "#{ctx.context.token_link_base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
|
|
246
262
|
sender.call({user: user, url: url, token: token}, ctx.request)
|
|
247
263
|
end
|
|
248
264
|
|
|
265
|
+
def self.preflight_sign_up_token_link!(ctx)
|
|
266
|
+
verification = ctx.context.options.email_verification
|
|
267
|
+
password_config = ctx.context.options.email_and_password
|
|
268
|
+
send_on_sign_up = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification]
|
|
269
|
+
ctx.context.token_link_base_url if send_on_sign_up && verification[:send_verification_email].respond_to?(:call)
|
|
270
|
+
end
|
|
271
|
+
|
|
249
272
|
def self.session_overrides(ctx)
|
|
250
273
|
{
|
|
251
274
|
ipAddress: RequestIP.client_ip(ctx, ctx.context.options).to_s,
|