better_auth 0.10.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE.md +1 -1
  4. data/README.md +138 -91
  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 +191 -56
  9. data/lib/better_auth/adapters/mssql.rb +7 -3
  10. data/lib/better_auth/adapters/sql.rb +294 -49
  11. data/lib/better_auth/api.rb +3 -24
  12. data/lib/better_auth/configuration.rb +41 -46
  13. data/lib/better_auth/context.rb +43 -67
  14. data/lib/better_auth/cookies.rb +110 -14
  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 +14 -3
  18. data/lib/better_auth/error.rb +3 -1
  19. data/lib/better_auth/logger.rb +1 -1
  20. data/lib/better_auth/middleware/origin_check.rb +9 -1
  21. data/lib/better_auth/migration_plan.rb +4 -4
  22. data/lib/better_auth/oauth_state.rb +147 -0
  23. data/lib/better_auth/plugin_loader.rb +220 -0
  24. data/lib/better_auth/plugins/access.rb +10 -13
  25. data/lib/better_auth/plugins/admin.rb +79 -28
  26. data/lib/better_auth/plugins/api_key.rb +4 -7
  27. data/lib/better_auth/plugins/bearer.rb +8 -22
  28. data/lib/better_auth/plugins/captcha.rb +24 -6
  29. data/lib/better_auth/plugins/device_authorization.rb +47 -9
  30. data/lib/better_auth/plugins/dub.rb +2 -2
  31. data/lib/better_auth/plugins/email_otp.rb +30 -47
  32. data/lib/better_auth/plugins/generic_oauth.rb +141 -146
  33. data/lib/better_auth/plugins/i18n.rb +215 -0
  34. data/lib/better_auth/plugins/jwt.rb +37 -19
  35. data/lib/better_auth/plugins/magic_link.rb +18 -21
  36. data/lib/better_auth/plugins/oauth_popup.rb +330 -0
  37. data/lib/better_auth/plugins/oauth_protocol.rb +142 -32
  38. data/lib/better_auth/plugins/oauth_provider.rb +4 -7
  39. data/lib/better_auth/plugins/oauth_proxy.rb +1 -1
  40. data/lib/better_auth/plugins/one_tap.rb +61 -60
  41. data/lib/better_auth/plugins/one_time_token.rb +1 -4
  42. data/lib/better_auth/plugins/open_api.rb +35 -7
  43. data/lib/better_auth/plugins/organization.rb +455 -122
  44. data/lib/better_auth/plugins/passkey.rb +4 -7
  45. data/lib/better_auth/plugins/phone_number.rb +24 -26
  46. data/lib/better_auth/plugins/scim.rb +4 -7
  47. data/lib/better_auth/plugins/siwe.rb +126 -16
  48. data/lib/better_auth/plugins/sso.rb +4 -7
  49. data/lib/better_auth/plugins/stripe.rb +4 -7
  50. data/lib/better_auth/plugins/two_factor.rb +186 -21
  51. data/lib/better_auth/plugins/username.rb +56 -13
  52. data/lib/better_auth/plugins.rb +57 -0
  53. data/lib/better_auth/rate_limiter.rb +297 -135
  54. data/lib/better_auth/request_ip.rb +123 -13
  55. data/lib/better_auth/router.rb +3 -3
  56. data/lib/better_auth/routes/account.rb +40 -8
  57. data/lib/better_auth/routes/email_verification.rb +88 -5
  58. data/lib/better_auth/routes/password.rb +5 -6
  59. data/lib/better_auth/routes/session.rb +7 -2
  60. data/lib/better_auth/routes/sign_in.rb +10 -1
  61. data/lib/better_auth/routes/sign_up.rb +45 -22
  62. data/lib/better_auth/routes/social.rb +169 -54
  63. data/lib/better_auth/routes/user.rb +11 -8
  64. data/lib/better_auth/schema/sql.rb +90 -24
  65. data/lib/better_auth/schema.rb +124 -3
  66. data/lib/better_auth/session.rb +27 -9
  67. data/lib/better_auth/session_store.rb +1 -1
  68. data/lib/better_auth/social_providers/apple.rb +6 -1
  69. data/lib/better_auth/social_providers/atlassian.rb +1 -0
  70. data/lib/better_auth/social_providers/base.rb +41 -14
  71. data/lib/better_auth/social_providers/cognito.rb +75 -5
  72. data/lib/better_auth/social_providers/facebook.rb +95 -1
  73. data/lib/better_auth/social_providers/figma.rb +2 -0
  74. data/lib/better_auth/social_providers/google.rb +12 -1
  75. data/lib/better_auth/social_providers/line.rb +35 -1
  76. data/lib/better_auth/social_providers/linear.rb +1 -0
  77. data/lib/better_auth/social_providers/microsoft_entra_id.rb +9 -0
  78. data/lib/better_auth/social_providers/naver.rb +24 -1
  79. data/lib/better_auth/social_providers/notion.rb +1 -0
  80. data/lib/better_auth/social_providers/paybin.rb +25 -1
  81. data/lib/better_auth/social_providers/paypal.rb +118 -2
  82. data/lib/better_auth/social_providers/reddit.rb +25 -4
  83. data/lib/better_auth/social_providers/salesforce.rb +1 -0
  84. data/lib/better_auth/social_providers/twitch.rb +24 -1
  85. data/lib/better_auth/social_providers/twitter.rb +36 -1
  86. data/lib/better_auth/social_providers/vk.rb +34 -1
  87. data/lib/better_auth/social_providers/wechat.rb +6 -4
  88. data/lib/better_auth/sql_migration.rb +5 -5
  89. data/lib/better_auth/version.rb +1 -1
  90. data/lib/better_auth.rb +11 -41
  91. metadata +5 -13
  92. data/lib/better_auth/plugins/mcp/authorization.rb +0 -111
  93. data/lib/better_auth/plugins/mcp/config.rb +0 -51
  94. data/lib/better_auth/plugins/mcp/consent.rb +0 -31
  95. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +0 -43
  96. data/lib/better_auth/plugins/mcp/metadata.rb +0 -81
  97. data/lib/better_auth/plugins/mcp/registration.rb +0 -31
  98. data/lib/better_auth/plugins/mcp/resource_handler.rb +0 -37
  99. data/lib/better_auth/plugins/mcp/schema.rb +0 -91
  100. data/lib/better_auth/plugins/mcp/token.rb +0 -108
  101. data/lib/better_auth/plugins/mcp/userinfo.rb +0 -37
  102. data/lib/better_auth/plugins/mcp.rb +0 -256
  103. data/lib/better_auth/plugins/oidc_provider.rb +0 -769
