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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +5 -3
  4. data/lib/better_auth/adapters/internal_adapter.rb +168 -18
  5. data/lib/better_auth/adapters/memory.rb +4 -1
  6. data/lib/better_auth/adapters/mongodb.rb +5 -365
  7. data/lib/better_auth/adapters/sql.rb +17 -1
  8. data/lib/better_auth/api.rb +1 -1
  9. data/lib/better_auth/context.rb +2 -1
  10. data/lib/better_auth/plugin.rb +14 -1
  11. data/lib/better_auth/plugins/oauth_protocol.rb +403 -57
  12. data/lib/better_auth/plugins/organization.rb +5 -0
  13. data/lib/better_auth/rate_limiter.rb +19 -2
  14. data/lib/better_auth/router.rb +14 -1
  15. data/lib/better_auth/routes/email_verification.rb +5 -2
  16. data/lib/better_auth/routes/password.rb +19 -0
  17. data/lib/better_auth/routes/session.rb +27 -4
  18. data/lib/better_auth/routes/sign_in.rb +1 -1
  19. data/lib/better_auth/routes/sign_up.rb +52 -1
  20. data/lib/better_auth/routes/social.rb +201 -22
  21. data/lib/better_auth/routes/user.rb +14 -2
  22. data/lib/better_auth/schema/sql.rb +11 -0
  23. data/lib/better_auth/schema.rb +16 -0
  24. data/lib/better_auth/social_providers/apple.rb +44 -8
  25. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  26. data/lib/better_auth/social_providers/base.rb +262 -4
  27. data/lib/better_auth/social_providers/cognito.rb +32 -0
  28. data/lib/better_auth/social_providers/discord.rb +27 -5
  29. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  30. data/lib/better_auth/social_providers/facebook.rb +35 -0
  31. data/lib/better_auth/social_providers/figma.rb +31 -0
  32. data/lib/better_auth/social_providers/github.rb +21 -6
  33. data/lib/better_auth/social_providers/gitlab.rb +16 -3
  34. data/lib/better_auth/social_providers/google.rb +38 -13
  35. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  36. data/lib/better_auth/social_providers/kakao.rb +32 -0
  37. data/lib/better_auth/social_providers/kick.rb +32 -0
  38. data/lib/better_auth/social_providers/line.rb +33 -0
  39. data/lib/better_auth/social_providers/linear.rb +44 -0
  40. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  41. data/lib/better_auth/social_providers/microsoft_entra_id.rb +79 -7
  42. data/lib/better_auth/social_providers/naver.rb +31 -0
  43. data/lib/better_auth/social_providers/notion.rb +33 -0
  44. data/lib/better_auth/social_providers/paybin.rb +31 -0
  45. data/lib/better_auth/social_providers/paypal.rb +36 -0
  46. data/lib/better_auth/social_providers/polar.rb +31 -0
  47. data/lib/better_auth/social_providers/railway.rb +49 -0
  48. data/lib/better_auth/social_providers/reddit.rb +32 -0
  49. data/lib/better_auth/social_providers/roblox.rb +31 -0
  50. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  51. data/lib/better_auth/social_providers/slack.rb +30 -0
  52. data/lib/better_auth/social_providers/spotify.rb +31 -0
  53. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  54. data/lib/better_auth/social_providers/twitch.rb +39 -0
  55. data/lib/better_auth/social_providers/twitter.rb +32 -0
  56. data/lib/better_auth/social_providers/vercel.rb +47 -0
  57. data/lib/better_auth/social_providers/vk.rb +34 -0
  58. data/lib/better_auth/social_providers/wechat.rb +104 -0
  59. data/lib/better_auth/social_providers/zoom.rb +31 -0
  60. data/lib/better_auth/social_providers.rb +29 -0
  61. data/lib/better_auth/version.rb +1 -1
  62. data/lib/better_auth.rb +0 -1
  63. 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
@@ -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 == "application/json"
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: true)
47
- set_verified_session_cookie(ctx, updated || user.merge("email" => update_to, "emailVerified" => true))
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
- 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
 
@@ -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
 
@@ -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(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,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