better_auth 0.8.0 → 0.11.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 (109) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +142 -95
  5. data/lib/better_auth/adapters/base.rb +64 -1
  6. data/lib/better_auth/adapters/internal_adapter.rb +197 -11
  7. data/lib/better_auth/adapters/join_support.rb +10 -0
  8. data/lib/better_auth/adapters/memory.rb +303 -54
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +390 -63
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +48 -47
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +119 -15
  15. data/lib/better_auth/core.rb +0 -2
  16. data/lib/better_auth/crypto.rb +4 -3
  17. data/lib/better_auth/doctor.rb +108 -0
  18. data/lib/better_auth/endpoint.rb +88 -5
  19. data/lib/better_auth/error.rb +3 -1
  20. data/lib/better_auth/http_client.rb +46 -0
  21. data/lib/better_auth/logger.rb +1 -1
  22. data/lib/better_auth/middleware/origin_check.rb +9 -1
  23. data/lib/better_auth/migration_plan.rb +15 -0
  24. data/lib/better_auth/oauth2.rb +1 -1
  25. data/lib/better_auth/oauth_state.rb +147 -0
  26. data/lib/better_auth/plugin_loader.rb +220 -0
  27. data/lib/better_auth/plugins/access.rb +10 -13
  28. data/lib/better_auth/plugins/admin.rb +85 -29
  29. data/lib/better_auth/plugins/anonymous.rb +2 -0
  30. data/lib/better_auth/plugins/api_key.rb +4 -7
  31. data/lib/better_auth/plugins/bearer.rb +8 -22
  32. data/lib/better_auth/plugins/captcha.rb +24 -6
  33. data/lib/better_auth/plugins/device_authorization.rb +77 -5
  34. data/lib/better_auth/plugins/dub.rb +10 -2
  35. data/lib/better_auth/plugins/email_otp.rb +30 -47
  36. data/lib/better_auth/plugins/generic_oauth.rb +162 -140
  37. data/lib/better_auth/plugins/have_i_been_pwned.rb +1 -1
  38. data/lib/better_auth/plugins/i18n.rb +215 -0
  39. data/lib/better_auth/plugins/jwt.rb +44 -19
  40. data/lib/better_auth/plugins/magic_link.rb +18 -21
  41. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  42. data/lib/better_auth/plugins/oauth_protocol.rb +233 -46
  43. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  44. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  45. data/lib/better_auth/plugins/one_tap.rb +78 -65
  46. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  47. data/lib/better_auth/plugins/open_api.rb +77 -9
  48. data/lib/better_auth/plugins/organization.rb +546 -102
  49. data/lib/better_auth/plugins/passkey.rb +4 -7
  50. data/lib/better_auth/plugins/phone_number.rb +25 -27
  51. data/lib/better_auth/plugins/scim.rb +4 -7
  52. data/lib/better_auth/plugins/siwe.rb +126 -16
  53. data/lib/better_auth/plugins/sso.rb +4 -7
  54. data/lib/better_auth/plugins/stripe.rb +4 -7
  55. data/lib/better_auth/plugins/two_factor.rb +205 -19
  56. data/lib/better_auth/plugins/username.rb +56 -13
  57. data/lib/better_auth/plugins.rb +57 -0
  58. data/lib/better_auth/rate_limiter.rb +300 -133
  59. data/lib/better_auth/request_ip.rb +123 -13
  60. data/lib/better_auth/router.rb +3 -3
  61. data/lib/better_auth/routes/account.rb +44 -8
  62. data/lib/better_auth/routes/email_verification.rb +93 -6
  63. data/lib/better_auth/routes/password.rb +6 -6
  64. data/lib/better_auth/routes/session.rb +7 -2
  65. data/lib/better_auth/routes/sign_in.rb +10 -1
  66. data/lib/better_auth/routes/sign_up.rb +45 -22
  67. data/lib/better_auth/routes/social.rb +197 -54
  68. data/lib/better_auth/routes/user.rb +17 -10
  69. data/lib/better_auth/schema/sql.rb +189 -34
  70. data/lib/better_auth/schema.rb +159 -5
  71. data/lib/better_auth/session.rb +29 -10
  72. data/lib/better_auth/session_store.rb +1 -1
  73. data/lib/better_auth/social_providers/apple.rb +6 -1
  74. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  75. data/lib/better_auth/social_providers/base.rb +45 -23
  76. data/lib/better_auth/social_providers/cognito.rb +75 -5
  77. data/lib/better_auth/social_providers/facebook.rb +95 -1
  78. data/lib/better_auth/social_providers/figma.rb +2 -0
  79. data/lib/better_auth/social_providers/github.rb +2 -0
  80. data/lib/better_auth/social_providers/google.rb +12 -1
  81. data/lib/better_auth/social_providers/line.rb +35 -1
  82. data/lib/better_auth/social_providers/linear.rb +1 -0
  83. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  84. data/lib/better_auth/social_providers/naver.rb +24 -1
  85. data/lib/better_auth/social_providers/notion.rb +1 -0
  86. data/lib/better_auth/social_providers/paybin.rb +25 -1
  87. data/lib/better_auth/social_providers/paypal.rb +118 -2
  88. data/lib/better_auth/social_providers/reddit.rb +25 -4
  89. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  90. data/lib/better_auth/social_providers/twitch.rb +24 -1
  91. data/lib/better_auth/social_providers/twitter.rb +36 -1
  92. data/lib/better_auth/social_providers/vk.rb +34 -1
  93. data/lib/better_auth/social_providers/wechat.rb +6 -4
  94. data/lib/better_auth/sql_migration.rb +566 -0
  95. data/lib/better_auth/version.rb +1 -1
  96. data/lib/better_auth.rb +14 -41
  97. metadata +14 -18
  98. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  99. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  100. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  101. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  102. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  103. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  104. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  105. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  106. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  107. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  108. data/lib/better_auth/plugins/mcp.rb +0 -215
  109. data/lib/better_auth/plugins/oidc_provider.rb +0 -710
