better_auth 0.1.1 → 0.3.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -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 +441 -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 +211 -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 +142 -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 +694 -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 +995 -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 +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -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 +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -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 +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -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 +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,378 @@
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
+ return run_on_response_chain(forbidden) if server_only?(endpoint)
68
+
69
+ response = @origin_check.call(endpoint_context)
70
+ return run_on_response_chain(response) if response
71
+
72
+ response = run_plugin_middlewares(endpoint_context)
73
+ return run_on_response_chain(response) if response
74
+
75
+ return run_on_response_chain(not_found) if disabled_path?(route_path)
76
+
77
+ request = run_on_request_chain(request)
78
+ return run_on_response_chain(request) if rack_response?(request)
79
+
80
+ response = rate_limiter.call(request, context, route_path)
81
+ return run_on_response_chain(response) if response
82
+
83
+ endpoint_context = rebuild_endpoint_context(endpoint_context, request, route_path, params)
84
+ result = API.new(context, endpoints).execute(endpoint, endpoint_context)
85
+ response = result.response.is_a?(APIError) ? error_response(result.response, headers: result.headers) : result.to_rack_response
86
+ run_on_response_chain(response)
87
+ rescue APIError => error
88
+ error_response(error)
89
+ rescue JSON::ParserError
90
+ error_response(APIError.new("BAD_REQUEST", message: "Invalid JSON body"))
91
+ end
92
+
93
+ def self.conflicting_methods(entries)
94
+ method_map = Hash.new { |hash, key| hash[key] = [] }
95
+ entries.each do |entry|
96
+ entry[:methods].each do |method|
97
+ method_map[method] << entry[:plugin_id]
98
+ end
99
+ end
100
+
101
+ method_map.keys.select do |method|
102
+ method_map[method].length > 1 ||
103
+ (method == "*" && entries.length > 1) ||
104
+ (method != "*" && method_map.key?("*"))
105
+ end
106
+ end
107
+
108
+ def self.log(logger, level, message)
109
+ if logger.respond_to?(:call)
110
+ logger.call(level, message)
111
+ elsif logger.respond_to?(level)
112
+ logger.public_send(level, message)
113
+ end
114
+ end
115
+
116
+ private_class_method :conflicting_methods, :log
117
+
118
+ private
119
+
120
+ def route_path_for(path_info)
121
+ base_path = context.options.base_path
122
+ decoded = normalize_path(path_info, trim: false)
123
+
124
+ path = if base_path.empty?
125
+ decoded
126
+ elsif decoded == base_path
127
+ "/"
128
+ elsif decoded.start_with?("#{base_path}/")
129
+ decoded.delete_prefix(base_path)
130
+ else
131
+ return nil
132
+ end
133
+
134
+ if context.options.advanced[:skip_trailing_slashes]
135
+ trim_trailing_slashes(path)
136
+ else
137
+ path
138
+ end
139
+ end
140
+
141
+ def normalize_path(path, trim: true)
142
+ decoded = path.to_s
143
+ 2.times do
144
+ next_decoded = CGI.unescape(decoded)
145
+ break if next_decoded == decoded
146
+
147
+ decoded = next_decoded
148
+ end
149
+ decoded = decoded.gsub(/[[:cntrl:]]/, "")
150
+ decoded = decoded.squeeze("/")
151
+ decoded = trim_trailing_slashes(decoded) if trim
152
+ decoded.empty? ? "/" : decoded
153
+ rescue ArgumentError
154
+ path.to_s
155
+ end
156
+
157
+ def trim_trailing_slashes(path)
158
+ path = path.sub(%r{/+\z}, "")
159
+ path.empty? ? "/" : path
160
+ end
161
+
162
+ def parse_body(request)
163
+ return {} unless request.body
164
+
165
+ request.body.rewind
166
+ raw = request.body.read.to_s
167
+ request.body.rewind
168
+ return {} if raw.empty?
169
+
170
+ if json_media_type?(request.media_type)
171
+ JSON.parse(raw)
172
+ else
173
+ request.POST
174
+ end
175
+ end
176
+
177
+ def json_media_type?(media_type)
178
+ media_type == "application/json" || media_type.to_s.end_with?("+json")
179
+ end
180
+
181
+ def allowed_media_type?(request, endpoint)
182
+ return true unless request_body_method?(request.request_method)
183
+ return true if request.media_type.nil? || request.media_type.empty?
184
+ return true if request.body.nil? || request.content_length.to_i.zero?
185
+
186
+ allowed_media_types(endpoint).include?(request.media_type)
187
+ end
188
+
189
+ def request_body_method?(method)
190
+ %w[POST PUT PATCH DELETE].include?(method.to_s.upcase)
191
+ end
192
+
193
+ def allowed_media_types(endpoint)
194
+ endpoint.metadata[:allowed_media_types] ||
195
+ endpoint.metadata["allowedMediaTypes"] ||
196
+ endpoint.metadata[:allowedMediaTypes] ||
197
+ ["application/json"]
198
+ end
199
+
200
+ def parse_query(request)
201
+ request.GET
202
+ end
203
+
204
+ def build_endpoint_context(request, path, query, body, params)
205
+ Endpoint::Context.new(
206
+ path: path,
207
+ method: request.request_method,
208
+ query: query,
209
+ body: body,
210
+ params: params,
211
+ headers: headers_from(request.env),
212
+ context: context,
213
+ request: request
214
+ )
215
+ end
216
+
217
+ def rebuild_endpoint_context(previous_context, request, route_path, params)
218
+ fresh_context = build_endpoint_context(request, route_path, parse_query(request), parse_body(request), params)
219
+ fresh_context.headers = merge_hashes(previous_context.headers, fresh_context.headers)
220
+ fresh_context.query = merge_hashes(previous_context.query, fresh_context.query)
221
+ fresh_context.body = merge_hashes(previous_context.body, fresh_context.body)
222
+ fresh_context
223
+ end
224
+
225
+ def merge_hashes(base, override)
226
+ return override unless base.is_a?(Hash) && override.is_a?(Hash)
227
+
228
+ base.merge(override) do |_key, old_value, new_value|
229
+ if old_value.is_a?(Hash) && new_value.is_a?(Hash)
230
+ merge_hashes(old_value, new_value)
231
+ else
232
+ new_value
233
+ end
234
+ end
235
+ end
236
+
237
+ def headers_from(env)
238
+ env.each_with_object({}) do |(key, value), headers|
239
+ case key
240
+ when "CONTENT_TYPE"
241
+ headers["content-type"] = value if value
242
+ when "CONTENT_LENGTH"
243
+ headers["content-length"] = value if value
244
+ else
245
+ next unless key.start_with?("HTTP_")
246
+
247
+ header = key.delete_prefix("HTTP_").downcase.tr("_", "-")
248
+ headers[header] = value
249
+ end
250
+ end
251
+ end
252
+
253
+ def find_endpoint(route_path, method)
254
+ path_matches = endpoints.values.filter_map do |endpoint|
255
+ params = match_path(endpoint.path, route_path)
256
+ [endpoint, params] if params
257
+ end
258
+
259
+ return [nil, {}, []] if path_matches.empty?
260
+
261
+ endpoint, params = path_matches.reverse.find { |candidate, _candidate_params| candidate.matches_method?(method) } || path_matches.first
262
+ allowed_methods = path_matches.flat_map { |candidate, _candidate_params| candidate.methods }.uniq
263
+ [endpoint, params, allowed_methods]
264
+ end
265
+
266
+ def match_path(pattern, path)
267
+ return {} if pattern == path
268
+ return nil unless pattern
269
+
270
+ pattern_parts = pattern.split("/", -1)
271
+ path_parts = path.split("/", -1)
272
+ return nil unless pattern_parts.length == path_parts.length
273
+
274
+ params = {}
275
+ pattern_parts.zip(path_parts).each do |pattern_part, path_part|
276
+ if pattern_part.start_with?(":")
277
+ params[pattern_part.delete_prefix(":").to_sym] = path_part
278
+ elsif pattern_part != path_part
279
+ return nil
280
+ end
281
+ end
282
+ params
283
+ end
284
+
285
+ def run_plugin_middlewares(endpoint_context)
286
+ plugin_middlewares.each do |middleware|
287
+ next unless path_matches?(middleware[:path], endpoint_context.path)
288
+
289
+ result = middleware[:middleware].call(endpoint_context)
290
+ return Endpoint::Result.from_value(result, endpoint_context).to_rack_response if result
291
+ end
292
+ nil
293
+ end
294
+
295
+ def plugin_middlewares
296
+ context.options.plugins.flat_map do |plugin|
297
+ Array(plugin[:middlewares]).map do |middleware|
298
+ {
299
+ path: middleware[:path],
300
+ middleware: middleware[:middleware]
301
+ }
302
+ end
303
+ end
304
+ end
305
+
306
+ def disabled_path?(route_path)
307
+ context.options.disabled_paths.any? do |disabled|
308
+ normalize_path(disabled) == normalize_path(route_path)
309
+ end
310
+ end
311
+
312
+ def run_on_request_chain(request)
313
+ current_request = request
314
+ context.options.plugins.each do |plugin|
315
+ handler = plugin[:on_request]
316
+ next unless handler
317
+
318
+ result = handler.call(current_request, context)
319
+ next unless result
320
+
321
+ return result[:response] if result[:response]
322
+ current_request = result[:request] if result[:request]
323
+ end
324
+ current_request
325
+ end
326
+
327
+ def run_on_response_chain(response)
328
+ current_response = response
329
+ context.options.plugins.each do |plugin|
330
+ handler = plugin[:on_response]
331
+ next unless handler
332
+
333
+ result = handler.call(current_response, context)
334
+ current_response = result[:response] if result && result[:response]
335
+ end
336
+ current_response
337
+ end
338
+
339
+ def path_matches?(pattern, path)
340
+ return true if pattern == "/**"
341
+ return path == pattern unless pattern&.end_with?("/**")
342
+
343
+ path.start_with?(pattern.delete_suffix("/**"))
344
+ end
345
+
346
+ def not_found
347
+ [404, {"content-type" => "application/json"}, [JSON.generate({error: "Not Found"})]]
348
+ end
349
+
350
+ def method_not_allowed(methods)
351
+ [405, {"content-type" => "application/json", "allow" => methods.reject { |method| method == "*" }.join(", ")}, [JSON.generate({error: "Method Not Allowed"})]]
352
+ end
353
+
354
+ def unsupported_media_type
355
+ [415, {"content-type" => "application/json"}, [JSON.generate({error: "Unsupported Media Type"})]]
356
+ end
357
+
358
+ def forbidden
359
+ [403, {"content-type" => "application/json"}, [JSON.generate({error: "Forbidden"})]]
360
+ end
361
+
362
+ def server_only?(endpoint)
363
+ endpoint.metadata[:server_only] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata["SERVER_ONLY"]
364
+ end
365
+
366
+ def error_response(error, headers: {})
367
+ Endpoint::Result.new(
368
+ response: error.to_h,
369
+ status: error.status_code,
370
+ headers: Endpoint::Result.merge_headers(headers, error.headers)
371
+ ).to_rack_response
372
+ end
373
+
374
+ def rack_response?(value)
375
+ Endpoint::Result.rack_response?(value)
376
+ end
377
+ end
378
+ 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,111 @@
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
+ validate_callback_url!(ctx.context, callback_url)
39
+ payload = verify_email_token(ctx, token, callback_url)
40
+ email = payload["email"].to_s.downcase
41
+ update_to = payload["updateTo"] || payload["update_to"]
42
+ user_data = ctx.context.internal_adapter.find_user_by_email(email)
43
+ return redirect_or_error(ctx, callback_url, "user_not_found") unless user_data
44
+
45
+ user = user_data[:user]
46
+ if update_to
47
+ updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
48
+ updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
49
+ send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
50
+ set_verified_session_cookie(ctx, updated_user)
51
+ next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
52
+ end
53
+
54
+ if user["emailVerified"]
55
+ next redirect_or_json(ctx, callback_url, {status: true, user: nil})
56
+ end
57
+
58
+ call_option(ctx.context.options.email_verification[:before_email_verification], user, ctx.request)
59
+ call_option(ctx.context.options.email_verification[:on_email_verification], user, ctx.request)
60
+ updated = ctx.context.internal_adapter.update_user_by_email(email, emailVerified: true)
61
+ call_option(ctx.context.options.email_verification[:after_email_verification], updated, ctx.request)
62
+ set_verified_session_cookie(ctx, updated) if ctx.context.options.email_verification[:auto_sign_in_after_verification]
63
+ redirect_or_json(ctx, callback_url, {status: true, user: nil})
64
+ end
65
+ end
66
+
67
+ def self.send_verification_email_payload(ctx, user, callback_url)
68
+ token = create_email_verification_token(ctx, user["email"])
69
+ callback = URI.encode_www_form_component(callback_url || "/")
70
+ url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
71
+ ctx.context.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
72
+ end
73
+
74
+ def self.create_email_verification_token(ctx, email, update_to: nil, extra: {})
75
+ payload = {"email" => email.to_s.downcase}.merge(extra)
76
+ payload["updateTo"] = update_to if update_to
77
+ Crypto.sign_jwt(payload, ctx.context.secret, expires_in: ctx.context.options.email_verification[:expires_in] || 3600)
78
+ end
79
+
80
+ def self.verify_email_token(ctx, token, callback_url)
81
+ payload = Crypto.verify_jwt(token, ctx.context.secret)
82
+ return payload if payload
83
+
84
+ redirect_or_error(ctx, callback_url, "invalid_token")
85
+ end
86
+
87
+ def self.redirect_or_error(ctx, callback_url, error)
88
+ if callback_url
89
+ separator = callback_url.include?("?") ? "&" : "?"
90
+ raise ctx.redirect("#{callback_url}#{separator}error=#{error}")
91
+ end
92
+ raise APIError.new("UNAUTHORIZED", message: error)
93
+ end
94
+
95
+ def self.redirect_or_json(ctx, callback_url, data)
96
+ raise ctx.redirect(callback_url) if callback_url
97
+
98
+ ctx.json(data)
99
+ end
100
+
101
+ def self.set_verified_session_cookie(ctx, user)
102
+ session = current_session(ctx, allow_nil: true)
103
+ session_data = session ? session[:session] : ctx.context.internal_adapter.create_session(user["id"])
104
+ Cookies.set_session_cookie(ctx, {session: session_data, user: user})
105
+ end
106
+
107
+ def self.call_option(callback, user, request)
108
+ callback.call(user, request) if callback.respond_to?(:call)
109
+ end
110
+ end
111
+ end