better_auth 0.2.0 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +5 -3
- data/lib/better_auth/adapters/internal_adapter.rb +168 -18
- data/lib/better_auth/adapters/memory.rb +4 -1
- data/lib/better_auth/adapters/mongodb.rb +5 -365
- data/lib/better_auth/adapters/sql.rb +17 -1
- data/lib/better_auth/api.rb +1 -1
- data/lib/better_auth/context.rb +2 -1
- data/lib/better_auth/plugin.rb +14 -1
- data/lib/better_auth/plugins/oauth_protocol.rb +403 -57
- data/lib/better_auth/plugins/organization.rb +5 -0
- data/lib/better_auth/rate_limiter.rb +19 -2
- data/lib/better_auth/router.rb +14 -1
- data/lib/better_auth/routes/email_verification.rb +5 -2
- data/lib/better_auth/routes/password.rb +19 -0
- data/lib/better_auth/routes/session.rb +27 -4
- data/lib/better_auth/routes/sign_in.rb +1 -1
- data/lib/better_auth/routes/sign_up.rb +52 -1
- data/lib/better_auth/routes/social.rb +201 -22
- data/lib/better_auth/routes/user.rb +14 -2
- data/lib/better_auth/schema/sql.rb +11 -0
- data/lib/better_auth/schema.rb +16 -0
- data/lib/better_auth/social_providers/apple.rb +44 -8
- data/lib/better_auth/social_providers/atlassian.rb +32 -0
- data/lib/better_auth/social_providers/base.rb +262 -4
- data/lib/better_auth/social_providers/cognito.rb +32 -0
- data/lib/better_auth/social_providers/discord.rb +27 -5
- data/lib/better_auth/social_providers/dropbox.rb +33 -0
- data/lib/better_auth/social_providers/facebook.rb +35 -0
- data/lib/better_auth/social_providers/figma.rb +31 -0
- data/lib/better_auth/social_providers/github.rb +21 -6
- data/lib/better_auth/social_providers/gitlab.rb +16 -3
- data/lib/better_auth/social_providers/google.rb +38 -13
- data/lib/better_auth/social_providers/huggingface.rb +31 -0
- data/lib/better_auth/social_providers/kakao.rb +32 -0
- data/lib/better_auth/social_providers/kick.rb +32 -0
- data/lib/better_auth/social_providers/line.rb +33 -0
- data/lib/better_auth/social_providers/linear.rb +44 -0
- data/lib/better_auth/social_providers/linkedin.rb +30 -0
- data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
- data/lib/better_auth/social_providers/naver.rb +31 -0
- data/lib/better_auth/social_providers/notion.rb +33 -0
- data/lib/better_auth/social_providers/paybin.rb +31 -0
- data/lib/better_auth/social_providers/paypal.rb +36 -0
- data/lib/better_auth/social_providers/polar.rb +31 -0
- data/lib/better_auth/social_providers/railway.rb +49 -0
- data/lib/better_auth/social_providers/reddit.rb +32 -0
- data/lib/better_auth/social_providers/roblox.rb +31 -0
- data/lib/better_auth/social_providers/salesforce.rb +38 -0
- data/lib/better_auth/social_providers/slack.rb +30 -0
- data/lib/better_auth/social_providers/spotify.rb +31 -0
- data/lib/better_auth/social_providers/tiktok.rb +35 -0
- data/lib/better_auth/social_providers/twitch.rb +39 -0
- data/lib/better_auth/social_providers/twitter.rb +32 -0
- data/lib/better_auth/social_providers/vercel.rb +47 -0
- data/lib/better_auth/social_providers/vk.rb +34 -0
- data/lib/better_auth/social_providers/wechat.rb +104 -0
- data/lib/better_auth/social_providers/zoom.rb +31 -0
- data/lib/better_auth/social_providers.rb +29 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +0 -1
- metadata +30 -15
|
@@ -148,18 +148,26 @@ module BetterAuth
|
|
|
148
148
|
def read_storage((type, storage), key)
|
|
149
149
|
data = storage.get(key)
|
|
150
150
|
data = JSON.parse(data) if type == :secondary && data.is_a?(String)
|
|
151
|
-
symbolize_keys(data)
|
|
151
|
+
normalize_rate_limit_data(symbolize_keys(data))
|
|
152
152
|
rescue JSON::ParserError
|
|
153
153
|
nil
|
|
154
154
|
end
|
|
155
155
|
|
|
156
156
|
def write_storage((type, storage), key, data, ttl:, update:)
|
|
157
|
-
value = (type == :secondary) ? JSON.generate(data) : data
|
|
157
|
+
value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
|
|
158
158
|
return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
|
|
159
159
|
|
|
160
160
|
call_storage_set(storage, key, value, ttl: ttl, update: update)
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
def secondary_storage_data(data)
|
|
164
|
+
{
|
|
165
|
+
key: data[:key],
|
|
166
|
+
count: data[:count],
|
|
167
|
+
lastRequest: (data[:last_request].to_f * 1000).to_i
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
|
|
163
171
|
def call_secondary_storage_set(storage, key, value, ttl:, update:)
|
|
164
172
|
storage.set(key, value, ttl)
|
|
165
173
|
rescue ArgumentError
|
|
@@ -188,6 +196,15 @@ module BetterAuth
|
|
|
188
196
|
end
|
|
189
197
|
end
|
|
190
198
|
|
|
199
|
+
def normalize_rate_limit_data(data)
|
|
200
|
+
return data unless data.is_a?(Hash)
|
|
201
|
+
|
|
202
|
+
last_request = data[:last_request]
|
|
203
|
+
return data unless last_request.is_a?(Numeric) && last_request > 10_000_000_000
|
|
204
|
+
|
|
205
|
+
data.merge(last_request: last_request / 1000.0)
|
|
206
|
+
end
|
|
207
|
+
|
|
191
208
|
def rate_limit_key(ip, path)
|
|
192
209
|
"#{ip}|#{path}"
|
|
193
210
|
end
|
data/lib/better_auth/router.rb
CHANGED
|
@@ -64,6 +64,7 @@ module BetterAuth
|
|
|
64
64
|
|
|
65
65
|
body = parse_body(request)
|
|
66
66
|
endpoint_context = build_endpoint_context(request, route_path, query, body, params)
|
|
67
|
+
return run_on_response_chain(forbidden) if server_only?(endpoint)
|
|
67
68
|
|
|
68
69
|
response = @origin_check.call(endpoint_context)
|
|
69
70
|
return run_on_response_chain(response) if response
|
|
@@ -166,13 +167,17 @@ module BetterAuth
|
|
|
166
167
|
request.body.rewind
|
|
167
168
|
return {} if raw.empty?
|
|
168
169
|
|
|
169
|
-
if request.media_type
|
|
170
|
+
if json_media_type?(request.media_type)
|
|
170
171
|
JSON.parse(raw)
|
|
171
172
|
else
|
|
172
173
|
request.POST
|
|
173
174
|
end
|
|
174
175
|
end
|
|
175
176
|
|
|
177
|
+
def json_media_type?(media_type)
|
|
178
|
+
media_type == "application/json" || media_type.to_s.end_with?("+json")
|
|
179
|
+
end
|
|
180
|
+
|
|
176
181
|
def allowed_media_type?(request, endpoint)
|
|
177
182
|
return true unless request_body_method?(request.request_method)
|
|
178
183
|
return true if request.media_type.nil? || request.media_type.empty?
|
|
@@ -350,6 +355,14 @@ module BetterAuth
|
|
|
350
355
|
[415, {"content-type" => "application/json"}, [JSON.generate({error: "Unsupported Media Type"})]]
|
|
351
356
|
end
|
|
352
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
|
+
|
|
353
366
|
def error_response(error, headers: {})
|
|
354
367
|
Endpoint::Result.new(
|
|
355
368
|
response: error.to_h,
|
|
@@ -35,6 +35,7 @@ module BetterAuth
|
|
|
35
35
|
Endpoint.new(path: "/verify-email", method: "GET") do |ctx|
|
|
36
36
|
token = fetch_value(ctx.query, "token").to_s
|
|
37
37
|
callback_url = fetch_value(ctx.query, "callbackURL")
|
|
38
|
+
validate_callback_url!(ctx.context, callback_url)
|
|
38
39
|
payload = verify_email_token(ctx, token, callback_url)
|
|
39
40
|
email = payload["email"].to_s.downcase
|
|
40
41
|
update_to = payload["updateTo"] || payload["update_to"]
|
|
@@ -43,8 +44,10 @@ module BetterAuth
|
|
|
43
44
|
|
|
44
45
|
user = user_data[:user]
|
|
45
46
|
if update_to
|
|
46
|
-
updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified:
|
|
47
|
-
|
|
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)
|
|
48
51
|
next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
|
|
49
52
|
end
|
|
50
53
|
|
|
@@ -41,6 +41,7 @@ module BetterAuth
|
|
|
41
41
|
Endpoint.new(path: "/reset-password/:token", method: "GET") do |ctx|
|
|
42
42
|
token = ctx.params[:token].to_s
|
|
43
43
|
callback_url = fetch_value(ctx.query, "callbackURL") || "/error"
|
|
44
|
+
validate_callback_url!(ctx.context, callback_url)
|
|
44
45
|
verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
|
|
45
46
|
|
|
46
47
|
unless verification && !expired_time?(verification["expiresAt"])
|
|
@@ -152,6 +153,7 @@ module BetterAuth
|
|
|
152
153
|
end
|
|
153
154
|
|
|
154
155
|
def self.absolute_callback(context, callback_url, params)
|
|
156
|
+
validate_callback_url!(context, callback_url)
|
|
155
157
|
uri = URI.parse(callback_url.to_s)
|
|
156
158
|
origin = Configuration.origin_for(URI.parse(context.base_url))
|
|
157
159
|
url = uri.relative? ? URI.join("#{origin}/", callback_url.to_s.delete_prefix("/")) : uri
|
|
@@ -160,5 +162,22 @@ module BetterAuth
|
|
|
160
162
|
url.query = URI.encode_www_form(query)
|
|
161
163
|
url.to_s
|
|
162
164
|
end
|
|
165
|
+
|
|
166
|
+
def self.validate_callback_url!(context, callback_url)
|
|
167
|
+
return if callback_url.nil? || callback_url.to_s.empty?
|
|
168
|
+
|
|
169
|
+
value = callback_url.to_s
|
|
170
|
+
if value.start_with?("/")
|
|
171
|
+
return if Configuration.relative_path_allowed?(value)
|
|
172
|
+
else
|
|
173
|
+
uri = Configuration.parse_uri(value)
|
|
174
|
+
base_uri = Configuration.parse_uri(context.base_url.to_s)
|
|
175
|
+
base_origin = base_uri && Configuration.origin_for(base_uri)
|
|
176
|
+
return if uri && Configuration.origin_for(uri) == base_origin
|
|
177
|
+
return if context.trusted_origin?(value)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"])
|
|
181
|
+
end
|
|
163
182
|
end
|
|
164
183
|
end
|
|
@@ -34,10 +34,11 @@ module BetterAuth
|
|
|
34
34
|
body = Routes.parse_declared_input(ctx, "session", ctx.body, allowed_base: [])
|
|
35
35
|
raise APIError.new("BAD_REQUEST", message: "No fields to update") if body.empty?
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
update = body.merge("updatedAt" => Time.now)
|
|
38
|
+
updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
|
|
39
|
+
merged = session[:session].merge(updated || update)
|
|
39
40
|
Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
|
|
40
|
-
ctx.json({
|
|
41
|
+
ctx.json(parsed_session_response(ctx, {session: merged, user: session[:user]}))
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
@@ -91,12 +92,34 @@ module BetterAuth
|
|
|
91
92
|
|
|
92
93
|
raise APIError.new("UNAUTHORIZED") unless data
|
|
93
94
|
|
|
95
|
+
session = stringify_keys(data[:session] || data["session"])
|
|
96
|
+
ensure_fresh_session!(ctx, session) if sensitive
|
|
97
|
+
|
|
94
98
|
{
|
|
95
|
-
session:
|
|
99
|
+
session: session,
|
|
96
100
|
user: stringify_keys(data[:user] || data["user"])
|
|
97
101
|
}
|
|
98
102
|
end
|
|
99
103
|
|
|
104
|
+
def self.ensure_fresh_session!(ctx, session)
|
|
105
|
+
fresh_age = ctx.context.session_config[:fresh_age].to_i
|
|
106
|
+
return if fresh_age.zero?
|
|
107
|
+
|
|
108
|
+
created_at = normalize_time(session["createdAt"])
|
|
109
|
+
return unless created_at && Time.now - created_at >= fresh_age
|
|
110
|
+
|
|
111
|
+
raise APIError.new("FORBIDDEN", code: "SESSION_NOT_FRESH", message: BASE_ERROR_CODES.fetch("SESSION_NOT_FRESH"))
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.normalize_time(value)
|
|
115
|
+
return value if value.is_a?(Time)
|
|
116
|
+
return nil if value.nil? || value.to_s.empty?
|
|
117
|
+
|
|
118
|
+
Time.parse(value.to_s)
|
|
119
|
+
rescue ArgumentError
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
100
123
|
def self.parsed_session_response(ctx, session)
|
|
101
124
|
{
|
|
102
125
|
session: Schema.parse_output(ctx.context.options, "session", session[:session]),
|
|
@@ -17,7 +17,7 @@ module BetterAuth
|
|
|
17
17
|
) do |ctx|
|
|
18
18
|
options = ctx.context.options
|
|
19
19
|
email_config = options.email_and_password
|
|
20
|
-
if email_config[:enabled]
|
|
20
|
+
if email_config[:enabled] != true
|
|
21
21
|
raise APIError.new("BAD_REQUEST", message: "Email and password is not enabled")
|
|
22
22
|
end
|
|
23
23
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "uri"
|
|
4
|
+
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module BetterAuth
|
|
6
7
|
module Routes
|
|
@@ -19,7 +20,7 @@ module BetterAuth
|
|
|
19
20
|
) do |ctx|
|
|
20
21
|
options = ctx.context.options
|
|
21
22
|
email_config = options.email_and_password
|
|
22
|
-
if email_config[:enabled]
|
|
23
|
+
if email_config[:enabled] != true || email_config[:disable_sign_up]
|
|
23
24
|
raise APIError.new("BAD_REQUEST", message: "Email and password sign up is not enabled")
|
|
24
25
|
end
|
|
25
26
|
|
|
@@ -36,6 +37,13 @@ module BetterAuth
|
|
|
36
37
|
ctx.context.adapter.transaction do
|
|
37
38
|
existing = ctx.context.internal_adapter.find_user_by_email(email)
|
|
38
39
|
if existing
|
|
40
|
+
if email_config[:require_email_verification]
|
|
41
|
+
hash_password(ctx, password)
|
|
42
|
+
call_existing_sign_up_callback(ctx, email_config, existing)
|
|
43
|
+
synthetic_user = synthetic_sign_up_user(ctx, body, email, name, image)
|
|
44
|
+
next ctx.json({token: nil, user: Schema.parse_output(options, "user", synthetic_user)})
|
|
45
|
+
end
|
|
46
|
+
|
|
39
47
|
raise APIError.new(
|
|
40
48
|
"UNPROCESSABLE_ENTITY",
|
|
41
49
|
message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]
|
|
@@ -110,6 +118,49 @@ module BetterAuth
|
|
|
110
118
|
raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"])
|
|
111
119
|
end
|
|
112
120
|
|
|
121
|
+
def self.call_existing_sign_up_callback(ctx, email_config, existing)
|
|
122
|
+
callback = email_config[:on_existing_user_sign_up]
|
|
123
|
+
return unless callback.respond_to?(:call)
|
|
124
|
+
|
|
125
|
+
user = existing[:user] || existing["user"] || existing
|
|
126
|
+
data = {user: user}
|
|
127
|
+
if callback.arity == 1
|
|
128
|
+
callback.call(data)
|
|
129
|
+
else
|
|
130
|
+
callback.call(data, ctx.request)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def self.synthetic_sign_up_user(ctx, body, email, name, image)
|
|
135
|
+
now = Time.now
|
|
136
|
+
core_fields = {
|
|
137
|
+
"id" => SecureRandom.hex(16),
|
|
138
|
+
"name" => name,
|
|
139
|
+
"email" => email.to_s.downcase,
|
|
140
|
+
"emailVerified" => false,
|
|
141
|
+
"image" => image,
|
|
142
|
+
"createdAt" => now,
|
|
143
|
+
"updatedAt" => now
|
|
144
|
+
}
|
|
145
|
+
reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
|
|
146
|
+
additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
|
|
147
|
+
custom = ctx.context.options.email_and_password[:custom_synthetic_user]
|
|
148
|
+
return core_fields.merge(additional) unless custom.respond_to?(:call)
|
|
149
|
+
|
|
150
|
+
value = {
|
|
151
|
+
core_fields: core_fields.except("id"),
|
|
152
|
+
additional_fields: additional,
|
|
153
|
+
id: core_fields["id"]
|
|
154
|
+
}
|
|
155
|
+
stringify_synthetic_user(custom.call(value))
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def self.stringify_synthetic_user(value)
|
|
159
|
+
return value.each_with_object({}) { |(key, object_value), result| result[Schema.storage_key(key)] = object_value } if value.is_a?(Hash)
|
|
160
|
+
|
|
161
|
+
{}
|
|
162
|
+
end
|
|
163
|
+
|
|
113
164
|
def self.send_sign_up_verification_email(ctx, user, callback_url)
|
|
114
165
|
verification = ctx.context.options.email_verification
|
|
115
166
|
password_config = ctx.context.options.email_and_password
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "uri"
|
|
4
|
+
require "json"
|
|
4
5
|
require "securerandom"
|
|
5
6
|
|
|
6
7
|
module BetterAuth
|
|
@@ -11,11 +12,23 @@ module BetterAuth
|
|
|
11
12
|
provider_id = body["provider"].to_s
|
|
12
13
|
provider = social_provider(ctx.context, provider_id)
|
|
13
14
|
raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
|
|
15
|
+
validate_social_callback_url!(ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL")
|
|
16
|
+
validate_social_callback_url!(ctx.context, body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "INVALID_ERROR_CALLBACK_URL")
|
|
17
|
+
validate_social_callback_url!(ctx.context, body["newUserCallbackURL"] || body["newUserCallbackUrl"] || body["new_user_callback_url"], "INVALID_NEW_USER_CALLBACK_URL")
|
|
14
18
|
|
|
15
19
|
id_token = fetch_value(body, "idToken")
|
|
16
20
|
if id_token
|
|
17
21
|
data = social_user_from_id_token!(ctx, provider, id_token)
|
|
18
|
-
session_data = persist_social_user(
|
|
22
|
+
session_data = persist_social_user(
|
|
23
|
+
ctx,
|
|
24
|
+
provider_id,
|
|
25
|
+
data[:user],
|
|
26
|
+
token_hash_for_storage(ctx, data[:account]),
|
|
27
|
+
callback_url: body["callbackURL"],
|
|
28
|
+
disable_sign_up: provider_disable_sign_up?(provider) || (provider_disable_implicit_sign_up?(provider) && !body["requestSignUp"])
|
|
29
|
+
)
|
|
30
|
+
raise APIError.new("UNAUTHORIZED", message: session_data[:error], code: "OAUTH_LINK_ERROR") if session_data[:error]
|
|
31
|
+
|
|
19
32
|
Cookies.set_session_cookie(ctx, session_data)
|
|
20
33
|
next ctx.json({
|
|
21
34
|
redirect: false,
|
|
@@ -25,17 +38,18 @@ module BetterAuth
|
|
|
25
38
|
})
|
|
26
39
|
end
|
|
27
40
|
|
|
41
|
+
code_verifier = SecureRandom.hex(16)
|
|
28
42
|
state = Crypto.sign_jwt(
|
|
29
43
|
{
|
|
30
44
|
"callbackURL" => body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || "/",
|
|
31
45
|
"errorCallbackURL" => body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"],
|
|
32
46
|
"newUserCallbackURL" => body["newUserCallbackURL"] || body["newUserCallbackUrl"] || body["new_user_callback_url"],
|
|
33
|
-
"requestSignUp" => body["requestSignUp"] || body["request_sign_up"]
|
|
34
|
-
|
|
47
|
+
"requestSignUp" => body["requestSignUp"] || body["request_sign_up"],
|
|
48
|
+
"codeVerifier" => code_verifier
|
|
49
|
+
}.merge(safe_additional_state(body)),
|
|
35
50
|
ctx.context.secret,
|
|
36
51
|
expires_in: 600
|
|
37
52
|
)
|
|
38
|
-
code_verifier = SecureRandom.hex(16)
|
|
39
53
|
url = call_provider(provider, :create_authorization_url, {
|
|
40
54
|
state: state,
|
|
41
55
|
codeVerifier: code_verifier,
|
|
@@ -56,17 +70,25 @@ module BetterAuth
|
|
|
56
70
|
method: ["GET", "POST"],
|
|
57
71
|
metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}
|
|
58
72
|
) do |ctx|
|
|
59
|
-
|
|
73
|
+
if ctx.method == "POST"
|
|
74
|
+
merged = normalize_hash(ctx.query).merge(normalize_hash(ctx.body))
|
|
75
|
+
query = URI.encode_www_form(merged.reject { |_key, value| value.nil? || value.to_s.empty? })
|
|
76
|
+
target = "#{ctx.context.base_url}/callback/#{fetch_value(ctx.params, "providerId")}"
|
|
77
|
+
target = "#{target}?#{query}" unless query.empty?
|
|
78
|
+
raise ctx.redirect(target)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
source = ctx.query
|
|
60
82
|
data = normalize_hash(source)
|
|
61
83
|
provider_id = fetch_value(ctx.params, "providerId").to_s
|
|
62
84
|
provider = social_provider(ctx.context, provider_id)
|
|
63
85
|
state = data["state"].to_s
|
|
64
|
-
state_data = Crypto.verify_jwt(state, ctx.context.secret)
|
|
65
|
-
error_url = state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error"
|
|
86
|
+
state_data = state.empty? ? nil : Crypto.verify_jwt(state, ctx.context.secret)
|
|
87
|
+
error_url = state_data ? (state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error") : "#{ctx.context.base_url}/error"
|
|
66
88
|
|
|
67
89
|
raise ctx.redirect(oauth_error_url(error_url, data["error"], data["errorDescription"] || data["error_description"])) if data["error"]
|
|
68
90
|
raise ctx.redirect(oauth_error_url(error_url, "oauth_provider_not_found")) unless provider
|
|
69
|
-
raise ctx.redirect(oauth_error_url(error_url, "state_not_found"))
|
|
91
|
+
raise ctx.redirect(oauth_error_url(error_url, "state_not_found")) unless state_data
|
|
70
92
|
raise ctx.redirect(oauth_error_url(error_url, "no_code")) if data["code"].to_s.empty?
|
|
71
93
|
|
|
72
94
|
tokens = call_provider(provider, :validate_authorization_code, {
|
|
@@ -78,14 +100,33 @@ module BetterAuth
|
|
|
78
100
|
})
|
|
79
101
|
raise ctx.redirect(oauth_error_url(error_url, "invalid_code")) unless tokens
|
|
80
102
|
|
|
81
|
-
|
|
103
|
+
token_data = token_hash(tokens)
|
|
104
|
+
token_data["user"] = parse_json_hash(data["user"]) if data["user"]
|
|
105
|
+
user_info = call_provider(provider, :get_user_info, token_data)
|
|
82
106
|
user = user_info[:user] || user_info["user"] if user_info
|
|
83
107
|
raise ctx.redirect(oauth_error_url(error_url, "unable_to_get_user_info")) unless user
|
|
84
108
|
raise ctx.redirect(oauth_error_url(error_url, "email_not_found")) if fetch_value(user, "email").to_s.empty?
|
|
85
109
|
|
|
86
|
-
|
|
110
|
+
link = state_data["link"] || state_data[:link]
|
|
111
|
+
if link
|
|
112
|
+
linked = link_social_account_from_callback(ctx, provider_id, user, tokens, link)
|
|
113
|
+
raise ctx.redirect(oauth_error_url(error_url, linked[:error])) if linked[:error]
|
|
114
|
+
|
|
115
|
+
raise ctx.redirect(state_data["callbackURL"] || "/")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
account_info = token_hash_for_storage(ctx, tokens).merge("accountId" => fetch_value(user, "id").to_s)
|
|
119
|
+
session_data = persist_social_user(
|
|
120
|
+
ctx,
|
|
121
|
+
provider_id,
|
|
122
|
+
user,
|
|
123
|
+
account_info,
|
|
124
|
+
callback_url: state_data["callbackURL"],
|
|
125
|
+
disable_sign_up: provider_disable_sign_up?(provider) || (provider_disable_implicit_sign_up?(provider) && !state_data["requestSignUp"])
|
|
126
|
+
)
|
|
127
|
+
raise ctx.redirect(oauth_error_url(error_url, session_data[:error].tr(" ", "_"))) if session_data[:error]
|
|
87
128
|
Cookies.set_session_cookie(ctx, session_data)
|
|
88
|
-
callback_url = state_data["callbackURL"] || "/"
|
|
129
|
+
callback_url = session_data[:new_user] ? (state_data["newUserCallbackURL"] || state_data["callbackURL"] || "/") : (state_data["callbackURL"] || "/")
|
|
89
130
|
raise ctx.redirect(callback_url)
|
|
90
131
|
end
|
|
91
132
|
end
|
|
@@ -97,11 +138,16 @@ module BetterAuth
|
|
|
97
138
|
provider_id = body["provider"].to_s
|
|
98
139
|
provider = social_provider(ctx.context, provider_id)
|
|
99
140
|
raise APIError.new("NOT_FOUND", message: BASE_ERROR_CODES["PROVIDER_NOT_FOUND"]) unless provider
|
|
141
|
+
validate_social_callback_url!(ctx.context, body["callbackURL"] || body["callbackUrl"] || body["callback_url"], "INVALID_CALLBACK_URL")
|
|
142
|
+
validate_social_callback_url!(ctx.context, body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"], "INVALID_ERROR_CALLBACK_URL")
|
|
100
143
|
|
|
101
144
|
id_token = fetch_value(body, "idToken")
|
|
102
145
|
if id_token
|
|
103
146
|
data = social_user_from_id_token!(ctx, provider, id_token)
|
|
104
147
|
email = fetch_value(data[:user], "email").to_s.downcase
|
|
148
|
+
unless linkable_provider?(ctx, provider_id, data[:user])
|
|
149
|
+
raise APIError.new("UNAUTHORIZED", message: "Account not linked - untrusted provider")
|
|
150
|
+
end
|
|
105
151
|
unless email == session[:user]["email"].to_s.downcase || ctx.context.options.account.dig(:account_linking, :allow_different_emails)
|
|
106
152
|
raise APIError.new("UNAUTHORIZED", message: "Account not linked - different emails not allowed")
|
|
107
153
|
end
|
|
@@ -111,12 +157,35 @@ module BetterAuth
|
|
|
111
157
|
account["providerId"] == provider_id && account["accountId"] == account_id
|
|
112
158
|
end
|
|
113
159
|
unless existing
|
|
114
|
-
ctx.context.internal_adapter.create_account(data[:account].merge("userId" => session[:user]["id"]))
|
|
160
|
+
ctx.context.internal_adapter.create_account(token_hash_for_storage(ctx, data[:account]).merge("userId" => session[:user]["id"]))
|
|
115
161
|
end
|
|
162
|
+
update_verified_email_on_link(ctx, session[:user]["id"], session[:user]["email"], data[:user])
|
|
116
163
|
next ctx.json({url: "", status: true, redirect: false})
|
|
117
164
|
end
|
|
118
165
|
|
|
119
|
-
|
|
166
|
+
code_verifier = SecureRandom.hex(16)
|
|
167
|
+
state_data = {
|
|
168
|
+
"callbackURL" => body["callbackURL"] || body["callbackUrl"] || body["callback_url"] || ctx.context.base_url,
|
|
169
|
+
"errorCallbackURL" => body["errorCallbackURL"] || body["errorCallbackUrl"] || body["error_callback_url"],
|
|
170
|
+
"requestSignUp" => body["requestSignUp"] || body["request_sign_up"],
|
|
171
|
+
"codeVerifier" => code_verifier,
|
|
172
|
+
"link" => {
|
|
173
|
+
"userId" => session[:user]["id"],
|
|
174
|
+
"email" => session[:user]["email"]
|
|
175
|
+
}
|
|
176
|
+
}.merge(safe_additional_state(body))
|
|
177
|
+
state = Crypto.sign_jwt(state_data, ctx.context.secret, expires_in: 600)
|
|
178
|
+
url = call_provider(provider, :create_authorization_url, {
|
|
179
|
+
state: state,
|
|
180
|
+
codeVerifier: code_verifier,
|
|
181
|
+
code_verifier: code_verifier,
|
|
182
|
+
redirectURI: "#{ctx.context.base_url}/callback/#{provider_id}",
|
|
183
|
+
redirect_uri: "#{ctx.context.base_url}/callback/#{provider_id}",
|
|
184
|
+
scopes: body["scopes"],
|
|
185
|
+
loginHint: body["loginHint"] || body["login_hint"]
|
|
186
|
+
})
|
|
187
|
+
ctx.set_header("location", url.to_s) unless body["disableRedirect"] || body["disable_redirect"]
|
|
188
|
+
ctx.json({url: url.to_s, redirect: !(body["disableRedirect"] || body["disable_redirect"])})
|
|
120
189
|
end
|
|
121
190
|
end
|
|
122
191
|
|
|
@@ -125,13 +194,15 @@ module BetterAuth
|
|
|
125
194
|
valid = call_provider(provider, :verify_id_token, token, fetch_value(id_token, "nonce"))
|
|
126
195
|
raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless valid
|
|
127
196
|
|
|
197
|
+
token_user = parse_json_hash(fetch_value(id_token, "user"))
|
|
128
198
|
user_info = call_provider(provider, :get_user_info, {
|
|
129
|
-
idToken
|
|
130
|
-
id_token
|
|
131
|
-
accessToken
|
|
132
|
-
access_token
|
|
133
|
-
refreshToken
|
|
134
|
-
refresh_token
|
|
199
|
+
"idToken" => token,
|
|
200
|
+
"id_token" => token,
|
|
201
|
+
"accessToken" => fetch_value(id_token, "accessToken"),
|
|
202
|
+
"access_token" => fetch_value(id_token, "accessToken"),
|
|
203
|
+
"refreshToken" => fetch_value(id_token, "refreshToken"),
|
|
204
|
+
"refresh_token" => fetch_value(id_token, "refreshToken"),
|
|
205
|
+
"user" => token_user
|
|
135
206
|
})
|
|
136
207
|
user = user_info[:user] || user_info["user"] if user_info
|
|
137
208
|
raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_USER_INFO"]) unless user
|
|
@@ -144,22 +215,30 @@ module BetterAuth
|
|
|
144
215
|
"accountId" => fetch_value(user, "id").to_s,
|
|
145
216
|
"accessToken" => fetch_value(id_token, "accessToken"),
|
|
146
217
|
"refreshToken" => fetch_value(id_token, "refreshToken"),
|
|
147
|
-
"idToken" => token
|
|
218
|
+
"idToken" => token,
|
|
219
|
+
"user" => token_user
|
|
148
220
|
}
|
|
149
221
|
}
|
|
150
222
|
end
|
|
151
223
|
|
|
152
|
-
def self.persist_social_user(ctx, provider_id, user_info, account_info)
|
|
224
|
+
def self.persist_social_user(ctx, provider_id, user_info, account_info, callback_url: nil, disable_sign_up: false)
|
|
153
225
|
email = fetch_value(user_info, "email").to_s.downcase
|
|
154
226
|
account_id = (account_info["accountId"] || account_info[:accountId] || account_info[:account_id] || fetch_value(user_info, "id")).to_s
|
|
155
227
|
existing = ctx.context.internal_adapter.find_oauth_user(email, account_id, provider_id)
|
|
156
228
|
|
|
157
229
|
if existing && existing[:linked_account]
|
|
158
230
|
user = existing[:user]
|
|
231
|
+
new_user = false
|
|
159
232
|
elsif existing
|
|
233
|
+
unless linkable_provider?(ctx, provider_id, user_info, implicit: true)
|
|
234
|
+
return {error: "account not linked"}
|
|
235
|
+
end
|
|
160
236
|
user = existing[:user]
|
|
161
237
|
ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
|
|
238
|
+
new_user = false
|
|
162
239
|
else
|
|
240
|
+
return {error: "signup disabled"} if disable_sign_up
|
|
241
|
+
|
|
163
242
|
created = ctx.context.internal_adapter.create_oauth_user(
|
|
164
243
|
{
|
|
165
244
|
email: email,
|
|
@@ -170,10 +249,11 @@ module BetterAuth
|
|
|
170
249
|
account_info.merge("providerId" => provider_id, "accountId" => account_id)
|
|
171
250
|
)
|
|
172
251
|
user = created[:user]
|
|
252
|
+
new_user = true
|
|
173
253
|
end
|
|
174
254
|
|
|
175
255
|
session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
|
|
176
|
-
{session: session, user: user}
|
|
256
|
+
{session: session, user: user, new_user: new_user}
|
|
177
257
|
end
|
|
178
258
|
|
|
179
259
|
def self.oauth_error_url(base_url, error, description = nil)
|
|
@@ -184,5 +264,104 @@ module BetterAuth
|
|
|
184
264
|
uri.query = URI.encode_www_form(query)
|
|
185
265
|
uri.to_s
|
|
186
266
|
end
|
|
267
|
+
|
|
268
|
+
def self.provider_disable_implicit_sign_up?(provider)
|
|
269
|
+
!!(fetch_value(provider, "disableImplicitSignUp") || fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def self.provider_disable_sign_up?(provider)
|
|
273
|
+
!!(fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def self.linkable_provider?(ctx, provider_id, user_info, implicit: false)
|
|
277
|
+
linking = ctx.context.options.account[:account_linking] || {}
|
|
278
|
+
return false if linking[:enabled] == false
|
|
279
|
+
return false if implicit && linking[:disable_implicit_linking] == true
|
|
280
|
+
|
|
281
|
+
trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
|
|
282
|
+
trusted || !!fetch_value(user_info, "emailVerified")
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.link_social_account_from_callback(ctx, provider_id, user_info, tokens, link)
|
|
286
|
+
return {error: "unable_to_link_account"} unless linkable_provider?(ctx, provider_id, user_info)
|
|
287
|
+
|
|
288
|
+
email = fetch_value(user_info, "email").to_s.downcase
|
|
289
|
+
link_email = fetch_value(link, "email").to_s.downcase
|
|
290
|
+
unless email == link_email || ctx.context.options.account.dig(:account_linking, :allow_different_emails)
|
|
291
|
+
return {error: "email_doesn't_match"}
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
account_id = fetch_value(user_info, "id").to_s
|
|
295
|
+
user_id = fetch_value(link, "userId").to_s
|
|
296
|
+
account_info = token_hash_for_storage(ctx, tokens).merge(
|
|
297
|
+
"providerId" => provider_id,
|
|
298
|
+
"accountId" => account_id,
|
|
299
|
+
"userId" => user_id
|
|
300
|
+
)
|
|
301
|
+
existing = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id)
|
|
302
|
+
if existing
|
|
303
|
+
return {error: "account_already_linked_to_different_user"} if existing["userId"].to_s != user_id
|
|
304
|
+
|
|
305
|
+
ctx.context.internal_adapter.update_account(existing["id"], account_info)
|
|
306
|
+
else
|
|
307
|
+
ctx.context.internal_adapter.create_account(account_info)
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
if ctx.context.options.account.dig(:account_linking, :update_user_info_on_link)
|
|
311
|
+
ctx.context.internal_adapter.update_user(user_id, {
|
|
312
|
+
name: fetch_value(user_info, "name"),
|
|
313
|
+
image: fetch_value(user_info, "image")
|
|
314
|
+
}.compact)
|
|
315
|
+
end
|
|
316
|
+
update_verified_email_on_link(ctx, user_id, link_email, user_info)
|
|
317
|
+
|
|
318
|
+
{status: true}
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def self.safe_additional_state(body)
|
|
322
|
+
additional = body["additionalData"] || body["additional_data"]
|
|
323
|
+
return {} unless additional.is_a?(Hash)
|
|
324
|
+
|
|
325
|
+
reserved = %w[callbackURL callbackUrl callback_url errorCallbackURL errorCallbackUrl error_callback_url errorURL error_url newUserCallbackURL newUserCallbackUrl new_user_callback_url newUserURL new_user_url requestSignUp request_sign_up codeVerifier code_verifier link expiresAt expires_at]
|
|
326
|
+
normalize_hash(additional).reject { |key, _value| reserved.include?(key.to_s) }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def self.validate_social_callback_url!(context, callback_url, error_code)
|
|
330
|
+
validate_callback_url!(context, callback_url)
|
|
331
|
+
rescue APIError => error
|
|
332
|
+
return if oauth_proxy_callback_url?(context, callback_url)
|
|
333
|
+
raise error unless error.message == BASE_ERROR_CODES["INVALID_CALLBACK_URL"]
|
|
334
|
+
|
|
335
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES[error_code])
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def self.oauth_proxy_callback_url?(context, callback_url)
|
|
339
|
+
uri = URI.parse(callback_url.to_s)
|
|
340
|
+
proxy_path = "#{context.options.base_path}/oauth-proxy-callback"
|
|
341
|
+
return false unless uri.path == proxy_path
|
|
342
|
+
|
|
343
|
+
nested = URI.decode_www_form(uri.query.to_s).assoc("callbackURL")&.last
|
|
344
|
+
validate_callback_url!(context, nested)
|
|
345
|
+
true
|
|
346
|
+
rescue APIError, URI::InvalidURIError
|
|
347
|
+
false
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def self.update_verified_email_on_link(ctx, user_id, current_email, social_user)
|
|
351
|
+
return unless fetch_value(social_user, "emailVerified")
|
|
352
|
+
return unless fetch_value(social_user, "email").to_s.downcase == current_email.to_s.downcase
|
|
353
|
+
|
|
354
|
+
ctx.context.internal_adapter.update_user(user_id, {"emailVerified" => true})
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def self.parse_json_hash(value)
|
|
358
|
+
return value if value.is_a?(Hash)
|
|
359
|
+
return {} if value.nil? || value.to_s.empty?
|
|
360
|
+
|
|
361
|
+
parsed = JSON.parse(value.to_s)
|
|
362
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
363
|
+
rescue JSON::ParserError
|
|
364
|
+
{}
|
|
365
|
+
end
|
|
187
366
|
end
|
|
188
367
|
end
|