@@ -32,6 +32,14 @@ module BetterAuth
32
32
  operationId: "oneTapCallback",
33
33
  summary: "One tap callback",
34
34
  description: "Use this endpoint to authenticate with Google One Tap",
35
+ requestBody: OpenAPI.json_request_body(
36
+ OpenAPI.object_schema(
37
+ {
38
+ id_token: {type: "string", description: "Google One Tap ID token"}
39
+ },
40
+ required: ["id_token"]
41
+ )
42
+ ),
35
43
  responses: {
36
44
  "200" => OpenAPI.json_response("Success", OpenAPI.session_response_schema_pair)
37
45
  }
@@ -42,36 +50,32 @@ module BetterAuth
42
50
  id_token = body[:id_token].to_s
43
51
  payload = one_tap_verify_id_token(ctx, config, id_token)
44
52
  email = fetch_value(payload, "email").to_s.downcase
53
+ raise APIError.new("BAD_REQUEST", message: "invalid id token") if Routes.blank_remote_id?(fetch_value(payload, "sub"))
45
54
 
46
55
  if email.empty?
47
56
  next ctx.json({error: "Email not available in token"})
48
57
  end
49
58
 
50
- user = ctx.context.internal_adapter.find_user_by_email(email)
51
- if user
52
- one_tap_link_account_unless_present!(ctx, config, user, payload, id_token)
53
- session_data = one_tap_create_session(ctx, user[:user])
54
- else
55
- raise APIError.new("BAD_GATEWAY", message: "User not found") if config[:disable_signup]
56
-
57
- created = ctx.context.internal_adapter.create_oauth_user(
58
- {
59
- email: email,
60
- emailVerified: one_tap_boolean_value(fetch_value(payload, "email_verified")),
61
- name: fetch_value(payload, "name").to_s,
62
- image: fetch_value(payload, "picture")
63
- },
64
- {
65
- providerId: "google",
66
- accountId: fetch_value(payload, "sub").to_s,
67
- idToken: id_token
68
- },
69
- context: ctx
70
- )
71
- raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user") unless created
72
-
73
- session_data = one_tap_create_session(ctx, created[:user])
74
- end
59
+ sub = fetch_value(payload, "sub").to_s
60
+ session_data = Routes.persist_social_user(
61
+ ctx,
62
+ "google",
63
+ {
64
+ id: sub,
65
+ email: email,
66
+ emailVerified: one_tap_boolean_value(fetch_value(payload, "email_verified")),
67
+ name: fetch_value(payload, "name").to_s,
68
+ image: fetch_value(payload, "picture")
69
+ },
70
+ {
71
+ providerId: "google",
72
+ accountId: sub,
73
+ idToken: id_token,
74
+ scope: "openid,profile,email"
75
+ },
76
+ disable_sign_up: config[:disable_signup]
77
+ )
78
+ one_tap_raise_persistence_error!(session_data[:error]) if session_data[:error]
75
79
 
