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
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BetterAuth
|
|
4
|
+
module Plugins
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def i18n(options = {})
|
|
8
|
+
config = normalize_i18n_options(options)
|
|
9
|
+
validate_i18n_translations!(config)
|
|
10
|
+
|
|
11
|
+
Plugin.new(
|
|
12
|
+
id: "i18n",
|
|
13
|
+
hooks: {
|
|
14
|
+
after: [
|
|
15
|
+
{
|
|
16
|
+
matcher: ->(_ctx) { true },
|
|
17
|
+
handler: ->(ctx) { apply_i18n_translation(ctx, config) }
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
options: config
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def normalize_i18n_options(options)
|
|
26
|
+
return {} unless options.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
translations = normalize_i18n_translations(fetch_i18n_option(options, :translations))
|
|
29
|
+
default_locale = fetch_i18n_option(options, :default_locale)
|
|
30
|
+
detection = normalize_i18n_detection(fetch_i18n_option(options, :detection))
|
|
31
|
+
locale_cookie = fetch_i18n_option(options, :locale_cookie) || "locale"
|
|
32
|
+
user_locale_field = fetch_i18n_option(options, :user_locale_field) || "locale"
|
|
33
|
+
get_locale = fetch_i18n_option(options, :get_locale)
|
|
34
|
+
available_locales = translations.keys
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
translations: translations,
|
|
38
|
+
default_locale: resolve_i18n_default_locale(default_locale, available_locales),
|
|
39
|
+
detection: detection,
|
|
40
|
+
locale_cookie: locale_cookie.to_s,
|
|
41
|
+
user_locale_field: user_locale_field.to_s,
|
|
42
|
+
get_locale: get_locale
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def fetch_i18n_option(options, name)
|
|
47
|
+
snake = name.to_s
|
|
48
|
+
camel = snake.split("_").map.with_index { |part, index| index.zero? ? part : part.capitalize }.join
|
|
49
|
+
options[snake.to_sym] || options[snake] || options[camel.to_sym] || options[camel]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def normalize_i18n_translations(translations)
|
|
53
|
+
return {} unless translations.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
translations.each_with_object({}) do |(locale, codes), result|
|
|
56
|
+
locale_key = locale.to_s
|
|
57
|
+
next if locale_key.empty?
|
|
58
|
+
|
|
59
|
+
normalized_codes = {}
|
|
60
|
+
next unless codes.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
codes.each do |(code, message)|
|
|
63
|
+
normalized_codes[code.to_s.tr("-", "_").upcase] = message
|
|
64
|
+
end
|
|
65
|
+
result[locale_key] = normalized_codes
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def normalize_i18n_detection(detection)
|
|
70
|
+
return ["header"] if detection.nil?
|
|
71
|
+
|
|
72
|
+
Array(detection).map { |strategy| strategy.to_s.downcase }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_i18n_translations!(config)
|
|
76
|
+
translations = config[:translations]
|
|
77
|
+
if translations.nil? || translations.empty?
|
|
78
|
+
raise BetterAuth::Error, "i18n plugin: translations object is empty"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_i18n_default_locale(explicit, available_locales)
|
|
83
|
+
explicit_locale = explicit.to_s
|
|
84
|
+
return explicit_locale if explicit && available_locales.include?(explicit_locale)
|
|
85
|
+
return "en" if available_locales.include?("en")
|
|
86
|
+
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def parse_accept_language(header)
|
|
91
|
+
return [] if header.nil? || header.to_s.empty?
|
|
92
|
+
|
|
93
|
+
header.to_s
|
|
94
|
+
.split(",")
|
|
95
|
+
.map do |part|
|
|
96
|
+
locale_str, quality = part.strip.split(";", 2)
|
|
97
|
+
q_string = quality ? quality.strip.sub(/\Aq=/i, "") : "1"
|
|
98
|
+
q = Float(q_string)
|
|
99
|
+
locale = locale_str.to_s.strip.split("-").first.to_s
|
|
100
|
+
{locale: locale, q: q}
|
|
101
|
+
end
|
|
102
|
+
.select { |item| !item[:locale].empty? }
|
|
103
|
+
.sort_by { |item| -item[:q] }
|
|
104
|
+
.map { |item| item[:locale] }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def detect_i18n_locale(ctx, config)
|
|
108
|
+
available_locales = config[:translations].keys
|
|
109
|
+
|
|
110
|
+
config[:detection].each do |strategy|
|
|
111
|
+
locale = case strategy
|
|
112
|
+
when "header"
|
|
113
|
+
detect_locale_from_header(ctx, available_locales)
|
|
114
|
+
when "cookie"
|
|
115
|
+
detect_locale_from_cookie(ctx, config, available_locales)
|
|
116
|
+
when "session"
|
|
117
|
+
detect_locale_from_session(ctx, config, available_locales)
|
|
118
|
+
when "callback"
|
|
119
|
+
detect_locale_from_callback(ctx, config, available_locales)
|
|
120
|
+
end
|
|
121
|
+
return locale if locale && available_locales.include?(locale)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
config[:default_locale]
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def detect_locale_from_header(ctx, available_locales)
|
|
128
|
+
parse_accept_language(ctx.headers["accept-language"]).find do |locale|
|
|
129
|
+
available_locales.include?(locale)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def detect_locale_from_cookie(ctx, config, available_locales)
|
|
134
|
+
value = ctx.get_cookie(config[:locale_cookie])
|
|
135
|
+
locale = value.to_s
|
|
136
|
+
locale if value && available_locales.include?(locale)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def detect_locale_from_session(ctx, config, available_locales)
|
|
140
|
+
session = ctx.context.current_session || ctx.context.new_session
|
|
141
|
+
return nil unless session
|
|
142
|
+
|
|
143
|
+
user = session[:user] || session["user"]
|
|
144
|
+
return nil unless user.is_a?(Hash)
|
|
145
|
+
|
|
146
|
+
locale = fetch_value(user, config[:user_locale_field])
|
|
147
|
+
locale = locale.to_s
|
|
148
|
+
locale if locale && !locale.empty? && available_locales.include?(locale)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def detect_locale_from_callback(ctx, config, available_locales)
|
|
152
|
+
callback = config[:get_locale]
|
|
153
|
+
return nil unless callback.respond_to?(:call)
|
|
154
|
+
|
|
155
|
+
locale = callback.call(ctx)
|
|
156
|
+
locale = locale.to_s
|
|
157
|
+
locale if locale && !locale.empty? && available_locales.include?(locale)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def apply_i18n_translation(ctx, config)
|
|
161
|
+
error = ctx.returned
|
|
162
|
+
return nil unless error.is_a?(BetterAuth::APIError)
|
|
163
|
+
|
|
164
|
+
error_code = resolve_i18n_error_code(error, ctx)
|
|
165
|
+
return nil unless error_code
|
|
166
|
+
|
|
167
|
+
locale = detect_i18n_locale(ctx, config)
|
|
168
|
+
translation = config.dig(:translations, locale, error_code)
|
|
169
|
+
return nil unless translation
|
|
170
|
+
|
|
171
|
+
raise BetterAuth::APIError.new(
|
|
172
|
+
error.status,
|
|
173
|
+
message: translation,
|
|
174
|
+
headers: error.headers,
|
|
175
|
+
code: error.code,
|
|
176
|
+
body: {
|
|
177
|
+
code: error_code,
|
|
178
|
+
message: translation,
|
|
179
|
+
originalMessage: error.message
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def resolve_i18n_error_code(error, ctx)
|
|
185
|
+
body = error.body
|
|
186
|
+
if body.is_a?(Hash)
|
|
187
|
+
code = body[:code] || body["code"]
|
|
188
|
+
return code.to_s.tr("-", "_").upcase if code
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
unless error.code.to_s.upcase == error.status.to_s.upcase
|
|
192
|
+
return error.code.to_s.tr("-", "_").upcase
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
reverse_lookup_i18n_error_code(error.message, ctx)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def reverse_lookup_i18n_error_code(message, ctx)
|
|
199
|
+
merged_i18n_error_catalog(ctx).find { |_code, text| text == message }&.first
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def merged_i18n_error_catalog(ctx)
|
|
203
|
+
catalog = BetterAuth::BASE_ERROR_CODES.dup
|
|
204
|
+
ctx.context.options.plugins.each do |plugin|
|
|
205
|
+
next unless plugin.respond_to?(:error_codes)
|
|
206
|
+
|
|
207
|
+
codes = plugin.error_codes
|
|
208
|
+
next if codes.nil? || codes.empty?
|
|
209
|
+
|
|
210
|
+
catalog.merge!(codes)
|
|
211
|
+
end
|
|
212
|
+
catalog
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -8,6 +8,8 @@ module BetterAuth
|
|
|
8
8
|
module Plugins
|
|
9
9
|
module JWT
|
|
10
10
|
SUPPORTED_ALGORITHMS = %w[EdDSA RS256 PS256 ES256 ES512].freeze
|
|
11
|
+
LocalJwksCache = Struct.new(:keys, :expires_at)
|
|
12
|
+
RemoteJwksCache = Struct.new(:entries)
|
|
11
13
|
|
|
12
14
|
module_function
|
|
13
15
|
|
|
@@ -42,6 +44,10 @@ module BetterAuth
|
|
|
42
44
|
def jwt(options = {})
|
|
43
45
|
config = normalize_hash(options)
|
|
44
46
|
validate_jwt_options!(config)
|
|
47
|
+
# Mutable caches are owned by this plugin configuration, which is scoped
|
|
48
|
+
# to one auth instance even when endpoint overrides are merged.
|
|
49
|
+
config[:local_jwks_cache] = JWT::LocalJwksCache.new
|
|
50
|
+
config[:remote_jwks_cache] = JWT::RemoteJwksCache.new({})
|
|
45
51
|
jwks_path = config.dig(:jwks, :jwks_path) || "/jwks"
|
|
46
52
|
|
|
47
53
|
Plugin.new(
|
|
@@ -206,8 +212,8 @@ module BetterAuth
|
|
|
206
212
|
payload = stringify_payload(payload).dup
|
|
207
213
|
payload["iat"] ||= now
|
|
208
214
|
payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"])
|
|
209
|
-
payload["iss"] ||= jwt_config[:issuer] || ctx.context.
|
|
210
|
-
payload["aud"] ||= jwt_config[:audience] || ctx.context.
|
|
215
|
+
payload["iss"] ||= jwt_config[:issuer] || ctx.context.canonical_base_url
|
|
216
|
+
payload["aud"] ||= jwt_config[:audience] || ctx.context.canonical_base_url
|
|
211
217
|
|
|
212
218
|
return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call)
|
|
213
219
|
|
|
@@ -227,9 +233,9 @@ module BetterAuth
|
|
|
227
233
|
|
|
228
234
|
options = {
|
|
229
235
|
algorithm: key["alg"] || "RS256",
|
|
230
|
-
iss: config.dig(:jwt, :issuer) || ctx.context.
|
|
236
|
+
iss: config.dig(:jwt, :issuer) || ctx.context.canonical_base_url,
|
|
231
237
|
verify_iss: true,
|
|
232
|
-
aud: config.dig(:jwt, :audience) || ctx.context.
|
|
238
|
+
aud: config.dig(:jwt, :audience) || ctx.context.canonical_base_url,
|
|
233
239
|
verify_aud: true
|
|
234
240
|
}
|
|
235
241
|
decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, options)
|
|
@@ -239,7 +245,7 @@ module BetterAuth
|
|
|
239
245
|
end
|
|
240
246
|
|
|
241
247
|
def latest_jwk(ctx, config)
|
|
242
|
-
all_jwks(ctx, config).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) }
|
|
248
|
+
all_jwks(ctx, config, force_refresh: true).max_by { |entry| normalize_time(entry["createdAt"]) || Time.at(0) }
|
|
243
249
|
end
|
|
244
250
|
|
|
245
251
|
def signing_jwk(ctx, config)
|
|
@@ -258,13 +264,21 @@ module BetterAuth
|
|
|
258
264
|
end
|
|
259
265
|
end
|
|
260
266
|
|
|
261
|
-
def all_jwks(ctx, config)
|
|
262
|
-
|
|
263
|
-
if
|
|
264
|
-
return
|
|
267
|
+
def all_jwks(ctx, config, force_refresh: false)
|
|
268
|
+
cache = config[:local_jwks_cache] ||= JWT::LocalJwksCache.new
|
|
269
|
+
if !force_refresh && cache.expires_at && cache.expires_at > Time.now
|
|
270
|
+
return cache.keys
|
|
265
271
|
end
|
|
266
272
|
|
|
267
|
-
|
|
273
|
+
adapter = config[:adapter]
|
|
274
|
+
keys = if adapter && adapter[:get_jwks].respond_to?(:call)
|
|
275
|
+
Array(adapter[:get_jwks].call(ctx)).map { |entry| stringify_payload(entry) }
|
|
276
|
+
else
|
|
277
|
+
ctx.context.adapter.find_many(model: "jwks")
|
|
278
|
+
end
|
|
279
|
+
cache.keys = keys
|
|
280
|
+
cache.expires_at = Time.now + 300
|
|
281
|
+
keys
|
|
268
282
|
end
|
|
269
283
|
|
|
270
284
|
def verification_jwks(ctx, config)
|
|
@@ -280,13 +294,13 @@ module BetterAuth
|
|
|
280
294
|
payload = if fetcher.respond_to?(:call)
|
|
281
295
|
fetcher.call(url)
|
|
282
296
|
else
|
|
283
|
-
cached =
|
|
284
|
-
entry = cached[url.to_s]
|
|
297
|
+
cached = config[:remote_jwks_cache] ||= JWT::RemoteJwksCache.new({})
|
|
298
|
+
entry = cached.entries[url.to_s]
|
|
285
299
|
if entry && entry[:expires_at] > Time.now
|
|
286
300
|
entry[:payload]
|
|
287
301
|
else
|
|
288
302
|
fetched = HTTPClient.get_json(url)
|
|
289
|
-
cached[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
|
|
303
|
+
cached.entries[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
|
|
290
304
|
fetched
|
|
291
305
|
end
|
|
292
306
|
end
|
|
@@ -320,11 +334,15 @@ module BetterAuth
|
|
|
320
334
|
data.merge!(public_key_jwk_fields(public_key, alg))
|
|
321
335
|
data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval)
|
|
322
336
|
|
|
323
|
-
if adapter && adapter[:create_jwk].respond_to?(:call)
|
|
324
|
-
|
|
337
|
+
created = if adapter && adapter[:create_jwk].respond_to?(:call)
|
|
338
|
+
stringify_payload(adapter[:create_jwk].call(data, ctx))
|
|
339
|
+
else
|
|
340
|
+
ctx.context.adapter.create(model: "jwks", data: data, force_allow_id: true)
|
|
325
341
|
end
|
|
326
|
-
|
|
327
|
-
|
|
342
|
+
cache = config[:local_jwks_cache]
|
|
343
|
+
cache.keys = nil if cache
|
|
344
|
+
cache.expires_at = nil if cache
|
|
345
|
+
created
|
|
328
346
|
end
|
|
329
347
|
|
|
330
348
|
def public_jwk(key, _config)
|
|
@@ -448,8 +466,8 @@ module BetterAuth
|
|
|
448
466
|
payload = JSON.parse(Crypto.base64url_decode(payload_segment))
|
|
449
467
|
now = Time.now.to_i
|
|
450
468
|
return nil if payload["exp"] && payload["exp"].to_i <= now
|
|
451
|
-
issuer = config.dig(:jwt, :issuer) || ctx.context.
|
|
452
|
-
audience = config.dig(:jwt, :audience) || ctx.context.
|
|
469
|
+
issuer = config.dig(:jwt, :issuer) || ctx.context.canonical_base_url
|
|
470
|
+
audience = config.dig(:jwt, :audience) || ctx.context.canonical_base_url
|
|
453
471
|
return nil if issuer && payload["iss"] != issuer
|
|
454
472
|
return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s)
|
|
455
473
|
return nil unless jwt_payload_valid?(payload)
|
|
@@ -9,6 +9,9 @@ module BetterAuth
|
|
|
9
9
|
|
|
10
10
|
def magic_link(options = {})
|
|
11
11
|
config = {store_token: "plain", allowed_attempts: 1}.merge(normalize_hash(options))
|
|
12
|
+
if options.key?(:allowed_attempts) && options[:allowed_attempts] != 1
|
|
13
|
+
warn "[better-auth/magic-link] `allowed_attempts` is ignored: tokens are consumed atomically on the first verification call. Any value other than `1` has no effect; remove the option to silence this warning."
|
|
14
|
+
end
|
|
12
15
|
|
|
13
16
|
Plugin.new(
|
|
14
17
|
id: "magic-link",
|
|
@@ -54,6 +57,9 @@ module BetterAuth
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
) do |ctx|
|
|
60
|
+
sender = config[:send_magic_link]
|
|
61
|
+
link_base_url = ctx.context.base_url
|
|
62
|
+
link_base_url = ctx.context.token_link_base_url if sender.respond_to?(:call)
|
|
57
63
|
body = normalize_hash(ctx.body)
|
|
58
64
|
email = body[:email].to_s.downcase
|
|
59
65
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"]) unless Routes::EMAIL_PATTERN.match?(email)
|
|
@@ -66,8 +72,7 @@ module BetterAuth
|
|
|
66
72
|
expiresAt: Time.now + (config[:expires_in] || 60 * 5).to_i
|
|
67
73
|
)
|
|
68
74
|
|
|
69
|
-
link = magic_link_url(
|
|
70
|
-
sender = config[:send_magic_link]
|
|
75
|
+
link = magic_link_url(link_base_url, token, body)
|
|
71
76
|
data = {email: email, url: link, token: token}
|
|
72
77
|
data[:metadata] = body[:metadata] if body.key?(:metadata)
|
|
73
78
|
sender.call(data, ctx) if sender.respond_to?(:call)
|
|
@@ -129,26 +134,12 @@ module BetterAuth
|
|
|
129
134
|
end
|
|
130
135
|
|
|
131
136
|
stored_token = store_magic_link_token(token, config)
|
|
132
|
-
verification = ctx.context.internal_adapter.
|
|
137
|
+
verification = ctx.context.internal_adapter.consume_verification_value(stored_token)
|
|
133
138
|
redirect_with_error.call("INVALID_TOKEN") unless verification
|
|
134
139
|
|
|
135
|
-
if Routes.expired_time?(verification["expiresAt"])
|
|
136
|
-
ctx.context.internal_adapter.delete_verification_value(verification["id"])
|
|
137
|
-
redirect_with_error.call("EXPIRED_TOKEN")
|
|
138
|
-
end
|
|
139
|
-
|
|
140
140
|
payload = JSON.parse(verification["value"])
|
|
141
141
|
email = payload.fetch("email").to_s.downcase
|
|
142
142
|
name = payload["name"]
|
|
143
|
-
attempt = payload["attempt"].to_i
|
|
144
|
-
if magic_link_attempts_exceeded?(attempt, config)
|
|
145
|
-
ctx.context.internal_adapter.delete_verification_value(verification["id"])
|
|
146
|
-
redirect_with_error.call("ATTEMPTS_EXCEEDED")
|
|
147
|
-
end
|
|
148
|
-
ctx.context.internal_adapter.update_verification_value(
|
|
149
|
-
verification["id"],
|
|
150
|
-
value: JSON.generate(payload.merge("attempt" => attempt + 1))
|
|
151
|
-
)
|
|
152
143
|
found = ctx.context.internal_adapter.find_user_by_email(email)
|
|
153
144
|
user = found && found[:user]
|
|
154
145
|
new_user = false
|
|
@@ -167,7 +158,13 @@ module BetterAuth
|
|
|
167
158
|
end
|
|
168
159
|
|
|
169
160
|
unless user["emailVerified"]
|
|
170
|
-
user = ctx.context.
|
|
161
|
+
user = ctx.context.adapter.transaction do
|
|
162
|
+
ctx.context.internal_adapter.revoke_unproven_account_access(user["id"])
|
|
163
|
+
updated = ctx.context.internal_adapter.update_user(user["id"], emailVerified: true)
|
|
164
|
+
raise Error, "Failed to verify magic-link user" unless updated
|
|
165
|
+
|
|
166
|
+
updated
|
|
167
|
+
end
|
|
171
168
|
end
|
|
172
169
|
|
|
173
170
|
session = ctx.context.internal_adapter.create_session(user["id"])
|
|
@@ -192,7 +189,7 @@ module BetterAuth
|
|
|
192
189
|
generator = config[:generate_token]
|
|
193
190
|
return generator.call(email) if generator.respond_to?(:call)
|
|
194
191
|
|
|
195
|
-
|
|
192
|
+
Crypto.random_string(32, alphabet: Crypto::ALPHABETIC_ALPHABET)
|
|
196
193
|
end
|
|
197
194
|
|
|
198
195
|
def magic_link_attempts_exceeded?(attempt, config)
|
|
@@ -214,14 +211,14 @@ module BetterAuth
|
|
|
214
211
|
token
|
|
215
212
|
end
|
|
216
213
|
|
|
217
|
-
def magic_link_url(
|
|
214
|
+
def magic_link_url(base_url, token, body)
|
|
218
215
|
params = {
|
|
219
216
|
token: token,
|
|
220
217
|
callbackURL: body[:callback_url] || "/"
|
|
221
218
|
}
|
|
222
219
|
params[:newUserCallbackURL] = body[:new_user_callback_url] if body[:new_user_callback_url]
|
|
223
220
|
params[:errorCallbackURL] = body[:error_callback_url] if body[:error_callback_url]
|
|
224
|
-
"#{
|
|
221
|
+
"#{base_url}/magic-link/verify?#{URI.encode_www_form(params)}"
|
|
225
222
|
end
|
|
226
223
|
|
|
227
224
|
def validate_magic_link_callback!(ctx, value, label)
|