better_auth 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +10 -7
  5. data/lib/better_auth/adapters/memory.rb +57 -11
  6. data/lib/better_auth/adapters/sql.rb +123 -20
  7. data/lib/better_auth/api.rb +114 -9
  8. data/lib/better_auth/async.rb +70 -0
  9. data/lib/better_auth/configuration.rb +97 -7
  10. data/lib/better_auth/context.rb +165 -12
  11. data/lib/better_auth/cookies.rb +6 -4
  12. data/lib/better_auth/core.rb +2 -0
  13. data/lib/better_auth/crypto/jwe.rb +27 -5
  14. data/lib/better_auth/crypto.rb +32 -0
  15. data/lib/better_auth/database_hooks.rb +8 -8
  16. data/lib/better_auth/deprecate.rb +28 -0
  17. data/lib/better_auth/endpoint.rb +92 -5
  18. data/lib/better_auth/error.rb +8 -1
  19. data/lib/better_auth/host.rb +166 -0
  20. data/lib/better_auth/instrumentation.rb +74 -0
  21. data/lib/better_auth/logger.rb +31 -0
  22. data/lib/better_auth/middleware/origin_check.rb +2 -2
  23. data/lib/better_auth/oauth2.rb +94 -0
  24. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  25. data/lib/better_auth/plugins/admin.rb +344 -16
  26. data/lib/better_auth/plugins/anonymous.rb +37 -3
  27. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  28. data/lib/better_auth/plugins/dub.rb +148 -0
  29. data/lib/better_auth/plugins/email_otp.rb +261 -19
  30. data/lib/better_auth/plugins/expo.rb +17 -1
  31. data/lib/better_auth/plugins/generic_oauth.rb +67 -35
  32. data/lib/better_auth/plugins/jwt.rb +37 -4
  33. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  34. data/lib/better_auth/plugins/magic_link.rb +66 -3
  35. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  36. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  37. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  38. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  39. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  40. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  41. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  42. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  43. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  44. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  45. data/lib/better_auth/plugins/mcp.rb +111 -263
  46. data/lib/better_auth/plugins/multi_session.rb +61 -3
  47. data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
  48. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  49. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  50. data/lib/better_auth/plugins/one_tap.rb +7 -2
  51. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  52. data/lib/better_auth/plugins/open_api.rb +163 -318
  53. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  54. data/lib/better_auth/plugins/organization.rb +186 -56
  55. data/lib/better_auth/plugins/phone_number.rb +141 -6
  56. data/lib/better_auth/plugins/siwe.rb +69 -3
  57. data/lib/better_auth/plugins/two_factor.rb +118 -41
  58. data/lib/better_auth/plugins/username.rb +57 -2
  59. data/lib/better_auth/rate_limiter.rb +38 -0
  60. data/lib/better_auth/request_state.rb +44 -0
  61. data/lib/better_auth/response.rb +42 -0
  62. data/lib/better_auth/router.rb +7 -1
  63. data/lib/better_auth/routes/account.rb +220 -42
  64. data/lib/better_auth/routes/email_verification.rb +98 -14
  65. data/lib/better_auth/routes/password.rb +126 -8
  66. data/lib/better_auth/routes/session.rb +128 -13
  67. data/lib/better_auth/routes/sign_in.rb +26 -2
  68. data/lib/better_auth/routes/sign_out.rb +13 -1
  69. data/lib/better_auth/routes/sign_up.rb +70 -4
  70. data/lib/better_auth/routes/social.rb +132 -7
  71. data/lib/better_auth/routes/user.rb +228 -20
  72. data/lib/better_auth/routes/validation.rb +50 -0
  73. data/lib/better_auth/secret_config.rb +115 -0
  74. data/lib/better_auth/session.rb +13 -2
  75. data/lib/better_auth/url_helpers.rb +206 -0
  76. data/lib/better_auth/version.rb +1 -1
  77. data/lib/better_auth.rb +12 -0
  78. metadata +23 -1
