better_auth 0.2.0 → 0.4.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 +32 -0
- data/README.md +5 -3
- data/lib/better_auth/adapters/internal_adapter.rb +173 -20
- data/lib/better_auth/adapters/memory.rb +61 -12
- data/lib/better_auth/adapters/mongodb.rb +5 -365
- data/lib/better_auth/adapters/sql.rb +44 -3
- data/lib/better_auth/api.rb +7 -2
- data/lib/better_auth/async.rb +70 -0
- data/lib/better_auth/context.rb +2 -1
- data/lib/better_auth/database_hooks.rb +3 -3
- data/lib/better_auth/deprecate.rb +28 -0
- data/lib/better_auth/endpoint.rb +5 -2
- data/lib/better_auth/host.rb +166 -0
- data/lib/better_auth/instrumentation.rb +74 -0
- data/lib/better_auth/logger.rb +31 -0
- data/lib/better_auth/middleware/origin_check.rb +2 -2
- data/lib/better_auth/oauth2.rb +94 -0
- data/lib/better_auth/plugin.rb +14 -1
- data/lib/better_auth/plugins/email_otp.rb +16 -5
- data/lib/better_auth/plugins/generic_oauth.rb +14 -28
- data/lib/better_auth/plugins/oauth_protocol.rb +553 -64
- data/lib/better_auth/plugins/organization/schema.rb +6 -0
- data/lib/better_auth/plugins/organization.rb +56 -20
- data/lib/better_auth/plugins/two_factor.rb +53 -18
- data/lib/better_auth/rate_limiter.rb +37 -2
- data/lib/better_auth/request_state.rb +44 -0
- data/lib/better_auth/router.rb +14 -1
- data/lib/better_auth/routes/account.rb +16 -4
- data/lib/better_auth/routes/email_verification.rb +5 -2
- data/lib/better_auth/routes/password.rb +21 -1
- data/lib/better_auth/routes/session.rb +27 -4
- data/lib/better_auth/routes/sign_in.rb +3 -1
- data/lib/better_auth/routes/sign_up.rb +60 -1
- data/lib/better_auth/routes/social.rb +231 -22
- data/lib/better_auth/routes/user.rb +23 -5
- data/lib/better_auth/schema/sql.rb +11 -0
- data/lib/better_auth/schema.rb +16 -0
- data/lib/better_auth/session.rb +12 -1
- 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/url_helpers.rb +195 -0
- data/lib/better_auth/version.rb +1 -1
- data/lib/better_auth.rb +8 -1
- metadata +38 -15
|
@@ -14,6 +14,8 @@ module BetterAuth
|
|
|
14
14
|
|
|
15
15
|
body = normalize_hash(ctx.body)
|
|
16
16
|
email = body["email"].to_s.downcase
|
|
17
|
+
redirect_to = body["redirectTo"] || body["redirect_to"]
|
|
18
|
+
validate_callback_url!(ctx.context, redirect_to)
|
|
17
19
|
found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
|
|
18
20
|
unless found
|
|
19
21
|
SecureRandom.hex(12)
|
|
@@ -29,7 +31,6 @@ module BetterAuth
|
|
|
29
31
|
expiresAt: Time.now + expires_in.to_i
|
|
30
32
|
)
|
|
31
33
|
|
|
32
|
-
redirect_to = body["redirectTo"] || body["redirect_to"]
|
|
33
34
|
callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
|
|
34
35
|
url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
|
|
35
36
|
sender.call({user: found[:user], url: url, token: token}, ctx.request)
|
|
@@ -41,6 +42,7 @@ module BetterAuth
|
|
|
41
42
|
Endpoint.new(path: "/reset-password/:token", method: "GET") do |ctx|
|
|
42
43
|
token = ctx.params[:token].to_s
|
|
43
44
|
callback_url = fetch_value(ctx.query, "callbackURL") || "/error"
|
|
45
|
+
validate_callback_url!(ctx.context, callback_url)
|
|
44
46
|
verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
|
|
45
47
|
|
|
46
48
|
unless verification && !expired_time?(verification["expiresAt"])
|
|
@@ -152,6 +154,7 @@ module BetterAuth
|
|
|
152
154
|
end
|
|
153
155
|
|
|
154
156
|
def self.absolute_callback(context, callback_url, params)
|
|
157
|
+
validate_callback_url!(context, callback_url)
|
|
155
158
|
uri = URI.parse(callback_url.to_s)
|
|
156
159
|
origin = Configuration.origin_for(URI.parse(context.base_url))
|
|
157
160
|
url = uri.relative? ? URI.join("#{origin}/", callback_url.to_s.delete_prefix("/")) : uri
|
|
@@ -160,5 +163,22 @@ module BetterAuth
|
|
|
160
163
|
url.query = URI.encode_www_form(query)
|
|
161
164
|
url.to_s
|
|
162
165
|
end
|
|
166
|
+
|
|
167
|
+
def self.validate_callback_url!(context, callback_url)
|
|
168
|
+
return if callback_url.nil? || callback_url.to_s.empty?
|
|
169
|
+
|
|
170
|
+
value = callback_url.to_s
|
|
171
|
+
if value.start_with?("/")
|
|
172
|
+
return if Configuration.relative_path_allowed?(value)
|
|
173
|
+
else
|
|
174
|
+
uri = Configuration.parse_uri(value)
|
|
175
|
+
base_uri = Configuration.parse_uri(context.base_url.to_s)
|
|
176
|
+
base_origin = base_uri && Configuration.origin_for(base_uri)
|
|
177
|
+
return if uri && Configuration.origin_for(uri) == base_origin
|
|
178
|
+
return if context.trusted_origin?(value)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"])
|
|
182
|
+
end
|
|
163
183
|
end
|
|
164
184
|
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
|
|
|
@@ -27,6 +27,8 @@ module BetterAuth
|
|
|
27
27
|
callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
|
|
28
28
|
remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
|
|
29
29
|
|
|
30
|
+
validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
|
|
31
|
+
|
|
30
32
|
unless EMAIL_PATTERN.match?(email)
|
|
31
33
|
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
|
|
32
34
|
end
|
|
@@ -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
|
|
|
@@ -31,11 +32,19 @@ module BetterAuth
|
|
|
31
32
|
callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
|
|
32
33
|
remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
|
|
33
34
|
|
|
35
|
+
validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
|
|
34
36
|
validate_sign_up_input!(email, password, email_config)
|
|
35
37
|
|
|
36
38
|
ctx.context.adapter.transaction do
|
|
37
39
|
existing = ctx.context.internal_adapter.find_user_by_email(email)
|
|
38
40
|
if existing
|
|
41
|
+
if email_config[:require_email_verification]
|
|
42
|
+
hash_password(ctx, password)
|
|
43
|
+
call_existing_sign_up_callback(ctx, email_config, existing)
|
|
44
|
+
synthetic_user = synthetic_sign_up_user(ctx, body, email, name, image)
|
|
45
|
+
next ctx.json({token: nil, user: Schema.parse_output(options, "user", synthetic_user)})
|
|
46
|
+
end
|
|
47
|
+
|
|
39
48
|
raise APIError.new(
|
|
40
49
|
"UNPROCESSABLE_ENTITY",
|
|
41
50
|
message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]
|
|
@@ -93,6 +102,13 @@ module BetterAuth
|
|
|
93
102
|
end
|
|
94
103
|
end
|
|
95
104
|
|
|
105
|
+
def self.validate_auth_callback_url!(context, value, label)
|
|
106
|
+
return if value.nil? || value.to_s.empty?
|
|
107
|
+
return if context.trusted_origin?(value.to_s, allow_relative_paths: true)
|
|
108
|
+
|
|
109
|
+
raise APIError.new("FORBIDDEN", message: "Invalid #{label}")
|
|
110
|
+
end
|
|
111
|
+
|
|
96
112
|
def self.create_sign_up_user(ctx, body, email, name, image)
|
|
97
113
|
reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
|
|
98
114
|
additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
|
|
@@ -110,6 +126,49 @@ module BetterAuth
|
|
|
110
126
|
raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"])
|
|
111
127
|
end
|
|
112
128
|
|
|
129
|
+
def self.call_existing_sign_up_callback(ctx, email_config, existing)
|
|
130
|
+
callback = email_config[:on_existing_user_sign_up]
|
|
131
|
+
return unless callback.respond_to?(:call)
|
|
132
|
+
|
|
133
|
+
user = existing[:user] || existing["user"] || existing
|
|
134
|
+
data = {user: user}
|
|
135
|
+
if callback.arity == 1
|
|
136
|
+
callback.call(data)
|
|
137
|
+
else
|
|
138
|
+
callback.call(data, ctx.request)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.synthetic_sign_up_user(ctx, body, email, name, image)
|
|
143
|
+
now = Time.now
|
|
144
|
+
core_fields = {
|
|
145
|
+
"id" => SecureRandom.hex(16),
|
|
146
|
+
"name" => name,
|
|
147
|
+
"email" => email.to_s.downcase,
|
|
148
|
+
"emailVerified" => false,
|
|
149
|
+
"image" => image,
|
|
150
|
+
"createdAt" => now,
|
|
151
|
+
"updatedAt" => now
|
|
152
|
+
}
|
|
153
|
+
reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
|
|
154
|
+
additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
|
|
155
|
+
custom = ctx.context.options.email_and_password[:custom_synthetic_user]
|
|
156
|
+
return core_fields.merge(additional) unless custom.respond_to?(:call)
|
|
157
|
+
|
|
158
|
+
value = {
|
|
159
|
+
core_fields: core_fields.except("id"),
|
|
160
|
+
additional_fields: additional,
|
|
161
|
+
id: core_fields["id"]
|
|
162
|
+
}
|
|
163
|
+
stringify_synthetic_user(custom.call(value))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def self.stringify_synthetic_user(value)
|
|
167
|
+
return value.each_with_object({}) { |(key, object_value), result| result[Schema.storage_key(key)] = object_value } if value.is_a?(Hash)
|
|
168
|
+
|
|
169
|
+
{}
|
|
170
|
+
end
|
|
171
|
+
|
|
113
172
|
def self.send_sign_up_verification_email(ctx, user, callback_url)
|
|
114
173
|
verification = ctx.context.options.email_verification
|
|
115
174
|
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,38 @@ 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
|
+
if ctx.context.options.account[:update_account_on_sign_in] != false
|
|
232
|
+
update_data = account_storage_fields(account_info)
|
|
233
|
+
ctx.context.internal_adapter.update_account(existing[:linked_account]["id"], update_data) unless update_data.empty?
|
|
234
|
+
end
|
|
235
|
+
verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
|
|
236
|
+
user = verified_user if verified_user
|
|
237
|
+
new_user = false
|
|
159
238
|
elsif existing
|
|
239
|
+
unless linkable_provider?(ctx, provider_id, user_info, implicit: true)
|
|
240
|
+
return {error: "account not linked"}
|
|
241
|
+
end
|
|
160
242
|
user = existing[:user]
|
|
161
243
|
ctx.context.internal_adapter.create_account(account_info.merge("providerId" => provider_id, "accountId" => account_id, "userId" => user["id"]))
|
|
244
|
+
verified_user = update_verified_email_on_link(ctx, user["id"], user["email"], user_info)
|
|
245
|
+
user = verified_user if verified_user
|
|
246
|
+
new_user = false
|
|
162
247
|
else
|
|
248
|
+
return {error: "signup disabled"} if disable_sign_up
|
|
249
|
+
|
|
163
250
|
created = ctx.context.internal_adapter.create_oauth_user(
|
|
164
251
|
{
|
|
165
252
|
email: email,
|
|
@@ -170,10 +257,12 @@ module BetterAuth
|
|
|
170
257
|
account_info.merge("providerId" => provider_id, "accountId" => account_id)
|
|
171
258
|
)
|
|
172
259
|
user = created[:user]
|
|
260
|
+
new_user = true
|
|
173
261
|
end
|
|
262
|
+
user = override_social_user_info(ctx, user, user_info) if existing && provider_override_user_info_on_sign_in?(provider_id, ctx.context)
|
|
174
263
|
|
|
175
264
|
session = ctx.context.internal_adapter.create_session(user["id"], false, session_overrides(ctx), true, ctx)
|
|
176
|
-
{session: session, user: user}
|
|
265
|
+
{session: session, user: user, new_user: new_user}
|
|
177
266
|
end
|
|
178
267
|
|
|
179
268
|
def self.oauth_error_url(base_url, error, description = nil)
|
|
@@ -184,5 +273,125 @@ module BetterAuth
|
|
|
184
273
|
uri.query = URI.encode_www_form(query)
|
|
185
274
|
uri.to_s
|
|
186
275
|
end
|
|
276
|
+
|
|
277
|
+
def self.provider_disable_implicit_sign_up?(provider)
|
|
278
|
+
!!(fetch_value(provider, "disableImplicitSignUp") || fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def self.provider_disable_sign_up?(provider)
|
|
282
|
+
!!(fetch_value(provider, "disableSignUp") || fetch_value(fetch_value(provider, "options") || {}, "disableSignUp"))
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def self.linkable_provider?(ctx, provider_id, user_info, implicit: false)
|
|
286
|
+
linking = ctx.context.options.account[:account_linking] || {}
|
|
287
|
+
return false if linking[:enabled] == false
|
|
288
|
+
return false if implicit && linking[:disable_implicit_linking] == true
|
|
289
|
+
|
|
290
|
+
trusted = Array(linking[:trusted_providers]).map(&:to_s).include?(provider_id.to_s)
|
|
291
|
+
trusted || !!fetch_value(user_info, "emailVerified")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def self.link_social_account_from_callback(ctx, provider_id, user_info, tokens, link)
|
|
295
|
+
return {error: "unable_to_link_account"} unless linkable_provider?(ctx, provider_id, user_info)
|
|
296
|
+
|
|
297
|
+
email = fetch_value(user_info, "email").to_s.downcase
|
|
298
|
+
link_email = fetch_value(link, "email").to_s.downcase
|
|
299
|
+
unless email == link_email || ctx.context.options.account.dig(:account_linking, :allow_different_emails)
|
|
300
|
+
return {error: "email_doesn't_match"}
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
account_id = fetch_value(user_info, "id").to_s
|
|
304
|
+
user_id = fetch_value(link, "userId").to_s
|
|
305
|
+
account_info = token_hash_for_storage(ctx, tokens).merge(
|
|
306
|
+
"providerId" => provider_id,
|
|
307
|
+
"accountId" => account_id,
|
|
308
|
+
"userId" => user_id
|
|
309
|
+
)
|
|
310
|
+
existing = ctx.context.internal_adapter.find_account_by_provider_id(account_id, provider_id)
|
|
311
|
+
if existing
|
|
312
|
+
return {error: "account_already_linked_to_different_user"} if existing["userId"].to_s != user_id
|
|
313
|
+
|
|
314
|
+
ctx.context.internal_adapter.update_account(existing["id"], account_info)
|
|
315
|
+
else
|
|
316
|
+
ctx.context.internal_adapter.create_account(account_info)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
if ctx.context.options.account.dig(:account_linking, :update_user_info_on_link)
|
|
320
|
+
ctx.context.internal_adapter.update_user(user_id, {
|
|
321
|
+
name: fetch_value(user_info, "name"),
|
|
322
|
+
image: fetch_value(user_info, "image")
|
|
323
|
+
}.compact)
|
|
324
|
+
end
|
|
325
|
+
update_verified_email_on_link(ctx, user_id, link_email, user_info)
|
|
326
|
+
|
|
327
|
+
{status: true}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def self.provider_override_user_info_on_sign_in?(provider_id, context)
|
|
331
|
+
provider = social_provider(context, provider_id)
|
|
332
|
+
!!(fetch_value(provider, "overrideUserInfoOnSignIn") || fetch_value(fetch_value(provider, "options") || {}, "overrideUserInfoOnSignIn"))
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def self.override_social_user_info(ctx, user, user_info)
|
|
336
|
+
email = fetch_value(user_info, "email").to_s.downcase
|
|
337
|
+
email_verified = if email == user["email"].to_s.downcase
|
|
338
|
+
!!(user["emailVerified"] || fetch_value(user_info, "emailVerified"))
|
|
339
|
+
else
|
|
340
|
+
!!fetch_value(user_info, "emailVerified")
|
|
341
|
+
end
|
|
342
|
+
update = {
|
|
343
|
+
"email" => email,
|
|
344
|
+
"name" => fetch_value(user_info, "name").to_s,
|
|
345
|
+
"image" => fetch_value(user_info, "image"),
|
|
346
|
+
"emailVerified" => email_verified
|
|
347
|
+
}.reject { |_key, value| value.nil? }
|
|
348
|
+
ctx.context.internal_adapter.update_user(user["id"], update) || user
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def self.safe_additional_state(body)
|
|
352
|
+
additional = body["additionalData"] || body["additional_data"]
|
|
353
|
+
return {} unless additional.is_a?(Hash)
|
|
354
|
+
|
|
355
|
+
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]
|
|
356
|
+
normalize_hash(additional).reject { |key, _value| reserved.include?(key.to_s) }
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def self.validate_social_callback_url!(context, callback_url, error_code)
|
|
360
|
+
validate_callback_url!(context, callback_url)
|
|
361
|
+
rescue APIError => error
|
|
362
|
+
return if oauth_proxy_callback_url?(context, callback_url)
|
|
363
|
+
raise error unless error.message == BASE_ERROR_CODES["INVALID_CALLBACK_URL"]
|
|
364
|
+
|
|
365
|
+
raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES[error_code])
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def self.oauth_proxy_callback_url?(context, callback_url)
|
|
369
|
+
uri = URI.parse(callback_url.to_s)
|
|
370
|
+
proxy_path = "#{context.options.base_path}/oauth-proxy-callback"
|
|
371
|
+
return false unless uri.path == proxy_path
|
|
372
|
+
|
|
373
|
+
nested = URI.decode_www_form(uri.query.to_s).assoc("callbackURL")&.last
|
|
374
|
+
validate_callback_url!(context, nested)
|
|
375
|
+
true
|
|
376
|
+
rescue APIError, URI::InvalidURIError
|
|
377
|
+
false
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def self.update_verified_email_on_link(ctx, user_id, current_email, social_user)
|
|
381
|
+
return unless fetch_value(social_user, "emailVerified")
|
|
382
|
+
return unless fetch_value(social_user, "email").to_s.downcase == current_email.to_s.downcase
|
|
383
|
+
|
|
384
|
+
ctx.context.internal_adapter.update_user(user_id, {"emailVerified" => true})
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def self.parse_json_hash(value)
|
|
388
|
+
return value if value.is_a?(Hash)
|
|
389
|
+
return {} if value.nil? || value.to_s.empty?
|
|
390
|
+
|
|
391
|
+
parsed = JSON.parse(value.to_s)
|
|
392
|
+
parsed.is_a?(Hash) ? parsed : {}
|
|
393
|
+
rescue JSON::ParserError
|
|
394
|
+
{}
|
|
395
|
+
end
|
|
187
396
|
end
|
|
188
397
|
end
|