better_auth 0.1.1 → 0.2.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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +106 -16
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +439 -0
  6. data/lib/better_auth/adapters/memory.rb +232 -0
  7. data/lib/better_auth/adapters/mongodb.rb +369 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +425 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +210 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +129 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +348 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +990 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +215 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +365 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +108 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +164 -0
  75. data/lib/better_auth/routes/session.rb +137 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +145 -0
  79. data/lib/better_auth/routes/social.rb +188 -0
  80. data/lib/better_auth/routes/user.rb +193 -0
  81. data/lib/better_auth/schema/sql.rb +191 -0
  82. data/lib/better_auth/schema.rb +275 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +55 -0
  86. data/lib/better_auth/social_providers/base.rb +67 -0
  87. data/lib/better_auth/social_providers/discord.rb +59 -0
  88. data/lib/better_auth/social_providers/github.rb +59 -0
  89. data/lib/better_auth/social_providers/gitlab.rb +54 -0
  90. data/lib/better_auth/social_providers/google.rb +65 -0
  91. data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
  92. data/lib/better_auth/social_providers.rb +9 -0
  93. data/lib/better_auth/version.rb +1 -1
  94. data/lib/better_auth.rb +87 -2
  95. metadata +218 -21
  96. data/.ruby-version +0 -1
  97. data/.standard.yml +0 -12
  98. data/.vscode/settings.json +0 -22
  99. data/AGENTS.md +0 -50
  100. data/CLAUDE.md +0 -1
  101. data/CODE_OF_CONDUCT.md +0 -173
  102. data/CONTRIBUTING.md +0 -187
  103. data/Gemfile +0 -12
  104. data/Makefile +0 -207
  105. data/Rakefile +0 -25
  106. data/SECURITY.md +0 -28
  107. data/docker-compose.yml +0 -63