@@ -53,7 +53,34 @@ module BetterAuth
53
53
  allowed_media_types: [
54
54
  "application/x-www-form-urlencoded",
55
55
  "application/json"
56
- ]
56
+ ],
57
+ openapi: {
58
+ operationId: "signInUsername",
59
+ description: "Sign in with username and password",
60
+ requestBody: OpenAPI.json_request_body(
61
+ OpenAPI.object_schema(
62
+ {
63
+ username: {type: "string"},
64
+ password: {type: "string"},
65
+ callbackURL: {type: ["string", "null"]},
66
+ rememberMe: {type: ["boolean", "null"]}
67
+ },
68
+ required: ["username", "password"]
69
+ )
70
+ ),
71
+ responses: {
72
+ "200" => OpenAPI.json_response(
73
+ "Signed in",
74
+ OpenAPI.object_schema(
75
+ {
76
+ token: {type: "string"},
77
+ user: {type: "object", "$ref": "#/components/schemas/User"}
78
+ },
79
+ required: ["token", "user"]
80
+ )
81
+ )
82
+ }
83
+ }
57
84
  }
58
85
  ) do |ctx|
59
86
  body = normalize_hash(ctx.body)
@@ -115,7 +142,35 @@ module BetterAuth
115
142
  end
116
143
 
117
144
  def is_username_available_endpoint(config)
118
- Endpoint.new(path: "/is-username-available", method: "POST") do |ctx|
145
+ Endpoint.new(
146
+ path: "/is-username-available",
147
+ method: "POST",
148
+ metadata: {
149
+ openapi: {
150
+ operationId: "isUsernameAvailable",
151
+ description: "Check whether a username is available",
152
+ requestBody: OpenAPI.json_request_body(
153
+ OpenAPI.object_schema(
154
+ {
155
+ username: {type: "string"}
156
+ },
157
+ required: ["username"]
158
+ )
159
+ ),
160
+ responses: {
161
+ "200" => OpenAPI.json_response(
162
+ "Username availability",
163
+ OpenAPI.object_schema(
164
+ {
165
+ available: {type: "boolean"}
166
+ },
167
+ required: ["available"]
168
+ )
169
+ )
170
+ }
171
+ }
172
+ }
173
+ ) do |ctx|
119
174
  body = normalize_hash(ctx.body)
120
175
  username = body[:username].to_s
121
176
  raise APIError.new("UNPROCESSABLE_ENTITY", message: USERNAME_ERROR_CODES["INVALID_USERNAME"]) if username.empty?
@@ -4,6 +4,9 @@ require "json"
4
4
 
5
5
  module BetterAuth
6
6
  class RateLimiter
7
+ MISSING_CLIENT_IP_WARNING = "Rate limiting skipped: could not determine client IP address. " \
8
+ "Ensure your runtime forwards a trusted client IP header and configure `advanced.ipAddress.ipAddressHeaders` if needed."
9
+
7
10
  class MemoryStore
8
11
  def initialize
9
12
  @entries = {}
@@ -36,6 +39,7 @@ module BetterAuth
36
39
 
37
40
  def initialize
38
41
  @memory_store = MemoryStore.new
42
+ @warned_missing_client_ip = false
39
43
  end
40
44
 
41
45
  def call(request, context, path)
@@ -43,6 +47,7 @@ module BetterAuth
43
47
  return unless config[:enabled]
44
48
 
45
49
  ip = client_ip(request, context.options)
50
+ warn_missing_client_ip(context) unless ip
46
51
  return unless ip
47
52
 
48
53
  rule = rate_limit_rule(request, context, config, path)
@@ -137,6 +142,7 @@ module BetterAuth
137
142
 
138
143
  def storage_for(context, config)
139
144
  return [:custom, config[:custom_storage]] if config[:custom_storage]
145
+ return [:database, context.internal_adapter.adapter] if config[:storage] == "database"
140
146
 
141
147
  if config[:storage] == "secondary-storage" && context.options.secondary_storage
142
148
  return [:secondary, context.options.secondary_storage]
@@ -146,6 +152,8 @@ module BetterAuth
146
152
  end
147
153
 
148
154
  def read_storage((type, storage), key)
155
+ return read_database_storage(storage, key) if type == :database
156
+
149
157
  data = storage.get(key)
150
158
  data = JSON.parse(data) if type == :secondary && data.is_a?(String)
151
159
  normalize_rate_limit_data(symbolize_keys(data))
@@ -154,12 +162,29 @@ module BetterAuth
154
162
  end
155
163
 
156
164
  def write_storage((type, storage), key, data, ttl:, update:)