76
80
  Cookies.set_session_cookie(ctx, session_data)
77
81
  ctx.json({
@@ -82,16 +86,35 @@ module BetterAuth
82
86
  end
83
87
 
84
88
  def one_tap_verify_id_token(ctx, config, id_token)
85
- verifier = config[:verify_id_token]
86
- audience = config[:client_id] || ctx.context.options.social_providers.dig(:google, :client_id)
87
- payload = if verifier.respond_to?(:call)
88
- verifier.call(id_token, ctx, audience: audience)
89
- else
90
- one_tap_verify_google_id_token(id_token, audience)
89
+ google_provider = ctx.context.options.social_providers[:google] || {}
90
+ google_options = fetch_value(google_provider, "options") || {}
91
+ audience = config[:client_id]
92
+ audience = fetch_value(google_provider, "client_id") if one_tap_blank_audience?(audience)
93
+ if one_tap_blank_audience?(audience)
94
+ raise APIError.new(
95
+ "BAD_REQUEST",
96
+ message: "Google client ID is required for One Tap. Set it on the one_tap plugin (client_id) or on social_providers.google."
97
+ )
98
+ end
99
+
100
+ begin
101
+ verifier = config[:verify_id_token]
102
+ payload = if verifier.respond_to?(:call)
103
+ verifier.call(id_token, ctx, audience: audience)
104
+ else
105
+ one_tap_verify_google_id_token(id_token, audience)
106
+ end
107
+ payload = one_tap_stringify_payload(payload)
108
+ hosted_domain = fetch_value(google_provider, "hd")
109
+ hosted_domain = fetch_value(google_options, "hd") if hosted_domain.nil?
110
+ unless SocialProviders.google_hosted_domain_allowed?(hosted_domain, payload["hd"])
111
+ raise "Invalid Google hosted domain"
112
+ end
113
+
114
+ payload
115
+ rescue
116
+ raise APIError.new("BAD_REQUEST", message: "invalid id token")
91
117
  end
92
- one_tap_stringify_payload(payload)
93
- rescue
94
- raise APIError.new("BAD_REQUEST", message: "invalid id token")
95
118
  end
96
119
 
97
120
  def one_tap_verify_google_id_token(id_token, audience)
@@ -101,47 +124,37 @@ module BetterAuth
101
124
  iss: ["https://accounts.google.com", "accounts.google.com"],
102
125
  verify_iss: true
103
126
  }
104
- if audience
105
- options[:aud] = audience
106
- options[:verify_aud] = true
107
- end
108
- payload, = JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
127
+ options[:aud] = audience
128
+ options[:verify_aud] = true
129
+ payload, = ::JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
109
130
  payload
110
131
  end
111
132
 
112
133
  def one_tap_google_jwks
113
- uri = URI("https://www.googleapis.com/oauth2/v3/certs")
114
- response = Net::HTTP.get_response(uri)
115
- raise "Unable to fetch Google JWKS" unless response.is_a?(Net::HTTPSuccess)
134
+ cached = @one_tap_google_jwks_cache
135
+ return cached[:jwks] if cached && cached[:expires_at] > Time.now
136
+
137
+ payload = HTTPClient.get_json("https://www.googleapis.com/oauth2/v3/certs")
138
+ raise "Unable to fetch Google JWKS" unless payload
116
139
 
117
- JWT::JWK::Set.new(JSON.parse(response.body))
140
+ jwks = ::JWT::JWK::Set.new(payload)
141
+ @one_tap_google_jwks_cache = {jwks: jwks, expires_at: Time.now + 300}
142
+ jwks
118
143
  end
119
144
 
120
- def one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token)
121
- sub = fetch_value(payload, "sub").to_s
122
- account = ctx.context.internal_adapter.find_account(sub)
123
- return if account
145
+ def one_tap_blank_audience?(audience)
146
+ Array(audience).empty? || Array(audience).all? { |value| value.to_s.strip.empty? }
147
+ end
124
148
 
125
- account_linking = ctx.context.options.account[:account_linking] || {}
126
- trusted = Array(account_linking[:trusted_providers]).map(&:to_s).include?("google")
127
- enabled = account_linking.fetch(:enabled, true)
128
- should_link_account = enabled != false && (trusted || one_tap_boolean_value(fetch_value(payload, "email_verified")))
129
- unless should_link_account
149
+ def one_tap_raise_persistence_error!(error)
150
+ case error
151
+ when "signup disabled"
152
+ raise APIError.new("BAD_GATEWAY", message: "User not found")
153
+ when "account not linked", "banned"
130
154
  raise APIError.new("UNAUTHORIZED", message: "Google sub doesn't match")
