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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +5 -3
  4. data/lib/better_auth/adapters/internal_adapter.rb +173 -20
  5. data/lib/better_auth/adapters/memory.rb +61 -12
  6. data/lib/better_auth/adapters/mongodb.rb +5 -365
  7. data/lib/better_auth/adapters/sql.rb +44 -3
  8. data/lib/better_auth/api.rb +7 -2
  9. data/lib/better_auth/async.rb +70 -0
  10. data/lib/better_auth/context.rb +2 -1
  11. data/lib/better_auth/database_hooks.rb +3 -3
  12. data/lib/better_auth/deprecate.rb +28 -0
  13. data/lib/better_auth/endpoint.rb +5 -2
  14. data/lib/better_auth/host.rb +166 -0
  15. data/lib/better_auth/instrumentation.rb +74 -0
  16. data/lib/better_auth/logger.rb +31 -0
  17. data/lib/better_auth/middleware/origin_check.rb +2 -2
  18. data/lib/better_auth/oauth2.rb +94 -0
  19. data/lib/better_auth/plugin.rb +14 -1
  20. data/lib/better_auth/plugins/email_otp.rb +16 -5
  21. data/lib/better_auth/plugins/generic_oauth.rb +14 -28
  22. data/lib/better_auth/plugins/oauth_protocol.rb +553 -64
  23. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  24. data/lib/better_auth/plugins/organization.rb +56 -20
  25. data/lib/better_auth/plugins/two_factor.rb +53 -18
  26. data/lib/better_auth/rate_limiter.rb +37 -2
  27. data/lib/better_auth/request_state.rb +44 -0
  28. data/lib/better_auth/router.rb +14 -1
  29. data/lib/better_auth/routes/account.rb +16 -4
  30. data/lib/better_auth/routes/email_verification.rb +5 -2
  31. data/lib/better_auth/routes/password.rb +21 -1
  32. data/lib/better_auth/routes/session.rb +27 -4
  33. data/lib/better_auth/routes/sign_in.rb +3 -1
  34. data/lib/better_auth/routes/sign_up.rb +60 -1
  35. data/lib/better_auth/routes/social.rb +231 -22
  36. data/lib/better_auth/routes/user.rb +23 -5
  37. data/lib/better_auth/schema/sql.rb +11 -0
  38. data/lib/better_auth/schema.rb +16 -0
  39. data/lib/better_auth/session.rb +12 -1
  40. data/lib/better_auth/social_providers/apple.rb +44 -8
  41. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  42. data/lib/better_auth/social_providers/base.rb +262 -4
  43. data/lib/better_auth/social_providers/cognito.rb +32 -0
  44. data/lib/better_auth/social_providers/discord.rb +27 -5
  45. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  46. data/lib/better_auth/social_providers/facebook.rb +35 -0
  47. data/lib/better_auth/social_providers/figma.rb +31 -0
  48. data/lib/better_auth/social_providers/github.rb +21 -6
  49. data/lib/better_auth/social_providers/gitlab.rb +16 -3
  50. data/lib/better_auth/social_providers/google.rb +38 -13
  51. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  52. data/lib/better_auth/social_providers/kakao.rb +32 -0
  53. data/lib/better_auth/social_providers/kick.rb +32 -0
  54. data/lib/better_auth/social_providers/line.rb +33 -0
  55. data/lib/better_auth/social_providers/linear.rb +44 -0
  56. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  57. data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
  58. data/lib/better_auth/social_providers/naver.rb +31 -0
  59. data/lib/better_auth/social_providers/notion.rb +33 -0
  60. data/lib/better_auth/social_providers/paybin.rb +31 -0
  61. data/lib/better_auth/social_providers/paypal.rb +36 -0
  62. data/lib/better_auth/social_providers/polar.rb +31 -0
  63. data/lib/better_auth/social_providers/railway.rb +49 -0
  64. data/lib/better_auth/social_providers/reddit.rb +32 -0
  65. data/lib/better_auth/social_providers/roblox.rb +31 -0
  66. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  67. data/lib/better_auth/social_providers/slack.rb +30 -0
  68. data/lib/better_auth/social_providers/spotify.rb +31 -0
  69. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  70. data/lib/better_auth/social_providers/twitch.rb +39 -0
  71. data/lib/better_auth/social_providers/twitter.rb +32 -0
  72. data/lib/better_auth/social_providers/vercel.rb +47 -0
  73. data/lib/better_auth/social_providers/vk.rb +34 -0
  74. data/lib/better_auth/social_providers/wechat.rb +104 -0
  75. data/lib/better_auth/social_providers/zoom.rb +31 -0
  76. data/lib/better_auth/social_providers.rb +29 -0
  77. data/lib/better_auth/url_helpers.rb +195 -0
  78. data/lib/better_auth/version.rb +1 -1
  79. data/lib/better_auth.rb +8 -1
  80. 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
- updated = ctx.context.internal_adapter.update_session(session[:session]["token"], body)
38
- merged = session[:session].merge(updated || body)
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({status: true})
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: stringify_keys(data[:session] || data["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] == false
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] == false || email_config[:disable_sign_up]
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(ctx, provider_id, data[:user], data[:account])
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
- source = (ctx.method == "POST") ? ctx.body.merge(ctx.query) : ctx.query
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")) if state.empty?
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
- user_info = call_provider(provider, :get_user_info, token_hash(tokens))
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
- session_data = persist_social_user(ctx, provider_id, user, token_hash(tokens).merge("accountId" => fetch_value(user, "id").to_s))
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
- raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"])
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: token,
130
- id_token: token,
131
- accessToken: fetch_value(id_token, "accessToken"),
132
- access_token: fetch_value(id_token, "accessToken"),
133
- refreshToken: fetch_value(id_token, "refreshToken"),
134
- refresh_token: fetch_value(id_token, "refreshToken")
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