165
+ return write_database_storage(storage, key, data) if type == :database
166
+
157
167
  value = (type == :secondary) ? JSON.generate(secondary_storage_data(data)) : data
158
168
  return call_secondary_storage_set(storage, key, value, ttl: ttl, update: update) if type == :secondary
159
169
 
160
170
  call_storage_set(storage, key, value, ttl: ttl, update: update)
161
171
  end
162
172
 
173
+ def read_database_storage(adapter, key)
174
+ data = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
175
+ normalize_rate_limit_data(symbolize_keys(data))
176
+ end
177
+
178
+ def write_database_storage(adapter, key, data)
179
+ value = secondary_storage_data(data)
180
+ existing = adapter.find_one(model: "rateLimit", where: [{field: "key", value: key}])
181
+ if existing
182
+ adapter.update(model: "rateLimit", where: [{field: "key", value: key}], update: value)
183
+ else
184
+ adapter.create(model: "rateLimit", data: value)
185
+ end
186
+ end
187
+
163
188
  def secondary_storage_data(data)
164
189
  {
165
190
  key: data[:key],
@@ -213,6 +238,19 @@ module BetterAuth
213
238
  RequestIP.client_ip(request, options)
214
239
  end
215
240
 
241
+ def warn_missing_client_ip(context)
242
+ return if @warned_missing_client_ip
243
+ return if context.options.advanced.dig(:ip_address, :disable_ip_tracking)
244
+
245
+ @warned_missing_client_ip = true
246
+ logger = context.logger
247
+ if logger.respond_to?(:call)
248
+ logger.call(:warn, MISSING_CLIENT_IP_WARNING)
249
+ elsif logger.respond_to?(:warn)
250
+ logger.warn(MISSING_CLIENT_IP_WARNING)
251
+ end
252
+ end
253
+
216
254
  def matching_plugin_rule(context, path)
217
255
  context.options.plugins
218
256
  .flat_map { |plugin| Array(plugin[:rate_limit]) }
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module RequestState
5
+ THREAD_KEY = :better_auth_request_state_stack
6
+
7
+ State = Struct.new(:ref, :initializer) do
8
+ def get
9
+ store = RequestState.current_store
10
+ store[ref] = initializer.call unless store.key?(ref)
11
+ store[ref]
12
+ end
13
+
14
+ def set(value)
15
+ RequestState.current_store[ref] = value
16
+ end
17
+ end
18
+
19
+ module_function
20
+
21
+ def run(store = {}, &block)
22
+ stack.push(store)
23
+ block.call
24
+ ensure
25
+ stack.pop
26
+ end
27
+
28
+ def present?
29
+ !stack.empty?
30
+ end
31
+
32
+ def current_store
33
+ stack.last || raise("No request state found. Please make sure you are calling this function within a `run` callback.")
34
+ end
35
+
36
+ def define(&initializer)
37
+ State.new(Object.new.freeze, initializer || -> {})
38
+ end
39
+
40
+ def stack
41
+ Thread.current[THREAD_KEY] ||= []
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterAuth
6
+ class Response
7
+ attr_reader :status, :headers, :body
8
+
9
+ def initialize(status:, headers:, body:)
10
+ @status = status
11
+ @headers = headers
12
+ @body = body
13
+ end
14
+
15
+ def self.from_rack(tuple)
16
+ status, headers, body = tuple
17
+ new(status: status, headers: headers, body: body)
18
+ end
19
+
20
+ def to_a
21
+ [status, headers, body]
22
+ end
23
+
24
+ alias_method :to_ary, :to_a
25
+
26
+ def each(&block)
27
+ to_a.each(&block)
28
+ end
29
+
30
+ def [](index)
31
+ to_a[index]
32
+ end
33
+
34
+ def first
35
+ status
36
+ end
37
+
38
+ def json(**options)
39
+ JSON.parse(body.join, **options)
40
+ end
41
+ end
42
+ end
@@ -88,6 +88,8 @@ module BetterAuth
88
88
  error_response(error)
89
89
  rescue JSON::ParserError
90
90
  error_response(APIError.new("BAD_REQUEST", message: "Invalid JSON body"))
91
+ ensure
92
+ context.clear_runtime! if context.respond_to?(:clear_runtime!)
91
93
  end
92
94
 
93
95
  def self.conflicting_methods(entries)
@@ -360,7 +362,11 @@ module BetterAuth
360
362
  end
361
363
 
362
364
  def server_only?(endpoint)
363
- endpoint.metadata[:server_only] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata["SERVER_ONLY"]
365
+ endpoint.metadata[:server_only] ||
366
+ endpoint.metadata[:SERVER_ONLY] ||
367
+ endpoint.metadata["SERVER_ONLY"] ||
368
+ endpoint.metadata[:scope].to_s == "server" ||
369
+ endpoint.metadata["scope"].to_s == "server"
364
370
  end
365
371
 
366
372
  def error_response(error, headers: {})
@@ -3,7 +3,22 @@
3
3
  module BetterAuth
4
4
  module Routes
5
5
  def self.list_accounts
6
- Endpoint.new(path: "/list-accounts", method: "GET") do |ctx|
6
+ Endpoint.new(
7
+ path: "/list-accounts",
8
+ method: "GET",
9
+ metadata: {
10
+ openapi: {
11
+ operationId: "listUserAccounts",
12
+ description: "List linked accounts for the current user",
13
+ responses: {
14
+ "200" => OpenAPI.json_response(
15
+ "Linked accounts",
16
+ {type: "array", items: {type: "object", "$ref": "#/components/schemas/Account"}}
17
+ )
18
+ }
19
+ }
20
+ }
21
+ ) do |ctx|
7
22
  session = current_session(ctx)
8
23
  accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"]).map do |account|
9
24
  parsed = Schema.parse_output(ctx.context.options, "account", account)
@@ -15,8 +30,33 @@ module BetterAuth
15
30
  end
16
31
 
17
32
  def self.unlink_account
18
- Endpoint.new(path: "/unlink-account", method: "POST") do |ctx|
19
- session = current_session(ctx, sensitive: true)
33
+ Endpoint.new(
34
+ path: "/unlink-account",
35
+ method: "POST",
36
+ body_schema: request_body_schema(
37
+ required_strings: %w[providerId],
38
+ optional_strings: %w[accountId]
39
+ ),
40
+ metadata: {
41
+ openapi: {
42
+ operationId: "unlinkAccount",
43
+ description: "Unlink an account from the current user",
44
+ requestBody: OpenAPI.json_request_body(
45
+ OpenAPI.object_schema(
46
+ {
47
+ providerId: {type: "string"},
48
+ accountId: {type: ["string", "null"]}
49
+ },
50
+ required: ["providerId"]
51
+ )
52
+ ),
53
+ responses: {
54
+ "200" => OpenAPI.json_response("Account unlinked", OpenAPI.status_response_schema)
55
+ }
56
+ }
57
+ }
58
+ ) do |ctx|
59
+ session = current_session(ctx, sensitive: true, fresh: true)
20
60
  body = normalize_hash(ctx.body)
21
61
  accounts = ctx.context.internal_adapter.find_accounts(session[:user]["id"])
22
62
  if accounts.length == 1 && !ctx.context.options.account.dig(:account_linking, :allow_unlinking_all)
@@ -24,6 +64,8 @@ module BetterAuth
24
64
  end
25
65
 
26
66
  provider_id = body["providerId"] || body["provider_id"]
67
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
68
+
27
69
  account_id = body["accountId"] || body["account_id"]
28
70
  account = accounts.find do |entry|
29
71
  entry["providerId"] == provider_id && (account_id.to_s.empty? || entry["accountId"] == account_id)
@@ -36,44 +78,108 @@ module BetterAuth
36
78
  end
37
79
 
38
80
  def self.get_access_token
39
- Endpoint.new(path: "/get-access-token", method: "POST") do |ctx|
81
+ Endpoint.new(
82
+ path: "/get-access-token",
83
+ method: "POST",
84
+ body_schema: request_body_schema(
85
+ required_strings: %w[providerId],
86
+ optional_strings: %w[accountId userId]
87
+ ),
88
+ metadata: {
89
+ openapi: {
90
+ operationId: "getAccessToken",
91
+ description: "Get an access token for a linked provider account",
92
+ requestBody: OpenAPI.json_request_body(
93
+ OpenAPI.object_schema(
94
+ {
95
+ providerId: {type: "string"},
96
+ accountId: {type: ["string", "null"]},
97
+ userId: {type: ["string", "null"]}
98
+ },
99
+ required: ["providerId"]
100
+ )
101
+ ),
102
+ responses: {
103
+ "200" => OpenAPI.json_response(
104
+ "Provider access token",
105
+ OpenAPI.object_schema(
106
+ {
107
+ accessToken: {type: ["string", "null"]},
108
+ accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
109
+ scopes: {type: "array", items: {type: "string"}},
110
+ idToken: {type: ["string", "null"]}
111
+ },
112
+ required: ["scopes"]
113
+ )
114
+ )
115
+ }
116
+ }
117
+ }
118
+ ) do |ctx|
40
119
  session = current_session(ctx, allow_nil: true)
41
120
  body = normalize_hash(ctx.body)
42
121
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
43
122
  raise APIError.new("UNAUTHORIZED") if user_id.to_s.empty?
44
123
 
45
124
  provider_id = body["providerId"] || body["provider_id"]
46
- provider = social_provider(ctx.context, provider_id)
47
- raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
125
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
48
126
 
49
127
  account_id = body["accountId"] || body["account_id"]
50
- account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
51
- raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
52
-
53
- if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
54
- tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
55
- updated = update_account_tokens(ctx, account, tokens)
56
- account = account.merge(token_hash(tokens))
57
- Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
58
- end
59
-
60
- ctx.json({
61
- accessToken: oauth_token_value(ctx, account["accessToken"]),
62
- accessTokenExpiresAt: account["accessTokenExpiresAt"],
63
- scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
64
- idToken: account["idToken"]
65
- })
128
+ ctx.json(access_token_response(ctx, user_id: user_id, provider_id: provider_id, account_id: account_id))
66
129
  end
67
130
  end
68
131
 
69
132
  def self.refresh_token
70
- Endpoint.new(path: "/refresh-token", method: "POST") do |ctx|
133
+ Endpoint.new(
134
+ path: "/refresh-token",
135
+ method: "POST",
136
+ body_schema: request_body_schema(
137
+ required_strings: %w[providerId],
138
+ optional_strings: %w[accountId userId]
139
+ ),
140
+ metadata: {
141
+ openapi: {
142
+ operationId: "refreshToken",
143
+ description: "Refresh an OAuth provider access token",
144
+ requestBody: OpenAPI.json_request_body(
145
+ OpenAPI.object_schema(
146
+ {
147
+ providerId: {type: "string"},
148
+ accountId: {type: ["string", "null"]},
149
+ userId: {type: ["string", "null"]}
150
+ },
151
+ required: ["providerId"]
152
+ )
153
+ ),
154
+ responses: {
155
+ "200" => OpenAPI.json_response(
156
+ "Refreshed provider tokens",
157
+ OpenAPI.object_schema(
158
+ {
159
+ accessToken: {type: ["string", "null"]},
160
+ refreshToken: {type: ["string", "null"]},
161
+ accessTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
162
+ refreshTokenExpiresAt: {type: ["string", "null"], format: "date-time"},
163
+ scope: {type: ["string", "null"]},
164
+ idToken: {type: ["string", "null"]},
165
+ providerId: {type: "string"},
166
+ accountId: {type: "string"}
167
+ },
168
+ required: ["providerId", "accountId"]
169
+ )
170
+ )
171
+ }
172
+ }
173
+ }
174
+ ) do |ctx|
71
175
  session = current_session(ctx, allow_nil: true)
72
176
  body = normalize_hash(ctx.body)
73
177
  user_id = session&.dig(:user, "id") || body["userId"] || body["user_id"]
74
178
  raise APIError.new("BAD_REQUEST", message: "Either userId or session is required") if user_id.to_s.empty?
75
179
 
76
180
  provider_id = body["providerId"] || body["provider_id"]
181
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VALIDATION_ERROR"]) if provider_id.to_s.empty?
182
+
77
183
  provider = social_provider(ctx.context, provider_id)
78
184
  raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} not found.") unless provider