@@ -1,16 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ return if defined?(BetterAuth::Plugins::OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION)
4
+
3
5
  module BetterAuth
4
6
  module Plugins
5
7
  module_function
6
8
 
7
- def oauth_provider(*args)
8
- Kernel.require "better_auth/oauth_provider"
9
- BetterAuth::Plugins.oauth_provider(*args)
10
- rescue LoadError => error
11
- raise if error.path && error.path != "better_auth/oauth_provider"
12
-
13
- raise LoadError, "BetterAuth::Plugins.oauth_provider requires the better_auth-oauth-provider gem. Add `gem \"better_auth-oauth-provider\"` and `require \"better_auth/oauth_provider\"`."
9
+ def oauth_provider(*args, &block)
10
+ call_external_plugin!(:oauth_provider, *args, implementation_constant: :OAUTH_PROVIDER_PLUGIN_IMPLEMENTATION, gem_name: "better_auth-oauth-provider", entry: "lib/better_auth/oauth_provider.rb", &block)
14
11
  end
15
12
  end
16
13
  end
@@ -14,7 +14,7 @@ module BetterAuth
14
14
  Plugin.new(
15
15
  id: "oauth-proxy",
16
16
  endpoints: {
17
- o_auth_proxy: oauth_proxy_endpoint(config)
17
+ oauth_proxy: oauth_proxy_endpoint(config)
18
18
  },
19
19
  hooks: {
20
20
  before: [
@@ -50,36 +50,32 @@ module BetterAuth
50
50
  id_token = body[:id_token].to_s
51
51
  payload = one_tap_verify_id_token(ctx, config, id_token)
52
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"))
53
54
 
54
55
  if email.empty?
55
56
  next ctx.json({error: "Email not available in token"})
56
57
  end
57
58
 
58
- user = ctx.context.internal_adapter.find_user_by_email(email)
59
- if user
60
- one_tap_link_account_unless_present!(ctx, config, user, payload, id_token)
61
- session_data = one_tap_create_session(ctx, user[:user])
62
- else
63
- raise APIError.new("BAD_GATEWAY", message: "User not found") if config[:disable_signup]
64
-
65
- created = ctx.context.internal_adapter.create_oauth_user(
66
- {
67
- email: email,
68
- emailVerified: one_tap_boolean_value(fetch_value(payload, "email_verified")),
69
- name: fetch_value(payload, "name").to_s,
70
- image: fetch_value(payload, "picture")
71
- },
72
- {
73
- providerId: "google",
74
- accountId: fetch_value(payload, "sub").to_s,
75
- idToken: id_token
76
- },
77
- context: ctx
78
- )
79
- raise APIError.new("INTERNAL_SERVER_ERROR", message: "Could not create user") unless created
80
-
81
- session_data = one_tap_create_session(ctx, created[:user])
82
- 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]
83
79
 
84
80
  Cookies.set_session_cookie(ctx, session_data)
85
81
  ctx.json({
@@ -90,16 +86,35 @@ module BetterAuth
90
86
  end
91
87
 
92
88
  def one_tap_verify_id_token(ctx, config, id_token)
93
- verifier = config[:verify_id_token]
94
- audience = config[:client_id] || ctx.context.options.social_providers.dig(:google, :client_id)
95
- payload = if verifier.respond_to?(:call)
96
- verifier.call(id_token, ctx, audience: audience)
97
- else
98
- 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")
99
117
  end
100
- one_tap_stringify_payload(payload)
101
- rescue
102
- raise APIError.new("BAD_REQUEST", message: "invalid id token")
103
118
  end
104
119
 
105
120
  def one_tap_verify_google_id_token(id_token, audience)
@@ -109,10 +124,8 @@ module BetterAuth
109
124
  iss: ["https://accounts.google.com", "accounts.google.com"],
110
125
  verify_iss: true
111
126
  }
112
- if audience
113
- options[:aud] = audience
114
- options[:verify_aud] = true
115
- end
127
+ options[:aud] = audience
128
+ options[:verify_aud] = true
116
129
  payload, = ::JWT.decode(id_token, nil, true, options.merge(jwks: jwks))
117
130
  payload
118
131
  end
@@ -129,31 +142,19 @@ module BetterAuth
129
142
  jwks
130
143
  end
131
144
 
132
- def one_tap_link_account_unless_present!(ctx, _config, user, payload, id_token)
133
- sub = fetch_value(payload, "sub").to_s
134
- account = ctx.context.internal_adapter.find_account(sub)
135
- 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
136
148
 
137
- account_linking = ctx.context.options.account[:account_linking] || {}
138
- trusted = Array(account_linking[:trusted_providers]).map(&:to_s).include?("google")
139
- enabled = account_linking.fetch(:enabled, true)
140
- should_link_account = enabled != false && (trusted || one_tap_boolean_value(fetch_value(payload, "email_verified")))
141
- 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"
142
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")
143
157
  end
144
-
145
- ctx.context.internal_adapter.link_account(
146
- userId: user[:user]["id"],
147
- providerId: "google",
148
- accountId: sub,
149
- scope: "openid,profile,email",
150
- idToken: id_token
151
- )
152
- end
153
-
154
- def one_tap_create_session(ctx, user)
155
- session = ctx.context.internal_adapter.create_session(user["id"])
156
- {session: session, user: user}
157
158
  end
158
159
 
159
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: [])
@@ -234,7 +238,7 @@ module BetterAuth
234
238
  Plugin.new(
235
239
  id: "open-api",
236
240
  endpoints: {
237
- 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|
238
242
  ctx.json(open_api_schema(ctx.context))
239
243
  end,
240
244
  open_api_reference: Endpoint.new(path: config[:path], method: "GET", metadata: {hide: true}) do |ctx|
@@ -267,7 +271,7 @@ module BetterAuth
267
271
  ],
268
272
  servers: [
269
273
  {
270
- url: context.base_url
274
+ url: context.canonical_base_url
271
275
  }
272
276
  ],
273
277
  tags: [
@@ -308,6 +312,7 @@ module BetterAuth
308
312
 
309
313
  def open_api_paths(endpoints, options)
310
314
  disabled_paths = Array(options.disabled_paths).map(&:to_s)
315
+ used_operation_ids = Set.new
311
316
  endpoints.each_with_object({}) do |(key, endpoint, tag), paths|
312
317
  next unless endpoint.path
313
318
  next if endpoint.metadata[:exclude_from_openapi] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata[:server_only]
@@ -318,7 +323,7 @@ module BetterAuth
318
323
  path = open_api_path(endpoint.path)
319
324
  paths[path] ||= {}
320
325
  endpoint.methods.reject { |method| method == "*" }.each do |method|
321
- 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)
322
327
  end
323
328
  end
324
329
  end
@@ -331,12 +336,12 @@ module BetterAuth
331
336
  tag == "Default" && [:ok, :error].include?(key) && endpoint.metadata[:openapi]
332
337
  end
333
338
 
334
- def open_api_operation(endpoint, method, tag)
339
+ def open_api_operation(endpoint, method, tag, used_operation_ids)
335
340
  metadata = endpoint.metadata[:openapi] || {}
336
341
  operation = {
337
342
  tags: Array(metadata[:tags] || [tag]),
338
343
  description: metadata[:description],
339
- operationId: metadata[:operationId],
344
+ operationId: open_api_operation_id(metadata[:operationId], method, used_operation_ids),
340
345
  security: [
341
346
  {
342
347
  bearerAuth: []
@@ -353,6 +358,26 @@ module BetterAuth
353
358
  operation
354
359
  end
355
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
+
356
381
  def open_api_components(options)
357
382
  Schema.auth_tables(options).each_with_object({}) do |(model, table), schemas|
358
383
  name = model.to_s.split(/[_-]/).map(&:capitalize).join
@@ -361,11 +386,14 @@ module BetterAuth
361
386
  end
362
387
 
363
388
  def schema_for_table(table)
364
- required = []
389
+ required = ["id"]
365
390
  properties = table[:fields].each_with_object({}) do |(field, attributes), result|
391
+ next if field.to_s == "id"
392
+
366
393
  result[field.to_sym] = field_schema(attributes)
367
- required << field if attributes[:required] && attributes[:input] != false && field != "id"
394
+ required << field if attributes[:required] && attributes[:returned] != false
368
395
  end
396
+ properties = {id: {type: "string", readOnly: true}}.merge(properties)
369
397
  {type: "object", properties: properties, required: required}
370
398
  end
371
399