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
|
@@ -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(
|
|
@@ -63,6 +69,7 @@ module BetterAuth
|
|
|
63
69
|
schema: {
|
|
64
70
|
jwks: {
|
|
65
71
|
fields: {
|
|
72
|
+
id: {type: "string", required: true},
|
|
66
73
|
publicKey: {type: "string", required: true},
|
|
67
74
|
privateKey: {type: "string", required: true},
|
|
68
75
|
createdAt: {type: "date", required: true},
|
|
@@ -205,8 +212,8 @@ module BetterAuth
|
|
|
205
212
|
payload = stringify_payload(payload).dup
|
|
206
213
|
payload["iat"] ||= now
|
|
207
214
|
payload["exp"] ||= jwt_expiration(jwt_config[:expiration_time] || "15m", payload["iat"])
|
|
208
|
-
payload["iss"] ||= jwt_config[:issuer] || ctx.context.
|
|
209
|
-
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
|
|
210
217
|
|
|
211
218
|
return jwt_config[:sign].call(payload) if jwt_config[:sign].respond_to?(:call)
|
|
212
219
|
|
|
@@ -226,9 +233,9 @@ module BetterAuth
|
|
|
226
233
|
|
|
227
234
|
options = {
|
|
228
235
|
algorithm: key["alg"] || "RS256",
|
|
229
|
-
iss: config.dig(:jwt, :issuer) || ctx.context.
|
|
236
|
+
iss: config.dig(:jwt, :issuer) || ctx.context.canonical_base_url,
|
|
230
237
|
verify_iss: true,
|
|
231
|
-
aud: config.dig(:jwt, :audience) || ctx.context.
|
|
238
|
+
aud: config.dig(:jwt, :audience) || ctx.context.canonical_base_url,
|
|
232
239
|
verify_aud: true
|
|
233
240
|
}
|
|
234
241
|
decoded, = ::JWT.decode(token.to_s, JWT.public_key(key), true, options)
|
|
@@ -238,7 +245,7 @@ module BetterAuth
|
|
|
238
245
|
end
|
|
239
246
|
|
|
240
247
|
def latest_jwk(ctx, config)
|
|
241
|
-
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) }
|
|
242
249
|
end
|
|
243
250
|
|
|
244
251
|
def signing_jwk(ctx, config)
|
|
@@ -257,13 +264,21 @@ module BetterAuth
|
|
|
257
264
|
end
|
|
258
265
|
end
|
|
259
266
|
|
|
260
|
-
def all_jwks(ctx, config)
|
|
261
|
-
|
|
262
|
-
if
|
|
263
|
-
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
|
|
264
271
|
end
|
|
265
272
|
|
|
266
|
-
|
|
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
|
|
267
282
|
end
|
|
268
283
|
|
|
269
284
|
def verification_jwks(ctx, config)
|
|
@@ -279,9 +294,15 @@ module BetterAuth
|
|
|
279
294
|
payload = if fetcher.respond_to?(:call)
|
|
280
295
|
fetcher.call(url)
|
|
281
296
|
else
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
cached = config[:remote_jwks_cache] ||= JWT::RemoteJwksCache.new({})
|
|
298
|
+
entry = cached.entries[url.to_s]
|
|
299
|
+
if entry && entry[:expires_at] > Time.now
|
|
300
|
+
entry[:payload]
|
|
301
|
+
else
|
|
302
|
+
fetched = HTTPClient.get_json(url)
|
|
303
|
+
cached.entries[url.to_s] = {payload: fetched, expires_at: Time.now + 300} if fetched
|
|
304
|
+
fetched
|
|
305
|
+
end
|
|
285
306
|
end
|
|
286
307
|
keys = fetch_value(payload, "keys")
|
|
287
308
|
Array(keys).map { |entry| normalize_remote_jwk(entry) }
|
|
@@ -313,11 +334,15 @@ module BetterAuth
|
|
|
313
334
|
data.merge!(public_key_jwk_fields(public_key, alg))
|
|
314
335
|
data["expiresAt"] = Time.now + config.dig(:jwks, :rotation_interval).to_i if config.dig(:jwks, :rotation_interval)
|
|
315
336
|
|
|
316
|
-
if adapter && adapter[:create_jwk].respond_to?(:call)
|
|
317
|
-
|
|
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)
|
|
318
341
|
end
|
|
319
|
-
|
|
320
|
-
|
|
342
|
+
cache = config[:local_jwks_cache]
|
|
343
|
+
cache.keys = nil if cache
|
|
344
|
+
cache.expires_at = nil if cache
|
|
345
|
+
created
|
|
321
346
|
end
|
|
322
347
|
|
|
323
348
|
def public_jwk(key, _config)
|
|
@@ -441,8 +466,8 @@ module BetterAuth
|
|
|
441
466
|
payload = JSON.parse(Crypto.base64url_decode(payload_segment))
|
|
442
467
|
now = Time.now.to_i
|
|
443
468
|
return nil if payload["exp"] && payload["exp"].to_i <= now
|
|
444
|
-
issuer = config.dig(:jwt, :issuer) || ctx.context.
|
|
445
|
-
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
|
|
446
471
|
return nil if issuer && payload["iss"] != issuer
|
|
447
472
|
return nil if audience && Array(payload["aud"]).map(&:to_s).none?(audience.to_s)
|
|
448
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)
|