79
185
  raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} does not support token refreshing.") unless provider_callable(provider, :refresh_access_token)
@@ -84,16 +190,21 @@ module BetterAuth
84
190
  refresh_token = oauth_token_value(ctx, account["refreshToken"])
85
191
  raise APIError.new("BAD_REQUEST", message: "Refresh token not found") if refresh_token.to_s.empty?
86
192
 
87
- tokens = call_provider(provider, :refresh_access_token, refresh_token)
88
- updated = update_account_tokens(ctx, account, tokens)
89
- values = token_hash(tokens)
90
- Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
193
+ begin
194
+ tokens = call_provider(provider, :refresh_access_token, refresh_token)
195
+ updated = update_account_tokens(ctx, account, tokens)
196
+ values = token_hash(tokens)
197
+ Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
198
+ rescue => error
199
+ log(ctx.context, :error, "FAILED_TO_REFRESH_ACCESS_TOKEN #{error.message}")
200
+ raise APIError.new("BAD_REQUEST", code: "FAILED_TO_REFRESH_ACCESS_TOKEN", message: "Failed to refresh access token")
201
+ end
91
202
  ctx.json({
92
203
  accessToken: values["accessToken"],
93
- refreshToken: values["refreshToken"],
204
+ refreshToken: values["refreshToken"] || refresh_token,
94
205
  accessTokenExpiresAt: values["accessTokenExpiresAt"],
95
- refreshTokenExpiresAt: values["refreshTokenExpiresAt"],
96
- scope: Array(values["scopes"]).join(","),
206
+ refreshTokenExpiresAt: values["refreshTokenExpiresAt"] || account["refreshTokenExpiresAt"],
207
+ scope: values["scope"] || account["scope"],
97
208
  idToken: values["idToken"] || account["idToken"],
98
209
  providerId: account["providerId"],
99
210
  accountId: account["accountId"]
@@ -102,31 +213,85 @@ module BetterAuth
102
213
  end
103
214
 
104
215
  def self.account_info
105
- Endpoint.new(path: "/account-info", method: "GET") do |ctx|
216
+ Endpoint.new(
217
+ path: "/account-info",
218
+ method: "GET",
219
+ metadata: {
220
+ openapi: {
221
+ operationId: "accountInfo",
222
+ description: "Get user info from a linked provider account",
223
+ parameters: [
224
+ {
225
+ name: "accountId",
226
+ in: "query",
227
+ required: false,
228
+ schema: {type: "string"}
229
+ }
230
+ ],
231
+ responses: {
232
+ "200" => OpenAPI.json_response("Provider user info", {type: "object"})
233
+ }
234
+ }
235
+ }
236
+ ) do |ctx|
106
237
  session = current_session(ctx)
107
238
  account_id = fetch_value(ctx.query, "accountId")
108
239
  account = if account_id
109
240
  ctx.context.internal_adapter.find_accounts(session[:user]["id"]).find do |entry|
110
241
  entry["id"] == account_id || entry["accountId"] == account_id
111
242
  end
243
+ else
244
+ account_cookie(ctx, nil, nil, session[:user]["id"])
112
245
  end
113
246
  raise APIError.new("BAD_REQUEST", message: "Account not found") unless account && account["userId"] == session[:user]["id"]
114
247
 
115
248
  provider = social_provider(ctx.context, account["providerId"])
116
249
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "Provider account provider is #{account["providerId"]} but it is not configured") unless provider
117
- raise APIError.new("BAD_REQUEST", message: "Access token not found") if account["accessToken"].to_s.empty?
118
250
 
119
- info = call_provider(provider, :get_user_info, {
120
- accessToken: oauth_token_value(ctx, account["accessToken"]),
121
- access_token: oauth_token_value(ctx, account["accessToken"]),
122
- idToken: account["idToken"],
123
- scopes: account["scope"].to_s.split(",")
124
- })
251
+ tokens = access_token_response(
252
+ ctx,
253
+ user_id: session[:user]["id"],
254
+ provider_id: account["providerId"],
255
+ account_id: account["accountId"],
256
+ provider: provider
257
+ )
258
+ raise APIError.new("BAD_REQUEST", message: "Access token not found") if tokens[:accessToken].to_s.empty?
259
+
260
+ info = call_provider(provider, :get_user_info, tokens.merge(access_token: tokens[:accessToken]))
125
261
  ctx.json(info)
126
262
  end
127
263
  end
128
264
 
265
+ def self.access_token_response(ctx, user_id:, provider_id:, account_id: nil, provider: nil)
266
+ provider ||= social_provider(ctx.context, provider_id)
267
+ raise APIError.new("BAD_REQUEST", message: "Provider #{provider_id} is not supported.") unless provider
268
+
269
+ account = account_cookie(ctx, provider_id, account_id, user_id) || find_provider_account(ctx, user_id, provider_id, account_id)
270
+ raise APIError.new("BAD_REQUEST", message: "Account not found") unless account
271
+
272
+ if account["refreshToken"] && access_token_expired?(account) && provider_callable(provider, :refresh_access_token)
273
+ begin
274
+ tokens = call_provider(provider, :refresh_access_token, oauth_token_value(ctx, account["refreshToken"]))
275
+ updated = update_account_tokens(ctx, account, tokens)
276
+ account = account.merge(token_hash(tokens))
277
+ Cookies.set_account_cookie(ctx, updated || account.merge(token_hash_for_storage(ctx, tokens)))
278
+ rescue => error
279
+ log(ctx.context, :error, "FAILED_TO_GET_ACCESS_TOKEN #{error.message}")
280
+ raise APIError.new("BAD_REQUEST", code: "FAILED_TO_GET_ACCESS_TOKEN", message: "Failed to get a valid access token")
281
+ end
282
+ end
283
+
284
+ {
285
+ accessToken: oauth_token_value(ctx, account["accessToken"]),
286
+ accessTokenExpiresAt: account["accessTokenExpiresAt"],
287
+ scopes: account["scopes"] || (account["scope"].to_s.empty? ? [] : account["scope"].to_s.split(",")),
288
+ idToken: account["idToken"]
289
+ }
290
+ end
291
+
129
292
  def self.social_provider(context, provider_id)
293
+ return nil if provider_id.to_s.empty?
294
+
130
295
  provider = context.social_providers[provider_id.to_sym] || context.social_providers[provider_id.to_s]
131
296
  return provider.merge(id: provider_id.to_s) if provider.is_a?(Hash) && !provider.key?(:id) && !provider.key?("id")
132
297
 
@@ -143,7 +308,8 @@ module BetterAuth
143
308
  return nil unless ctx.context.options.account[:store_account_cookie]
144
309
 
145
310
  account = Cookies.get_account_cookie(ctx)
146
- return nil unless account && account["providerId"] == provider_id
311
+ return nil unless account
312
+ return nil if provider_id && account["providerId"] != provider_id
147
313
  return nil unless account_id.to_s.empty? || account["id"] == account_id || account["accountId"] == account_id
148
314
  return nil unless user_id.to_s.empty? || account["userId"].to_s.empty? || account["userId"] == user_id
149
315
 
@@ -167,7 +333,10 @@ module BetterAuth
167
333
  def self.update_account_tokens(ctx, account, tokens)
168
334
  return nil if account["id"].to_s.empty?
169
335
 
170
- ctx.context.internal_adapter.update_account(account["id"], token_hash_for_storage(ctx, tokens))
336
+ data = account_token_update_hash(ctx, tokens)
337
+ return nil if data.empty?
338
+
339
+ ctx.context.internal_adapter.update_account(account["id"], data)
171
340
  end
172
341
 
173
342
  def self.token_hash(tokens)
@@ -183,18 +352,27 @@ module BetterAuth
183
352
  data
184
353
  end
185
354
 
355
+ def self.account_token_update_hash(ctx, tokens)
356
+ account_storage_fields(token_hash_for_storage(ctx, tokens))
357
+ end
358
+
359
+ def self.account_storage_fields(data)
360
+ allowed = %w[accessToken refreshToken idToken accessTokenExpiresAt refreshTokenExpiresAt scope]
361
+ token_hash(data).select { |key, value| allowed.include?(key) && !value.nil? }
362
+ end
363
+
186
364
  def self.oauth_token_for_storage(ctx, token)
187
365
  return token if token.to_s.empty?
188
366
  return token unless ctx.context.options.account[:encrypt_oauth_tokens]
189
367
 
190
- Crypto.symmetric_encrypt(key: ctx.context.secret, data: token)
368
+ Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: token)
191
369
  end
192
370
 
193
371
  def self.oauth_token_value(ctx, token)
194
372
  return token if token.to_s.empty?
195
373
  return token unless ctx.context.options.account[:encrypt_oauth_tokens]
196
374
 
197
- Crypto.symmetric_decrypt(key: ctx.context.secret, data: token) || token
375
+ Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: token) || token
198
376
  end
199
377
 
200
378
  def self.provider_callable(provider, key)