better_auth 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +92 -0
- data/LICENSE.md +1 -1
- data/README.md +142 -95
- data/lib/better_auth/adapters/base.rb +64 -1
- data/lib/better_auth/adapters/internal_adapter.rb +197 -11
- data/lib/better_auth/adapters/join_support.rb +10 -0
- data/lib/better_auth/adapters/memory.rb +303 -54
- data/lib/better_auth/adapters/mssql.rb +7 -3
- data/lib/better_auth/adapters/sql.rb +390 -63
- data/lib/better_auth/api.rb +3 -24
- data/lib/better_auth/configuration.rb +48 -47
- data/lib/better_auth/context.rb +43 -67
- data/lib/better_auth/cookies.rb +119 -15
- data/lib/better_auth/core.rb +0 -2
- data/lib/better_auth/crypto.rb +4 -3
- data/lib/better_auth/doctor.rb +108 -0
- data/lib/better_auth/endpoint.rb +88 -5
- data/lib/better_auth/error.rb +3 -1
- data/lib/better_auth/http_client.rb +46 -0
- data/lib/better_auth/logger.rb +1 -1
- data/lib/better_auth/middleware/origin_check.rb +9 -1
- data/lib/better_auth/migration_plan.rb +15 -0
- data/lib/better_auth/oauth2.rb +1 -1
- data/lib/better_auth/oauth_state.rb +147 -0
- data/lib/better_auth/plugin_loader.rb +220 -0
- data/lib/better_auth/plugins/access.rb +10 -13
- data/lib/better_auth/plugins/admin.rb +85 -29
- data/lib/better_auth/plugins/anonymous.rb +2 -0
- data/lib/better_auth/plugins/api_key.rb +4 -7
- data/lib/better_auth/plugins/bearer.rb +8 -22
- data/lib/better_auth/plugins/captcha.rb +24 -6
- data/lib/better_auth/plugins/device_authorization.rb +77 -5
- data/lib/better_auth/plugins/dub.rb +10 -2
- data/lib/better_auth/plugins/email_otp.rb +30 -47
- data/lib/better_auth/plugins/generic_oauth.rb +162 -140
- data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
- data/lib/better_auth/plugins/i18n.rb +215 -0
- data/lib/better_auth/plugins/jwt.rb +44 -19
- data/lib/better_auth/plugins/magic_link.rb +18 -21
- data/lib/better_auth/plugins/oauth_popup.rb +330 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
- data/lib/better_auth/plugins/oauth_provider.rb +4 -7
- data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
- data/lib/better_auth/plugins/one_tap.rb +78 -65
- data/lib/better_auth/plugins/one_time_token.rb +1 -4
- data/lib/better_auth/plugins/open_api.rb +77 -9
- data/lib/better_auth/plugins/organization.rb +546 -102
- data/lib/better_auth/plugins/passkey.rb +4 -7
- data/lib/better_auth/plugins/phone_number.rb +25 -27
- data/lib/better_auth/plugins/scim.rb +4 -7
- data/lib/better_auth/plugins/siwe.rb +126 -16
- data/lib/better_auth/plugins/sso.rb +4 -7
- data/lib/better_auth/plugins/stripe.rb +4 -7
- data/lib/better_auth/plugins/two_factor.rb +205 -19
- data/lib/better_auth/plugins/username.rb +56 -13
- data/lib/better_auth/plugins.rb +57 -0
- data/lib/better_auth/rate_limiter.rb +300 -133
- data/lib/better_auth/request_ip.rb +123 -13
- data/lib/better_auth/router.rb +3 -3
- data/lib/better_auth/routes/account.rb +44 -8
- data/lib/better_auth/routes/email_verification.rb +93 -6
- data/lib/better_auth/routes/password.rb +6 -6
- data/lib/better_auth/routes/session.rb +7 -2
- data/lib/better_auth/routes/sign_in.rb +10 -1
- data/lib/better_auth/routes/sign_up.rb +45 -22
- data/lib/better_auth/routes/social.rb +197 -54
- data/lib/better_auth/routes/user.rb +17 -10
- data/lib/better_auth/schema/sql.rb +189 -34
- data/lib/better_auth/schema.rb +159 -5
- data/lib/better_auth/session.rb +29 -10
- data/lib/better_auth/session_store.rb +1 -1
- data/lib/better_auth/social_providers/apple.rb +6 -1
- data/lib/better_auth/social_providers/atlassian.rb +1 -0
- data/lib/better_auth/social_providers/base.rb +45 -23
- data/lib/better_auth/social_providers/cognito.rb +75 -5
- data/lib/better_auth/social_providers/facebook.rb +95 -1
- data/lib/better_auth/social_providers/figma.rb +2 -0
- data/lib/better_auth/social_providers/github.rb +2 -0
- data/lib/better_auth/social_providers/google.rb +12 -1
- data/lib/better_auth/social_providers/line.rb +35 -1
- data/lib/better_auth/social_providers/linear.rb +1 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
- data/lib/better_auth/social_providers/naver.rb +24 -1
- data/lib/better_auth/social_providers/notion.rb +1 -0
- data/lib/better_auth/social_providers/paybin.rb +25 -1
- data/lib/better_auth/social_providers/paypal.rb +118 -2
- data/lib/better_auth/social_providers/reddit.rb +25 -4
- data/lib/better_auth/social_providers/salesforce.rb +1 -0
- data/lib/better_auth/social_providers/twitch.rb +24 -1
- data/lib/better_auth/social_providers/twitter.rb +36 -1
- data/lib/better_auth/social_providers/vk.rb +34 -1
- data/lib/better_auth/social_providers/wechat.rb +6 -4
- data/lib/better_auth/sql_migration.rb +566 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +14 -41
- metadata +14 -18
- data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
- data/lib/better_auth/plugins/mcp/config.rb +0 -51
- data/lib/better_auth/plugins/mcp/consent.rb +0 -31
- data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
- data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
- data/lib/better_auth/plugins/mcp/registration.rb +0 -31
- data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
- data/lib/better_auth/plugins/mcp/schema.rb +0 -91
- data/lib/better_auth/plugins/mcp/token.rb +0 -108
- data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
- data/lib/better_auth/plugins/mcp.rb +0 -215
- data/lib/better_auth/plugins/oidc_provider.rb +0 -710
|
@@ -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
|
|
@@ -118,6 +118,8 @@ module BetterAuth
|
|
|
118
118
|
) do |ctx|
|
|
119
119
|
session = current_session(ctx, allow_nil: true)
|
|
120
120
|
body = normalize_hash(ctx.body)
|
|
121
|
+
raise APIError.new("UNAUTHORIZED") if ctx.request && !session
|
|
122
|
+
|
|
121
123
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
122
124
|
raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
|
|
123
125
|
|
|
@@ -174,6 +176,8 @@ module BetterAuth
|
|
|
174
176
|
) do |ctx|
|
|
175
177
|
session = current_session(ctx, allow_nil: true)
|
|
176
178
|
body = normalize_hash(ctx.body)
|
|
179
|
+
raise APIError.new("UNAUTHORIZED") if ctx.request && !session
|
|
180
|
+
|
|
177
181
|
user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
|
|
178
182
|
raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
|
|
179
183
|
|
|
@@ -226,6 +230,18 @@ module BetterAuth
|
|
|
226
230
|
in: "query",
|
|
227
231
|
required: false,
|
|
228
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"}
|
|
229
245
|
}
|
|
230
246
|
],
|
|
231
247
|
responses: {
|
|
@@ -256,23 +272,43 @@ module BetterAuth
|
|
|
256
272
|
}
|
|
257
273
|
}
|
|
258
274
|
) do |ctx|
|
|
259
|
-
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
|
+
|
|
260
281
|
account_id = fetch_value(ctx.query, "accountId")
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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)
|
|
264
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
|
+
)
|
|
293
|
+
end
|
|
294
|
+
matching_accounts.first
|
|
265
295
|
else
|
|
266
|
-
account_cookie(ctx,
|
|
296
|
+
account_cookie(ctx, provider_id, nil, user_id)
|
|
267
297
|
end
|
|
268
|
-
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
|
|
269
299
|
|
|
270
300
|
provider = social_provider(ctx.context, account["providerId"])
|
|
271
|
-
|
|
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
|
|
272
308
|
|
|
273
309
|
tokens = access_token_response(
|
|
274
310
|
ctx,
|
|
275
|
-
user_id:
|
|
311
|
+
user_id: user_id,
|
|
276
312
|
provider_id: account["providerId"],
|
|
277
313
|
account_id: account["accountId"],
|
|
278
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?("?") ? "&" : "?"
|
|
@@ -184,10 +257,24 @@ module BetterAuth
|
|
|
184
257
|
|
|
185
258
|
def self.set_verified_session_cookie(ctx, user)
|
|
186
259
|
session = current_session(ctx, allow_nil: true)
|
|
187
|
-
session_data = session
|
|
260
|
+
session_data = if session && session[:user]["id"] == user["id"]
|
|
261
|
+
session[:session]
|
|
262
|
+
else
|
|
263
|
+
ctx.context.internal_adapter.create_session(user["id"])
|
|
264
|
+
end
|
|
188
265
|
Cookies.set_session_cookie(ctx, {session: session_data, user: user})
|
|
189
266
|
end
|
|
190
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
|
+
|
|
191
278
|
def self.call_option(callback, user, request)
|
|
192
279
|
callback.call(user, request) if callback.respond_to?(:call)
|
|
193
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
|
|
@@ -179,6 +178,7 @@ module BetterAuth
|
|
|
179
178
|
path: "/verify-password",
|
|
180
179
|
method: "POST",
|
|
181
180
|
metadata: {
|
|
181
|
+
scope: "server",
|
|
182
182
|
openapi: {
|
|
183
183
|
operationId: "verifyPassword",
|
|
184
184
|
description: "Verify the current user's password",
|
|
@@ -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
|