@@ -0,0 +1,365 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "rack/request"
6
+
7
+ module BetterAuth
8
+ class Router
9
+ attr_reader :context, :endpoints, :rate_limiter
10
+
11
+ def initialize(context, endpoints, rate_limiter: RateLimiter.new)
12
+ @context = context
13
+ @endpoints = endpoints
14
+ @rate_limiter = rate_limiter
15
+ @origin_check = Middleware::OriginCheck.new
16
+ end
17
+
18
+ def self.check_endpoint_conflicts(options, logger)
19
+ registry = Hash.new { |hash, key| hash[key] = [] }
20
+ options.plugins.each do |plugin|
21
+ plugin.fetch(:endpoints, {}).each do |key, endpoint|
22
+ next unless endpoint.respond_to?(:path) && endpoint.path
23
+
24
+ registry[endpoint.path] << {
25
+ plugin_id: plugin[:id].to_s,
26
+ endpoint_key: key.to_s,
27
+ methods: endpoint.methods
28
+ }
29
+ end
30
+ end
31
+
32
+ conflicts = registry.filter_map do |path, entries|
33
+ conflict_methods = conflicting_methods(entries)
34
+ next if conflict_methods.empty?
35
+
36
+ {
37
+ path: path,
38
+ plugins: entries.map { |entry| entry[:plugin_id] }.uniq,
39
+ methods: conflict_methods
40
+ }
41
+ end
42
+
43
+ return if conflicts.empty?
44
+
45
+ message = "Endpoint path conflicts detected! Multiple plugins are trying to use the same endpoint paths with conflicting HTTP methods:\n"
46
+ message += conflicts.map do |conflict|
47
+ " - \"#{conflict[:path]}\" [#{conflict[:methods].join(", ")}] used by plugins: #{conflict[:plugins].join(", ")}"
48
+ end.join("\n")
49
+ log(logger, :error, message)
50
+ end
51
+
52
+ def call(env)
53
+ request = Rack::Request.new(env)
54
+ context.prepare_for_request!(request) if context.respond_to?(:prepare_for_request!)
55
+
56
+ route_path = route_path_for(request.path_info)
57
+ return not_found unless route_path
58
+
59
+ query = parse_query(request)
60
+ endpoint, params, allowed_methods = find_endpoint(route_path, request.request_method)
61
+ return run_on_response_chain(not_found) unless endpoint
62
+ return run_on_response_chain(method_not_allowed(allowed_methods)) unless endpoint.matches_method?(request.request_method)
63
+ return run_on_response_chain(unsupported_media_type) unless allowed_media_type?(request, endpoint)
64
+
65
+ body = parse_body(request)
66
+ endpoint_context = build_endpoint_context(request, route_path, query, body, params)
67
+
68
+ response = @origin_check.call(endpoint_context)
69
+ return run_on_response_chain(response) if response
70
+
71
+ response = run_plugin_middlewares(endpoint_context)
72
+ return run_on_response_chain(response) if response
73
+
74
+ return run_on_response_chain(not_found) if disabled_path?(route_path)
75
+
76
+ request = run_on_request_chain(request)
77
+ return run_on_response_chain(request) if rack_response?(request)
78
+
79
+ response = rate_limiter.call(request, context, route_path)
80
+ return run_on_response_chain(response) if response
81
+
82
+ endpoint_context = rebuild_endpoint_context(endpoint_context, request, route_path, params)
83
+ result = API.new(context, endpoints).execute(endpoint, endpoint_context)
84
+ response = result.response.is_a?(APIError) ? error_response(result.response, headers: result.headers) : result.to_rack_response
85
+ run_on_response_chain(response)
86
+ rescue APIError => error
87
+ error_response(error)
88
+ rescue JSON::ParserError
89
+ error_response(APIError.new("BAD_REQUEST", message: "Invalid JSON body"))
90
+ end
91
+
92
+ def self.conflicting_methods(entries)
93
+ method_map = Hash.new { |hash, key| hash[key] = [] }
94
+ entries.each do |entry|
95
+ entry[:methods].each do |method|
96
+ method_map[method] << entry[:plugin_id]
97
+ end
98
+ end
99
+
100
+ method_map.keys.select do |method|
101
+ method_map[method].length > 1 ||
102
+ (method == "*" && entries.length > 1) ||
103
+ (method != "*" && method_map.key?("*"))
104
+ end
105
+ end
106
+
107
+ def self.log(logger, level, message)
108
+ if logger.respond_to?(:call)
109
+ logger.call(level, message)
110
+ elsif logger.respond_to?(level)
111
+ logger.public_send(level, message)
112
+ end
113
+ end
114
+
115
+ private_class_method :conflicting_methods, :log
116
+
117
+ private
118
+
119
+ def route_path_for(path_info)
120
+ base_path = context.options.base_path
121
+ decoded = normalize_path(path_info, trim: false)
122
+
123
+ path = if base_path.empty?
124
+ decoded
125
+ elsif decoded == base_path
126
+ "/"
127
+ elsif decoded.start_with?("#{base_path}/")
128
+ decoded.delete_prefix(base_path)
129
+ else
130
+ return nil
131
+ end
132
+
133
+ if context.options.advanced[:skip_trailing_slashes]
134
+ trim_trailing_slashes(path)
135
+ else
136
+ path
137
+ end
138
+ end
139
+
140
+ def normalize_path(path, trim: true)
141
+ decoded = path.to_s
142
+ 2.times do
143
+ next_decoded = CGI.unescape(decoded)
144
+ break if next_decoded == decoded
145
+
146
+ decoded = next_decoded
147
+ end
148
+ decoded = decoded.gsub(/[[:cntrl:]]/, "")
149
+ decoded = decoded.squeeze("/")
150
+ decoded = trim_trailing_slashes(decoded) if trim
151
+ decoded.empty? ? "/" : decoded
152
+ rescue ArgumentError
153
+ path.to_s
154
+ end
155
+
156
+ def trim_trailing_slashes(path)
157
+ path = path.sub(%r{/+\z}, "")
158
+ path.empty? ? "/" : path
159
+ end
160
+
161
+ def parse_body(request)
162
+ return {} unless request.body
163
+
164
+ request.body.rewind
165
+ raw = request.body.read.to_s
166
+ request.body.rewind
167
+ return {} if raw.empty?
168
+
169
+ if request.media_type == "application/json"
170
+ JSON.parse(raw)
171
+ else
172
+ request.POST
173
+ end
174
+ end
175
+
176
+ def allowed_media_type?(request, endpoint)
177
+ return true unless request_body_method?(request.request_method)
178
+ return true if request.media_type.nil? || request.media_type.empty?
179
+ return true if request.body.nil? || request.content_length.to_i.zero?
180
+
181
+ allowed_media_types(endpoint).include?(request.media_type)
182
+ end
183
+
184
+ def request_body_method?(method)
185
+ %w[POST PUT PATCH DELETE].include?(method.to_s.upcase)
186
+ end
187
+
188
+ def allowed_media_types(endpoint)
189
+ endpoint.metadata[:allowed_media_types] ||
190
+ endpoint.metadata["allowedMediaTypes"] ||
191
+ endpoint.metadata[:allowedMediaTypes] ||
192
+ ["application/json"]
193
+ end
194
+
195
+ def parse_query(request)
196
+ request.GET
197
+ end
198
+
199
+ def build_endpoint_context(request, path, query, body, params)
200
+ Endpoint::Context.new(
201
+ path: path,
202
+ method: request.request_method,
203
+ query: query,
204
+ body: body,
205
+ params: params,
206
+ headers: headers_from(request.env),
207
+ context: context,
208
+ request: request
209
+ )
210
+ end
211
+
212
+ def rebuild_endpoint_context(previous_context, request, route_path, params)
213
+ fresh_context = build_endpoint_context(request, route_path, parse_query(request), parse_body(request), params)
214
+ fresh_context.headers = merge_hashes(previous_context.headers, fresh_context.headers)
215
+ fresh_context.query = merge_hashes(previous_context.query, fresh_context.query)
216
+ fresh_context.body = merge_hashes(previous_context.body, fresh_context.body)
217
+ fresh_context
218
+ end
219
+
220
+ def merge_hashes(base, override)
221
+ return override unless base.is_a?(Hash) && override.is_a?(Hash)
222
+
223
+ base.merge(override) do |_key, old_value, new_value|
224
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
225
+ merge_hashes(old_value, new_value)
226
+ else
227
+ new_value
228
+ end
229
+ end
230
+ end
231
+
232
+ def headers_from(env)
233
+ env.each_with_object({}) do |(key, value), headers|
234
+ case key
235
+ when "CONTENT_TYPE"
236
+ headers["content-type"] = value if value
237
+ when "CONTENT_LENGTH"
238
+ headers["content-length"] = value if value
239
+ else
240
+ next unless key.start_with?("HTTP_")
241
+
242
+ header = key.delete_prefix("HTTP_").downcase.tr("_", "-")
243
+ headers[header] = value
244
+ end
245
+ end
246
+ end
247
+
248
+ def find_endpoint(route_path, method)
249
+ path_matches = endpoints.values.filter_map do |endpoint|
250
+ params = match_path(endpoint.path, route_path)
251
+ [endpoint, params] if params
252
+ end
253
+
254
+ return [nil, {}, []] if path_matches.empty?
255
+
256
+ endpoint, params = path_matches.reverse.find { |candidate, _candidate_params| candidate.matches_method?(method) } || path_matches.first
257
+ allowed_methods = path_matches.flat_map { |candidate, _candidate_params| candidate.methods }.uniq
258
+ [endpoint, params, allowed_methods]
259
+ end
260
+
261
+ def match_path(pattern, path)
262
+ return {} if pattern == path
263
+ return nil unless pattern
264
+
265
+ pattern_parts = pattern.split("/", -1)
266
+ path_parts = path.split("/", -1)
267
+ return nil unless pattern_parts.length == path_parts.length
268
+
269
+ params = {}
270
+ pattern_parts.zip(path_parts).each do |pattern_part, path_part|
271
+ if pattern_part.start_with?(":")
272
+ params[pattern_part.delete_prefix(":").to_sym] = path_part
273
+ elsif pattern_part != path_part
274
+ return nil
275
+ end
276
+ end
277
+ params
278
+ end
279
+
280
+ def run_plugin_middlewares(endpoint_context)
281
+ plugin_middlewares.each do |middleware|
282
+ next unless path_matches?(middleware[:path], endpoint_context.path)
283
+
284
+ result = middleware[:middleware].call(endpoint_context)
285
+ return Endpoint::Result.from_value(result, endpoint_context).to_rack_response if result
286
+ end
287
+ nil
288
+ end
289
+
290
+ def plugin_middlewares
291
+ context.options.plugins.flat_map do |plugin|
292
+ Array(plugin[:middlewares]).map do |middleware|
293
+ {
294
+ path: middleware[:path],
295
+ middleware: middleware[:middleware]
296
+ }
297
+ end
298
+ end
299
+ end
300
+
301
+ def disabled_path?(route_path)
302
+ context.options.disabled_paths.any? do |disabled|
303
+ normalize_path(disabled) == normalize_path(route_path)
304
+ end
305
+ end
306
+
307
+ def run_on_request_chain(request)
308
+ current_request = request
309
+ context.options.plugins.each do |plugin|
310
+ handler = plugin[:on_request]
311
+ next unless handler
312
+
313
+ result = handler.call(current_request, context)
314
+ next unless result
315
+
316
+ return result[:response] if result[:response]
317
+ current_request = result[:request] if result[:request]
318
+ end
319
+ current_request
320
+ end
321
+
322
+ def run_on_response_chain(response)
323
+ current_response = response
324
+ context.options.plugins.each do |plugin|
325
+ handler = plugin[:on_response]
326
+ next unless handler
327
+
328
+ result = handler.call(current_response, context)
329
+ current_response = result[:response] if result && result[:response]
330
+ end
331
+ current_response
332
+ end
333
+
334
+ def path_matches?(pattern, path)
335
+ return true if pattern == "/**"
336
+ return path == pattern unless pattern&.end_with?("/**")
337
+
338
+ path.start_with?(pattern.delete_suffix("/**"))
339
+ end
340
+
341
+ def not_found
342
+ [404, {"content-type" => "application/json"}, [JSON.generate({error: "Not Found"})]]
343
+ end
344
+
345
+ def method_not_allowed(methods)
346
+ [405, {"content-type" => "application/json", "allow" => methods.reject { |method| method == "*" }.join(", ")}, [JSON.generate({error: "Method Not Allowed"})]]
347
+ end
348
+
349
+ def unsupported_media_type
350
+ [415, {"content-type" => "application/json"}, [JSON.generate({error: "Unsupported Media Type"})]]
351
+ end
352
+
353
+ def error_response(error, headers: {})
354
+ Endpoint::Result.new(
355
+ response: error.to_h,
356
+ status: error.status_code,
357
+ headers: Endpoint::Result.merge_headers(headers, error.headers)
358
+ ).to_rack_response
359
+ end
360
+
361
+ def rack_response?(value)
362
+ Endpoint::Result.rack_response?(value)
363
+ end
364
+ end
365
+ end
@@ -0,0 +1,211 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Routes
5
+ def self.list_accounts
6
+ Endpoint.new(path: "/list-accounts", method: "GET") do |ctx|
7
+ session = current_session(ctx)
8
+ accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).map do |account|
9
+ parsed = Schema.parse_output(ctx.context.options, "account", account)
10
+ scope = parsed.delete("scope")
11
+ parsed.merge("scopes" => scope.to_s.empty? ? [] : scope.to_s.split(","))
12
+ end
13
+ ctx.json(accounts)
14
+ end
15
+ end
16
+
17
+ def self.unlink_account
18
+ Endpoint.new(path: "/unlink-account", method: "POST") do |ctx|
19
+ session = current_session(ctx, sensitive: true)
20
+ body = normalize_hash(ctx.body)
21
+ accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"])
22
+ if accounts.length == 1 && !ctx.context.options.account.dig(:account_linking, :allow_unlinking_all)
23
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["FAILED_TO_UNLINK_LAST_ACCOUNT"])
24
+ end
25
+
26
+ provider_id = body["providerId"] || body["provider_id"]
27
+ account_id = body["accountId"] || body["account_id"]
28
+ account = accounts.find do |entry|
29
+ entry["providerId"] == provider_id && (account_id.to_s.empty? || entry["accountId"] == account_id)
30
+ end
31
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["ACCOUNT_NOT_FOUND"]) unless account
32
+
33
+ ctx.context.internal_adapter.delete_account(account["id"])
34
+ ctx.json({status: true})
35
+ end
36
+ end
37
+
38
+ def self.get_access_token
39
+ Endpoint.new(path: "/get-access-token", method: "POST") do |ctx|
40
+ session = current_session(ctx, allow_nil: true)
41
+ body = normalize_hash(ctx.body)
42
+ user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
43
+ raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
44
+
45
+ provider_id = body["providerId"] || body["provider_id"]
46
+ provider = social_provider(ctx.context, provider_id)
47
+ raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
48
+
49
+ account_id = body["accountId"] || body["account_id"]
50
+ account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
51
+ raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
52
+
53
+ if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
54
+ tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
55
+ updated = update_account_tokens(ctx, account, tokens)
56
+ account = account.merge(token_hash(tokens))
57
+ Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
58
+ end
59
+
60
+ ctx.json({
61
+ accessToken: oauth_token_value(ctx, account["accessToken"]),
62
+ accessTokenExpiresAt: account["accessTokenExpiresAt"],
63
+ scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
64
+ idToken: account["idToken"]
65
+ })
66
+ end
67
+ end
68
+
69
+ def self.refresh_token
70
+ Endpoint.new(path: "/refresh-token", method: "POST") do |ctx|
71
+ session = current_session(ctx, allow_nil: true)
72
+ body = normalize_hash(ctx.body)
73
+ user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
74
+ raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
75
+
76
+ provider_id = body["providerId"] || body["provider_id"]
77
+ provider = social_provider(ctx.context, provider_id)
78
+ raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} not found.") unless provider
79
+ raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} does not support token refreshing.") unless provider_callable(provider, :refresh_access_token)
80
+
81
+ account_id = body["accountId"] || body["account_id"]
82
+ account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
83
+ raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
84
+ refresh_token = oauth_token_value(ctx, account["refreshToken"])
85
+ raise APIError.new("BAD_REQUEST", message: "Refresh token not found") if refresh_token.to_s.empty?
86
+
87
+ tokens = call_provider(provider, :refresh_access_token, refresh_token)
88
+ updated = update_account_tokens(ctx, account, tokens)
89
+ values = token_hash(tokens)
90
+ Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
91
+ ctx.json({
92
+ accessToken: values["accessToken"],
93
+ refreshToken: values["refreshToken"],
94
+ accessTokenExpiresAt: values["accessTokenExpiresAt"],
95
+ refreshTokenExpiresAt: values["refreshTokenExpiresAt"],
96
+ scope: Array(values["scopes"]).join(","),
97
+ idToken: values["idToken"] || account["idToken"],
98
+ providerId: account["providerId"],
99
+ accountId: account["accountId"]
100
+ })
101
+ end
102
+ end
103
+
104
+ def self.account_info
105
+ Endpoint.new(path: "/account-info", method: "GET") do |ctx|
106
+ session = current_session(ctx)
107
+ account_id = fetch_value(ctx.query, "accountId")
108
+ account = if account_id
109
+ ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
110
+ entry["id"] == account_id || entry["accountId"] == account_id
111
+ end
112
+ end
113
+ raise APIError.new("BAD_REQUEST", message: "Account not found") unless account && account["userId"] == session[:user]["id"]
114
+
115
+ provider = social_provider(ctx.context, account["providerId"])
116
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Provider account provider is #{account["providerId"]} but it is not configured") unless provider
117
+ raise APIError.new("BAD_REQUEST", message: "Access token not found") if account["accessToken"].to_s.empty?
118
+
119
+ info = call_provider(provider, :get_user_info, {
120
+ accessToken: oauth_token_value(ctx, account["accessToken"]),
121
+ access_token: oauth_token_value(ctx, account["accessToken"]),
122
+ idToken: account["idToken"],
123
+ scopes: account["scope"].to_s.split(",")
124
+ })
125
+ ctx.json(info)
126
+ end
127
+ end
128
+
129
+ def self.social_provider(context, provider_id)
130
+ provider = context.social_providers[provider_id.to_sym] || context.social_providers[provider_id.to_s]
131
+ return provider.merge(id: provider_id.to_s) if provider.is_a?(Hash) && !provider.key?(:id) && !provider.key?("id")
132
+
133
+ provider
134
+ end
135
+
136
+ def self.find_provider_account(ctx, user_id, provider_id, account_id = nil)
137
+ ctx.context.internal_adapter.find_accounts(user_id).find do |account|
138
+ account["providerId"] == provider_id && (account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id)
139
+ end
140
+ end
141
+
142
+ def self.account_cookie(ctx, provider_id, account_id = nil, user_id = nil)
143
+ return nil unless ctx.context.options.account[:store_account_cookie]
144
+
145
+ account = Cookies.get_account_cookie(ctx)
146
+ return nil unless account && account["providerId"] == provider_id
147
+ return nil unless account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id
148
+ return nil unless user_id.to_s.empty? || account["userId"].to_s.empty? || account["userId"] == user_id
149
+
150
+ account
151
+ end
152
+
153
+ def self.access_token_expired?(account)
154
+ value = parse_time(account["accessTokenExpiresAt"])
155
+ value && value < Time.now + 5
156
+ end
157
+
158
+ def self.parse_time(value)
159
+ return value if value.is_a?(Time)
160
+ return nil if value.nil? || value.to_s.empty?
161
+
162
+ Time.parse(value.to_s)
163
+ rescue ArgumentError
164
+ nil
165
+ end
166
+
167
+ def self.update_account_tokens(ctx, account, tokens)
168
+ return nil if account["id"].to_s.empty?
169
+
170
+ ctx.context.internal_adapter.update_account(account["id"], token_hash_for_storage(ctx, tokens))
171
+ end
172
+
173
+ def self.token_hash(tokens)
174
+ data = normalize_hash(tokens || {})
175
+ data["scope"] = Array(data.delete("scopes")).join(",") if data.key?("scopes")
176
+ data
177
+ end
178
+
179
+ def self.token_hash_for_storage(ctx, tokens)
180
+ data = token_hash(tokens)
181
+ data["accessToken"] = oauth_token_for_storage(ctx, data["accessToken"]) if data.key?("accessToken")
182
+ data["refreshToken"] = oauth_token_for_storage(ctx, data["refreshToken"]) if data.key?("refreshToken")
183
+ data
184
+ end
185
+
186
+ def self.oauth_token_for_storage(ctx, token)
187
+ return token if token.to_s.empty?
188
+ return token unless ctx.context.options.account[:encrypt_oauth_tokens]
189
+
190
+ Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
191
+ end
192
+
193
+ def self.oauth_token_value(ctx, token)
194
+ return token if token.to_s.empty?
195
+ return token unless ctx.context.options.account[:encrypt_oauth_tokens]
196
+
197
+ Crypto.symmetric_decrypt(key: ctx.context.secret, data: token) || token
198
+ end
199
+
200
+ def self.provider_callable(provider, key)
201
+ provider.respond_to?(key) || (provider.is_a?(Hash) && (provider[key] || provider[key.to_s]))
202
+ end
203
+
204
+ def self.call_provider(provider, key, *arguments)
205
+ return provider.public_send(key, *arguments) if provider.respond_to?(key)
206
+
207
+ callable = provider[key] || provider[key.to_s]
208
+ callable.respond_to?(:call) ? callable.call(*arguments) : callable
209
+ end
210
+ end
211
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Routes
7
+ def self.send_verification_email
8
+ Endpoint.new(path: "/send-verification-email", method: "POST") do |ctx|
9
+ sender = ctx.context.options.email_verification[:send_verification_email]
10
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
11
+
12
+ body = normalize_hash(ctx.body)
13
+ email = body["email"].to_s.downcase
14
+ session = current_session(ctx, allow_nil: true)
15
+
16
+ if session
17
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_MISMATCH"]) if session[:user]["email"] != email
18
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["EMAIL_ALREADY_VERIFIED"]) if session[:user]["emailVerified"]
19
+
20
+ send_verification_email_payload(ctx, session[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
21
+ next ctx.json({status: true})
22
+ end
23
+
24
+ found = ctx.context.internal_adapter.find_user_by_email(email)
25
+ if found && !found[:user]["emailVerified"]
26
+ send_verification_email_payload(ctx, found[:user], body["callbackURL"] || body["callbackUrl"] || body["callback_url"])
27
+ else
28
+ create_email_verification_token(ctx, email)
29
+ end
30
+ ctx.json({status: true})
31
+ end
32
+ end
33
+
34
+ def self.verify_email
35
+ Endpoint.new(path: "/verify-email", method: "GET") do |ctx|
36
+ token = fetch_value(ctx.query, "token").to_s
37
+ callback_url = fetch_value(ctx.query, "callbackURL")
38
+ payload = verify_email_token(ctx, token, callback_url)
39
+ email = payload["email"].to_s.downcase
40
+ update_to = payload["updateTo"] || payload["update_to"]
41
+ user_data = ctx.context.internal_adapter.find_user_by_email(email)
42
+ return redirect_or_error(ctx, callback_url, "user_not_found") unless user_data
43
+
44
+ user = user_data[:user]
45
+ if update_to
46
+ updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
47
+ set_verified_session_cookie(ctx, updated || user.merge("email" => update_to, "emailVerified" => true))
48
+ next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
49
+ end
50
+
51
+ if user["emailVerified"]
52
+ next redirect_or_json(ctx, callback_url, {status: true, user: nil})
53
+ end
54
+
55
+ call_option(ctx.context.options.email_verification[:before_email_verification], user, ctx.request)
56
+ call_option(ctx.context.options.email_verification[:on_email_verification], user, ctx.request)
57
+ updated = ctx.context.internal_adapter.update_user_by_email(email, emailVerified: true)
58
+ call_option(ctx.context.options.email_verification[:after_email_verification], updated, ctx.request)
59
+ set_verified_session_cookie(ctx, updated) if ctx.context.options.email_verification[:auto_sign_in_after_verification]
60
+ redirect_or_json(ctx, callback_url, {status: true, user: nil})
61
+ end
62
+ end
63
+
64
+ def self.send_verification_email_payload(ctx, user, callback_url)
65
+ token = create_email_verification_token(ctx, user["email"])
66
+ callback = URI.encode_www_form_component(callback_url || "/")
67
+ url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
68
+ ctx.context.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
69
+ end
70
+
71
+ def self.create_email_verification_token(ctx, email, update_to: nil, extra: {})
72
+ payload = {"email" => email.to_s.downcase}.merge(extra)
73
+ payload["updateTo"] = update_to if update_to
74
+ Crypto.sign_jwt(payload, ctx.context.secret, expires_in: ctx.context.options.email_verification[:expires_in] || 3600)
75
+ end
76
+
77
+ def self.verify_email_token(ctx, token, callback_url)
78
+ payload = Crypto.verify_jwt(token, ctx.context.secret)
79
+ return payload if payload
80
+
81
+ redirect_or_error(ctx, callback_url, "invalid_token")
82
+ end
83
+
84
+ def self.redirect_or_error(ctx, callback_url, error)
85
+ if callback_url
86
+ separator = callback_url.include?("?") ? "&" : "?"
87
+ raise ctx.redirect("#{callback_url}#{separator}error=#{error}")
88
+ end
89
+ raise APIError.new("UNAUTHORIZED", message: error)
90
+ end
91
+
92
+ def self.redirect_or_json(ctx, callback_url, data)
93
+ raise ctx.redirect(callback_url) if callback_url
94
+
95
+ ctx.json(data)
96
+ end
97
+
98
+ def self.set_verified_session_cookie(ctx, user)
99
+ session = current_session(ctx, allow_nil: true)
100
+ session_data = session ? session[:session] : ctx.context.internal_adapter.create_session(user["id"])
101
+ Cookies.set_session_cookie(ctx, {session: session_data, user: user})
102
+ end
103
+
104
+ def self.call_option(callback, user, request)
105
+ callback.call(user, request) if callback.respond_to?(:call)
106
+ end
107
+ end
108
+ end