155
+ else
156
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user")
131
157
  end
132
-
133
- ctx.context.internal_adapter.link_account(
134
- userId: user[:user]["id"],
135
- providerId: "google",
136
- accountId: sub,
137
- scope: "openid,profile,email",
138
- idToken: id_token
139
- )
140
- end
141
-
142
- def one_tap_create_session(ctx, user)
143
- session = ctx.context.internal_adapter.create_session(user["id"])
144
- {session: session, user: user}
145
158
  end
146
159
 
147
160
  def one_tap_stringify_payload(payload)
@@ -85,12 +85,9 @@ module BetterAuth
85
85
  body = normalize_hash(ctx.body)
86
86
  token = body[:token].to_s
87
87
  stored_token = one_time_token_stored_value(config, token)
88
- verification = ctx.context.internal_adapter.find_verification_value("one-time-token:#{stored_token}")
88
+ verification = ctx.context.internal_adapter.consume_verification_value("one-time-token:#{stored_token}")
89
89
  raise APIError.new("BAD_REQUEST", message: "Invalid token") unless verification
90
90
 
91
- ctx.context.internal_adapter.delete_verification_value(verification["id"])
92
- raise APIError.new("BAD_REQUEST", message: "Token expired") if Routes.expired_time?(verification["expiresAt"])
93
-
94
91
  session = ctx.context.internal_adapter.find_session(verification["value"])
95
92
  raise APIError.new("BAD_REQUEST", message: "Session not found") unless session
96
93
  raise APIError.new("BAD_REQUEST", message: "Session expired") if Routes.expired_time?(session[:session]["expiresAt"])
@@ -2,6 +2,10 @@
2
2
 
3
3
  module BetterAuth
4
4
  module OpenAPI
5
+ # Better Auth action endpoints advertise upstream camelCase JSON field names in
6
+ # OpenAPI and generated inventories. Runtime may still accept Ruby snake_case
7
+ # aliases via normalize_hash and route validation.
8
+
5
9
  module_function
6
10
 
7
11
  def object_schema(properties, required: [])
@@ -128,6 +132,21 @@ module BetterAuth
128
132
  }
129
133
  end
130
134
 
135
+ def default_request_body
136
+ {
137
+ required: true,
138
+ content: {
139
+ "application/json" => {
140
+ schema: {
141
+ type: "object",
142
+ properties: {},
143
+ additionalProperties: true
144
+ }
145
+ }
146
+ }
147
+ }
148
+ end
149
+
131
150
  def responses(responses = nil)
132
151
  {"200" => success_response}.merge(default_error_responses).merge(responses || {})
133
152
  end
@@ -142,6 +161,17 @@ module BetterAuth
142
161
  )
143
162
  end
144
163
 
164
+ def default_success_response
165
+ json_response(
166
+ "Success",
167
+ {
168
+ type: "object",
169
+ properties: {},
170
+ additionalProperties: true
171
+ }
172
+ )
173
+ end
174
+
145
175
  def default_error_responses
