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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +106 -16
- data/lib/better_auth/adapters/base.rb +49 -0
- data/lib/better_auth/adapters/internal_adapter.rb +439 -0
- data/lib/better_auth/adapters/memory.rb +232 -0
- data/lib/better_auth/adapters/mongodb.rb +369 -0
- data/lib/better_auth/adapters/mssql.rb +42 -0
- data/lib/better_auth/adapters/mysql.rb +33 -0
- data/lib/better_auth/adapters/postgres.rb +17 -0
- data/lib/better_auth/adapters/sql.rb +425 -0
- data/lib/better_auth/adapters/sqlite.rb +20 -0
- data/lib/better_auth/api.rb +226 -0
- data/lib/better_auth/api_error.rb +53 -0
- data/lib/better_auth/auth.rb +42 -0
- data/lib/better_auth/configuration.rb +399 -0
- data/lib/better_auth/context.rb +210 -0
- data/lib/better_auth/cookies.rb +278 -0
- data/lib/better_auth/core.rb +37 -1
- data/lib/better_auth/crypto/jwe.rb +76 -0
- data/lib/better_auth/crypto.rb +191 -0
- data/lib/better_auth/database_hooks.rb +114 -0
- data/lib/better_auth/endpoint.rb +326 -0
- data/lib/better_auth/error.rb +52 -0
- data/lib/better_auth/middleware/origin_check.rb +128 -0
- data/lib/better_auth/password.rb +120 -0
- data/lib/better_auth/plugin.rb +129 -0
- data/lib/better_auth/plugin_context.rb +16 -0
- data/lib/better_auth/plugin_registry.rb +67 -0
- data/lib/better_auth/plugins/access.rb +87 -0
- data/lib/better_auth/plugins/additional_fields.rb +29 -0
- data/lib/better_auth/plugins/admin/schema.rb +28 -0
- data/lib/better_auth/plugins/admin.rb +518 -0
- data/lib/better_auth/plugins/anonymous.rb +198 -0
- data/lib/better_auth/plugins/api_key.rb +16 -0
- data/lib/better_auth/plugins/bearer.rb +128 -0
- data/lib/better_auth/plugins/captcha.rb +159 -0
- data/lib/better_auth/plugins/custom_session.rb +84 -0
- data/lib/better_auth/plugins/device_authorization.rb +302 -0
- data/lib/better_auth/plugins/email_otp.rb +536 -0
- data/lib/better_auth/plugins/expo.rb +88 -0
- data/lib/better_auth/plugins/generic_oauth.rb +780 -0
- data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
- data/lib/better_auth/plugins/jwt.rb +482 -0
- data/lib/better_auth/plugins/last_login_method.rb +92 -0
- data/lib/better_auth/plugins/magic_link.rb +181 -0
- data/lib/better_auth/plugins/mcp.rb +342 -0
- data/lib/better_auth/plugins/multi_session.rb +173 -0
- data/lib/better_auth/plugins/oauth_protocol.rb +348 -0
- data/lib/better_auth/plugins/oauth_provider.rb +16 -0
- data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
- data/lib/better_auth/plugins/oidc_provider.rb +597 -0
- data/lib/better_auth/plugins/one_tap.rb +154 -0
- data/lib/better_auth/plugins/one_time_token.rb +106 -0
- data/lib/better_auth/plugins/open_api.rb +489 -0
- data/lib/better_auth/plugins/organization/schema.rb +106 -0
- data/lib/better_auth/plugins/organization.rb +990 -0
- data/lib/better_auth/plugins/passkey.rb +16 -0
- data/lib/better_auth/plugins/phone_number.rb +321 -0
- data/lib/better_auth/plugins/scim.rb +16 -0
- data/lib/better_auth/plugins/siwe.rb +242 -0
- data/lib/better_auth/plugins/sso.rb +16 -0
- data/lib/better_auth/plugins/stripe.rb +16 -0
- data/lib/better_auth/plugins/two_factor.rb +514 -0
- data/lib/better_auth/plugins/username.rb +278 -0
- data/lib/better_auth/plugins.rb +46 -0
- data/lib/better_auth/rate_limiter.rb +215 -0
- data/lib/better_auth/request_ip.rb +70 -0
- data/lib/better_auth/router.rb +365 -0
- data/lib/better_auth/routes/account.rb +211 -0
- data/lib/better_auth/routes/email_verification.rb +108 -0
- data/lib/better_auth/routes/error.rb +102 -0
- data/lib/better_auth/routes/ok.rb +15 -0
- data/lib/better_auth/routes/password.rb +164 -0
- data/lib/better_auth/routes/session.rb +137 -0
- data/lib/better_auth/routes/sign_in.rb +90 -0
- data/lib/better_auth/routes/sign_out.rb +15 -0
- data/lib/better_auth/routes/sign_up.rb +145 -0
- data/lib/better_auth/routes/social.rb +188 -0
- data/lib/better_auth/routes/user.rb +193 -0
- data/lib/better_auth/schema/sql.rb +191 -0
- data/lib/better_auth/schema.rb +275 -0
- data/lib/better_auth/session.rb +122 -0
- data/lib/better_auth/session_store.rb +91 -0
- data/lib/better_auth/social_providers/apple.rb +55 -0
- data/lib/better_auth/social_providers/base.rb +67 -0
- data/lib/better_auth/social_providers/discord.rb +59 -0
- data/lib/better_auth/social_providers/github.rb +59 -0
- data/lib/better_auth/social_providers/gitlab.rb +54 -0
- data/lib/better_auth/social_providers/google.rb +65 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
- data/lib/better_auth/social_providers.rb +9 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +87 -2
- metadata +218 -21
- data/.ruby-version +0 -1
- data/.standard.yml +0 -12
- data/.vscode/settings.json +0 -22
- data/AGENTS.md +0 -50
- data/CLAUDE.md +0 -1
- data/CODE_OF_CONDUCT.md +0 -173
- data/CONTRIBUTING.md +0 -187
- data/Gemfile +0 -12
- data/Makefile +0 -207
- data/Rakefile +0 -25
- data/SECURITY.md +0 -28
- 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
|