146
176
  {
147
177
  "400" => error_response("Bad Request. Usually due to missing parameters, or invalid parameters.", required: true),
@@ -168,10 +198,16 @@ module BetterAuth
168
198
 
169
199
  def default_metadata(path, methods)
170
200
  method = Array(methods).reject { |value| value.to_s == "*" }.first.to_s.upcase
171
- {
201
+ metadata = {
172
202
  operationId: operation_id(path, method),
173
- description: "#{method} #{path}"
203
+ description: "Execute the #{operation_id(path, method)} endpoint.",
204
+ parameters: default_path_parameters(path),
205
+ responses: {
206
+ "200" => default_success_response
207
+ }
174
208
  }
209
+ metadata[:requestBody] = default_request_body if %w[POST PUT PATCH].include?(method)
210
+ metadata
175
211
  end
176
212
 
177
213
  def operation_id(path, method)
@@ -183,6 +219,14 @@ module BetterAuth
183
219
 
184
220
  "#{method.to_s.downcase}#{base}"
185
221
  end
222
+
223
+ def default_path_parameters(path)
224
+ path.to_s.split("/").filter_map do |part|
225
+ next unless part.start_with?(":")
226
+
227
+ path_parameter(part.delete_prefix(":"))
228
+ end
229
+ end
186
230
  end
187
231
 
188
232
  module Plugins
@@ -194,7 +238,7 @@ module BetterAuth
194
238
  Plugin.new(
195
239
  id: "open-api",
196
240
  endpoints: {
197
- generate_open_api_schema: Endpoint.new(path: "/open-api/generate-schema", method: "GET") do |ctx|
241
+ generate_openapi_schema: Endpoint.new(path: "/open-api/generate-schema", method: "GET") do |ctx|
198
242
  ctx.json(open_api_schema(ctx.context))
199
243
  end,
200
244
  open_api_reference: Endpoint.new(path: config[:path], method: "GET", metadata: {hide: true}) do |ctx|
@@ -227,7 +271,7 @@ module BetterAuth
227
271
  ],
228
272
  servers: [
229
273
  {
230
- url: context.base_url
274
+ url: context.canonical_base_url
231
275
  }
232
276
  ],
233
277
  tags: [
@@ -268,6 +312,7 @@ module BetterAuth
268
312
 
269
313
  def open_api_paths(endpoints, options)
270
314
  disabled_paths = Array(options.disabled_paths).map(&:to_s)
315
+ used_operation_ids = Set.new
271
316
  endpoints.each_with_object({}) do |(key, endpoint, tag), paths|
272
317
  next unless endpoint.path
273
318
  next if endpoint.metadata[:exclude_from_openapi] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata[:server_only]
@@ -278,7 +323,7 @@ module BetterAuth
278
323
  path = open_api_path(endpoint.path)
279
324
  paths[path] ||= {}
280
325
  endpoint.methods.reject { |method| method == "*" }.each do |method|
281
- paths[path][method.downcase.to_sym] = open_api_operation(endpoint, method, tag)
326
+ paths[path][method.downcase.to_sym] = open_api_operation(endpoint, method, tag, used_operation_ids)
282
327
  end
283
328
  end
284
329
  end
@@ -291,12 +336,12 @@ module BetterAuth
291
336
  tag == "Default" && [:ok, :error].include?(key) && endpoint.metadata[:openapi]
292
337
  end
293
338
 
294
- def open_api_operation(endpoint, method, tag)
339
+ def open_api_operation(endpoint, method, tag, used_operation_ids)
295
340
  metadata = endpoint.metadata[:openapi] || {}
296
341
  operation = {
297
342
  tags: Array(metadata[:tags] || [tag]),
298
343
  description: metadata[:description],
299
- operationId: metadata[:operationId],
344
+ operationId: open_api_operation_id(metadata[:operationId], method, used_operation_ids),
300
345
  security: [
301
346
  {
302
347
  bearerAuth: []
@@ -313,6 +358,26 @@ module BetterAuth
313
358
  operation
314
359
  end
315
360
 
361
+ def open_api_operation_id(base_id, method, used_operation_ids)
362
+ return if base_id.to_s.empty?
363
+
364
+ operation_id = base_id.to_s
365
+ unless used_operation_ids.include?(operation_id)
366
+ used_operation_ids << operation_id
367
+ return operation_id
368
+ end
369
+
370
+ suffix = method.to_s.downcase.capitalize
371
+ candidate = "#{operation_id}#{suffix}"
372
+ duplicate_index = 2
373
+ while used_operation_ids.include?(candidate)
374
+ candidate = "#{operation_id}#{suffix}#{duplicate_index}"
375
+ duplicate_index += 1
376
+ end
377
+ used_operation_ids << candidate
378
+ candidate
379
+ end
380
+
316
381
  def open_api_components(options)
317
382
  Schema.auth_tables(options).each_with_object({}) do |(model, table), schemas|
318
383
  name = model.to_s.split(/[_-]/).map(&:capitalize).join
@@ -321,11 +386,14 @@ module BetterAuth
321
386
  end
322
387
 
323
388
  def schema_for_table(table)
324
- required = []
389
+ required = ["id"]
325
390
  properties = table[:fields].each_with_object({}) do |(field, attributes), result|
391
+ next if field.to_s == "id"
392
+
326
393
  result[field.to_sym] = field_schema(attributes)
327
- required << field if attributes[:required] && attributes[:input] != false && field != "id"
394
+ required << field if attributes[:required] && attributes[:returned] != false
328
395
  end
396
+ properties = {id: {type: "string", readOnly: true}}.merge(properties)
329
397
  {type: "object", properties: properties, required: required}
330
398
  end
331
399