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,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "time"
4
+
3
5
  module BetterAuth
4
6
  module Schema
5
7
  module_function
@@ -22,6 +24,40 @@ module BetterAuth
22
24
  tables.sort_by { |_name, table| table[:order] || Float::INFINITY }.to_h
23
25
  end
24
26
 
27
+ def migration_tables(options)
28
+ tables = auth_tables(options)
29
+ grouped = {}
30
+
31
+ tables.each_with_index do |(logical_name, table), index|
32
+ table_name = table.fetch(:model_name)
33
+ target = grouped[table_name] ||= {
34
+ model_name: table_name,
35
+ fields: {},
36
+ order: table[:order] || Float::INFINITY,
37
+ source_order: index,
38
+ logical_names: [],
39
+ disable_migration: false
40
+ }
41
+ target[:order] = [target[:order], table[:order] || Float::INFINITY].min
42
+ target[:source_order] = [target[:source_order], index].min
43
+ target[:logical_names] << logical_name
44
+ target[:disable_migration] ||= table[:disable_migration] == true
45
+
46
+ table.fetch(:fields).each do |logical_field, attributes|
47
+ column = attributes[:field_name] || physical_name(logical_field)
48
+ physical_attributes = physical_field_attributes(logical_field, attributes, tables)
49
+ merge_physical_field!(target, column, physical_attributes)
50
+ end
51
+ end
52
+
53
+ grouped
54
+ .reject { |_name, table| table[:disable_migration] }
55
+ .sort_by { |_name, table| [table[:order], table[:source_order]] }
56
+ .to_h do |name, table|
57
+ [name, table.except(:source_order, :disable_migration)]
58
+ end
59
+ end
60
+
25
61
  def storage_model_name(options, model)
26
62
  table = auth_tables(options).fetch(model.to_s)
27
63
  table[:model_name]
@@ -49,10 +85,48 @@ module BetterAuth
49
85
  data
50
86
  end
51
87
 
88
+ def parse_provider_profile_user_input(options, profile, action:)
89
+ action = action.to_sym
90
+ raise ArgumentError, "action must be :create or :update" unless [:create, :update].include?(action)
91
+
92
+ input = (profile || {}).each_with_object({}) do |(key, value), result|
93
+ result[storage_key(key)] = value
94
+ end
95
+ core_fields = %w[id email emailVerified name image createdAt updatedAt]
96
+ fields = auth_tables(options).fetch("user").fetch(:fields).except(*core_fields)
97
+
98
+ fields.each_with_object({}) do |(field, attributes), result|
99
+ provider_value_allowed = attributes[:input] != false && input.key?(field)
100
+ if provider_value_allowed
101
+ value = coerce_provider_profile_value(input[field], attributes)
102
+ transform = attributes[:transform]
103
+ value = transform.call(value) if transform.respond_to?(:call)
104
+ validator = attributes[:validator]
105
+ if validator.respond_to?(:call) && !validator.call(value)
106
+ raise APIError.new("BAD_REQUEST", message: "#{field} is invalid")
107
+ end
108
+ result[field] = value
109
+ elsif action == :create && attributes.key?(:default_value)
110
+ default = attributes[:default_value]
111
+ result[field] = default.respond_to?(:call) ? default.call : default
112
+ elsif action == :create && attributes[:required]
113
+ raise APIError.new("BAD_REQUEST", message: "#{field} is required")
114
+ end
115
+ end
116
+ end
117
+
118
+ private_class_method def self.coerce_provider_profile_value(value, attributes)
119
+ return value if value.nil?
120
+ return Time.parse(value) if attributes[:type] == "date" && value.is_a?(String)
121
+
122
+ value
123
+ end
124
+
52
125
  private_class_method def self.user_table(options, plugin_table)
53
126
  table(
54
127
  model_name: model_option(options, :user, :model_name) || "users",
55
128
  order: 1,
129
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
56
130
  fields: id_field.merge(
57
131
  "name" => field("string", required: true, sortable: true, field_name: mapped_field(options, :user, "name")),
58
132
  "email" => field("string", required: true, unique: true, sortable: true, field_name: mapped_field(options, :user, "email")),
@@ -67,6 +141,7 @@ module BetterAuth
67
141
  table(
68
142
  model_name: model_option(options, :session, :model_name) || "sessions",
69
143
  order: 2,
144
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
70
145
  fields: base_fields.merge(
71
146
  "expiresAt" => field("date", required: true, field_name: mapped_field(options, :session, "expiresAt")),
72
147
  "token" => field("string", required: true, unique: true, field_name: mapped_field(options, :session, "token")),
@@ -82,6 +157,7 @@ module BetterAuth
82
157
  table(
83
158
  model_name: model_option(options, :account, :model_name) || "accounts",
84
159
  order: 3,
160
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
85
161
  fields: base_fields.merge(
86
162
  "accountId" => field("string", required: true, field_name: mapped_field(options, :account, "accountId")),
87
163
  "providerId" => field("string", required: true, field_name: mapped_field(options, :account, "providerId")),
@@ -102,6 +178,7 @@ module BetterAuth
102
178
  table(
103
179
  model_name: model_option(options, :verification, :model_name) || "verifications",
104
180
  order: 4,
181
+ disable_migration: plugin_table&.fetch(:disable_migration, false),
105
182
  fields: base_fields.merge(
106
183
  "identifier" => field("string", required: true, index: true, field_name: mapped_field(options, :verification, "identifier")),
107
184
  "value" => field("string", required: true, field_name: mapped_field(options, :verification, "value")),
@@ -131,6 +208,49 @@ module BetterAuth
131
208
  end
132
209
  end
133
210
 
211
+ private_class_method def self.physical_field_attributes(logical_field, attributes, tables)
212
+ column = attributes[:field_name] || physical_name(logical_field)
213
+ physical_attributes = attributes.merge(field_name: column, logical_name: logical_field.to_s)
214
+ reference = attributes[:references]
215
+ return physical_attributes unless reference
216
+
217
+ physical_attributes.merge(references: physical_reference(reference, tables))
218
+ end
219
+
220
+ private_class_method def self.physical_reference(reference, tables)
221
+ model = reference.fetch(:model).to_s
222
+ target_table = tables.fetch(model, nil) || tables.each_value.find { |table| table.fetch(:model_name).to_s == model }
223
+ return reference unless target_table
224
+
225
+ field = reference.fetch(:field).to_s
226
+ target_field = target_table.fetch(:fields).fetch(field, nil)
227
+ physical_field = if target_field
228
+ target_field[:field_name] || physical_name(field)
229
+ elsif target_table.fetch(:fields).each_value.any? { |attributes| attributes[:field_name].to_s == field }
230
+ field
231
+ else
232
+ physical_name(field)
233
+ end
234
+ reference.merge(model: target_table.fetch(:model_name), field: physical_field)
235
+ end
236
+
237
+ private_class_method def self.merge_physical_field!(table, column, attributes)
238
+ existing = table.fetch(:fields)[column]
239
+ unless existing
240
+ table.fetch(:fields)[column] = attributes
241
+ return
242
+ end
243
+
244
+ return if compatible_physical_field?(existing, attributes)
245
+
246
+ raise BetterAuth::Error, "Conflicting schema metadata for #{table.fetch(:model_name)}.#{column}"
247
+ end
248
+
249
+ private_class_method def self.compatible_physical_field?(left, right)
250
+ keys = [:type, :required, :references, :index, :unique, :sortable, :bigint]
251
+ keys.all? { |key| left[key] == right[key] }
252
+ end
253
+
134
254
  private_class_method def self.base_fields
135
255
  id_field.merge(timestamp_fields)
136
256
  end
@@ -148,12 +268,12 @@ module BetterAuth
148
268
  }
149
269
  end
150
270
 
151
- private_class_method def self.table(model_name:, fields:, extra_fields:, order:)
271
+ private_class_method def self.table(model_name:, fields:, extra_fields:, order:, disable_migration: false)
152
272
  {
153
273
  model_name: model_name,
154
274
  fields: merge_fields(fields, *extra_fields),
155
275
  order: order
156
- }
276
+ }.tap { |data| data[:disable_migration] = true if disable_migration == true }
157
277
  end
158
278
 
159
279
  private_class_method def self.merge_fields(base, *extras)
@@ -175,6 +295,7 @@ module BetterAuth
175
295
  existing = tables[key] || {model_name: table_data[:model_name] || physical_table_name(key), fields: {}}
176
296
  existing[:model_name] = table_data[:model_name] || existing[:model_name] || physical_table_name(key)
177
297
  existing[:fields] = existing[:fields].merge(normalize_fields(table_data[:fields] || {}))
298
+ existing[:disable_migration] = true if table_data[:disable_migration] == true
178
299
  existing[:fields] = id_field.merge(existing[:fields]) unless core_table?(key) || existing[:fields].key?("id")
179
300
  tables[key] = existing
180
301
  end
@@ -319,6 +440,6 @@ module BetterAuth
319
440
  (Time.now.to_f * 1000).to_i
320
441
  end
321
442
 
322
- public_class_method :storage_key
443
+ public_class_method :physical_name, :storage_key
323
444
  end
324
445
  end
@@ -7,15 +7,21 @@ module BetterAuth
7
7
  module_function
8
8
 
9
9
  def find_current(ctx, disable_cookie_cache: false, disable_refresh: false, sensitive: false)
10
- if ctx.context.current_session
10
+ authoritative = sensitive && stateful?(ctx)
11
+ if ctx.context.current_session && !authoritative
11
12
  return ctx.context.current_session
12
13
  end
13
14
 
14
15
  token_cookie = ctx.context.auth_cookies[:session_token]
15
16
  token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
16
- return nil unless token
17
+ unless token
18
+ present = ctx.get_cookie(token_cookie.name)
19
+ return missing_session(ctx) if present && !present.empty?
17
20
 
18
- cached = cached_session(ctx, token, disable_cookie_cache: disable_cookie_cache, sensitive: sensitive)
21
+ return nil
22
+ end
23
+
24
+ cached = cached_session(ctx, token, disable_cookie_cache: disable_cookie_cache, sensitive: authoritative)
19
25
  if cached
20
26
  ctx.context.set_current_session(cached) if ctx.context.respond_to?(:set_current_session)
21
27
  return cached
@@ -57,10 +63,16 @@ module BetterAuth
57
63
  end
58
64
 
59
65
  def missing_session(ctx)
66
+ ctx.context.set_current_session(nil) if ctx.context.respond_to?(:set_current_session)
60
67
  Cookies.delete_session_cookie(ctx)
61
68
  nil
62
69
  end
63
70
 
71
+ def stateful?(ctx)
72
+ options = ctx.context.options
73
+ !options.database.nil? || !options.secondary_storage.nil?
74
+ end
75
+
64
76
  def expired?(session)
65
77
  expires_at = normalize_time(session["expiresAt"])
66
78
  expires_at && expires_at <= Time.now
@@ -84,7 +96,12 @@ module BetterAuth
84
96
  "expiresAt" => expires_at,
85
97
  "updatedAt" => now
86
98
  )
87
- session = stringify_keys(updated || result[:session]).merge("expiresAt" => expires_at, "updatedAt" => now)
99
+ unless updated
100
+ missing_session(ctx)
101
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"])
102
+ end
103
+
104
+ session = stringify_keys(updated)
88
105
  refreshed = {session: session, user: result[:user]}
89
106
  Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx))
90
107
  refreshed
@@ -92,12 +109,13 @@ module BetterAuth
92
109
 
93
110
  def refresh_cached_session(ctx, result)
94
111
  now = Time.now
95
- session = stringify_keys(result[:session]).merge(
96
- "expiresAt" => now + ctx.context.session_config[:expires_in].to_i,
97
- "updatedAt" => now
98
- )
112
+ session = stringify_keys(result[:session])
113
+ expires_at = normalize_time(session["expiresAt"])
114
+ return missing_session(ctx) if expires_at && expires_at <= now
115
+
116
+ remaining = expires_at && [(expires_at - now).floor, 0].max
99
117
  refreshed = {session: session, user: result[:user]}
100
- Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx))
118
+ Cookies.set_session_cookie(ctx, refreshed, Cookies.dont_remember?(ctx), max_age: remaining)
101
119
  refreshed
102
120
  end
103
121
 
@@ -6,7 +6,7 @@ module BetterAuth
6
6
  ESTIMATED_EMPTY_COOKIE_SIZE = 200
7
7
  CHUNK_SIZE = ALLOWED_COOKIE_SIZE - ESTIMATED_EMPTY_COOKIE_SIZE
8
8
 
9
- CookieValue = Struct.new(:name, :value, :attributes, keyword_init: true)
9
+ CookieValue = Struct.new(:name, :value, :attributes)
10
10
 
11
11
  attr_reader :cookie_name, :cookie_options, :context, :chunks
12
12
 
@@ -45,8 +45,13 @@ module BetterAuth
45
45
  algorithms: ["RS256"],
46
46
  issuers: "https://appleid.apple.com",
47
47
  audience: audiences,
48
- nonce: nonce
48
+ nonce: nil
49
49
  )
50
+ if nonce
51
+ token_nonce = profile&.fetch("nonce", nil)
52
+ expected_nonces = [nonce, OpenSSL::Digest::SHA256.hexdigest(nonce.to_s)]
53
+ return false unless token_nonce.is_a?(String) && expected_nonces.include?(token_nonce)
54
+ end
50
55
  !!profile&.fetch("sub", nil)
51
56
  end,
52
57
  get_user_info: lambda do |tokens|
@@ -15,6 +15,7 @@ module BetterAuth
15
15
  user_info_endpoint: "https://api.atlassian.com/me",
16
16
  scopes: scopes,
17
17
  pkce: true,
18
+ require_code_verifier: true,
18
19
  auth_params: {audience: "api.atlassian.com"},
19
20
  profile_map: ->(profile) {
20
21
  {
@@ -26,8 +26,9 @@ module BetterAuth
26
26
  uri.to_s
27
27
  end
28
28
 
29
- def oauth_provider(id:, name:, client_id:, authorization_endpoint:, token_endpoint:, profile_map:, client_secret: nil, user_info_endpoint: nil, scopes: [], scope_separator: " ", pkce: false, auth_params: {}, token_params: {}, user_info_method: :get, user_info_headers: {}, user_info_body: nil, **options)
29
+ def oauth_provider(id:, name:, client_id:, authorization_endpoint:, token_endpoint:, profile_map:, client_secret: nil, user_info_endpoint: nil, scopes: [], scope_separator: " ", pkce: false, require_code_verifier: false, auth_params: {}, token_params: {}, token_authentication: :post, user_info_method: :get, user_info_headers: {}, user_info_body: nil, **options)
30
30
  opts = normalize_options(options.merge(client_id: client_id, client_secret: client_secret))
31
+ primary_id = primary_client_id(client_id)
31
32
  {
32
33
  id: id,
33
34
  name: name,
@@ -36,9 +37,11 @@ module BetterAuth
36
37
  options: opts,
37
38
  create_authorization_url: lambda do |data|
38
39
  verifier = value(data, :code_verifier, :codeVerifier)
40
+ raise Error, "codeVerifier is required for #{name}" if require_code_verifier && verifier.to_s.empty?
41
+
39
42
  selected_scopes = selected_scopes(scopes, opts, data)
40
43
  params = {
41
- client_id: primary_client_id(client_id),
44
+ client_id: primary_id,
42
45
  redirect_uri: opts[:redirect_uri] || value(data, :redirect_uri, :redirectURI),
43
46
  response_type: "code",
44
47
  scope: selected_scopes.empty? ? nil : selected_scopes.join(scope_separator),
@@ -51,21 +54,27 @@ module BetterAuth
51
54
  authorization_url(option(opts, :authorization_endpoint, :authorizationEndpoint) || authorization_endpoint, params)
52
55
  end,
53
56
  validate_authorization_code: lambda do |data|
54
- post_form_json(option(opts, :token_endpoint, :tokenEndpoint) || token_endpoint, {
55
- client_id: primary_client_id(client_id),
56
- client_secret: client_secret,
57
+ token_form = {
57
58
  code: value(data, :code),
58
59
  code_verifier: value(data, :code_verifier, :codeVerifier),
59
60
  grant_type: "authorization_code",
60
61
  redirect_uri: opts[:redirect_uri] || value(data, :redirect_uri, :redirectURI)
61
- }.merge(resolve_hash(token_params, data, opts)))
62
+ }.merge(resolve_hash(token_params, data, opts))
63
+ post_token_form(
64
+ option(opts, :token_endpoint, :tokenEndpoint) || token_endpoint,
65
+ token_form,
66
+ client_id: primary_id,
67
+ client_secret: client_secret,
68
+ authentication: token_authentication
69
+ )
62
70
  end,
63
71
  refresh_access_token: opts[:refresh_access_token] || lambda do |refresh_token|
64
72
  refresh_access_token(
65
73
  option(opts, :token_endpoint, :tokenEndpoint) || token_endpoint,
66
74
  refresh_token,
67
- client_id: primary_client_id(client_id),
68
- client_secret: client_secret
75
+ client_id: primary_id,
76
+ client_secret: client_secret,
77
+ authentication: token_authentication
69
78
  )
70
79
  end,
71
80
  verify_id_token: opts[:verify_id_token],
@@ -138,13 +147,27 @@ module BetterAuth
138
147
  (method == :post) ? post_json(endpoint, resolved_body, resolved_headers) : get_json(endpoint, resolved_headers)
139
148
  end
140
149
 
141
- def refresh_access_token(token_endpoint, refresh_token, client_id:, client_secret: nil, extra_params: {})
142
- normalize_tokens(post_form_json(token_endpoint, {
143
- client_id: client_id,
144
- client_secret: client_secret,
150
+ def refresh_access_token(token_endpoint, refresh_token, client_id:, client_secret: nil, extra_params: {}, authentication: :post)
151
+ normalize_tokens(post_token_form(token_endpoint, {
145
152
  grant_type: "refresh_token",
146
153
  refresh_token: refresh_token
147
- }.merge(extra_params || {})))
154
+ }.merge(extra_params || {}), client_id: client_id, client_secret: client_secret, authentication: authentication))
155
+ end
156
+
157
+ def post_token_form(token_endpoint, form, client_id:, client_secret: nil, authentication: :post, headers: {})
158
+ case authentication&.to_sym
159
+ when :basic
160
+ post_form_json(token_endpoint, form.compact, basic_auth_headers(client_id, client_secret).merge(headers))
161
+ else
162
+ post_form_json(token_endpoint, {
163
+ client_id: client_id,
164
+ client_secret: client_secret
165
+ }.merge(form), headers)
166
+ end
167
+ end
168
+
169
+ def basic_auth_headers(client_id, client_secret)
170
+ {"Authorization" => "Basic #{Base64.strict_encode64("#{client_id}:#{client_secret}")}"}
148
171
  end
149
172
 
150
173
  def normalize_tokens(tokens, now: Time.now)
@@ -211,7 +234,11 @@ module BetterAuth
211
234
  appBundleIdentifier: :app_bundle_identifier,
212
235
  profilePhotoSize: :profile_photo_size,
213
236
  disableProfilePhoto: :disable_profile_photo,
214
- tenantId: :tenant_id
237
+ tenantId: :tenant_id,
238
+ userPoolId: :user_pool_id,
239
+ requireClientSecret: :require_client_secret,
240
+ configId: :config_id,
241
+ loginUrl: :login_url
215
242
  }.each do |camel, snake|
216
243
  normalized[snake] = normalized[camel] if normalized.key?(camel) && !normalized.key?(snake)
217
244
  end
@@ -5,15 +5,25 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def cognito(client_id:, client_secret: nil, scopes: ["openid", "profile", "email"], **options)
8
- domain = (options[:domain] || options[:issuer] || "https://cognito-idp.#{options[:region] || "us-east-1"}.amazonaws.com").to_s.sub(%r{/+\z}, "")
9
- Base.oauth_provider(
8
+ normalized = Base.normalize_options(options)
9
+ domain = normalized[:domain]
10
+ region = normalized[:region]
11
+ user_pool_id = normalized[:user_pool_id]
12
+ raise Error, "DOMAIN_AND_REGION_REQUIRED" if domain.to_s.empty? || region.to_s.empty? || user_pool_id.to_s.empty?
13
+
14
+ if normalized[:require_client_secret] && client_secret.to_s.empty?
15
+ raise Error, "CLIENT_SECRET_REQUIRED"
16
+ end
17
+
18
+ clean_domain = domain.to_s.sub(%r{\Ahttps?://}, "").sub(%r{/+\z}, "")
19
+ provider = Base.oauth_provider(
10
20
  id: "cognito",
11
21
  name: "Cognito",
12
22
  client_id: client_id,
13
23
  client_secret: client_secret,
14
- authorization_endpoint: "#{domain}/oauth2/authorize",
15
- token_endpoint: "#{domain}/oauth2/token",
16
- user_info_endpoint: "#{domain}/oauth2/userinfo",
24
+ authorization_endpoint: "https://#{clean_domain}/oauth2/authorize",
25
+ token_endpoint: "https://#{clean_domain}/oauth2/token",
26
+ user_info_endpoint: "https://#{clean_domain}/oauth2/userinfo",
17
27
  scopes: scopes,
18
28
  pkce: true,
19
29
  profile_map: ->(profile) {
@@ -27,6 +37,66 @@ module BetterAuth
27
37
  },
28
38
  **options
29
39
  )
40
+ create_authorization_url = provider.fetch(:create_authorization_url)
41
+ provider[:create_authorization_url] = lambda do |data|
42
+ create_authorization_url.call(data).sub(/scope=([^&]+)/) { "scope=#{$1.gsub("+", "%20")}" }
43
+ end
44
+ provider[:verify_id_token] ||= lambda do |token, nonce = nil|
45
+ return false if normalized[:disable_id_token_sign_in]
46
+
47
+ profile = Base.verify_jwt_with_jwks(
48
+ token,
49
+ jwks: normalized[:jwks],
50
+ jwks_endpoint: normalized[:jwks_endpoint] || "https://cognito-idp.#{region}.amazonaws.com/#{user_pool_id}/.well-known/jwks.json",
51
+ algorithms: ["RS256"],
52
+ issuers: "https://cognito-idp.#{region}.amazonaws.com/#{user_pool_id}",
53
+ audience: Array(client_id),
54
+ nonce: nonce,
55
+ max_age: 3600
56
+ )
57
+ !!profile&.fetch("sub", nil)
58
+ end
59
+ provider[:get_user_info] = lambda do |tokens|
60
+ custom = normalized[:get_user_info]
61
+ next custom.call(tokens) if custom
62
+
63
+ if Base.id_token(tokens)
64
+ profile = Base.decode_jwt_payload(Base.id_token(tokens))
65
+ unless profile.empty?
66
+ name = profile["name"] || profile["given_name"] || profile["username"] || ""
67
+ enriched = profile.merge("name" => name)
68
+ user = Base.apply_profile_mapping(
69
+ {
70
+ id: profile["sub"],
71
+ name: name,
72
+ email: profile["email"],
73
+ image: profile["picture"],
74
+ emailVerified: !!profile["email_verified"]
75
+ },
76
+ enriched,
77
+ normalized
78
+ )
79
+ next({user: user, data: enriched})
80
+ end
81
+ end
82
+
83
+ profile = Base.fetch_user_info("https://#{clean_domain}/oauth2/userinfo", tokens)
84
+ next nil unless profile
85
+
86
+ user = Base.apply_profile_mapping(
87
+ {
88
+ id: profile["sub"],
89
+ name: profile["name"] || profile["given_name"] || profile["username"] || "",
90
+ email: profile["email"],
91
+ image: profile["picture"],
92
+ emailVerified: !!profile["email_verified"]
93
+ },
94
+ profile,
95
+ normalized
96
+ )
97
+ {user: user, data: profile}
98
+ end
99
+ provider
30
100
  end
31
101
  end
32
102
  end
@@ -5,6 +5,7 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def facebook(client_id:, client_secret:, scopes: ["email", "public_profile"], **options)
8
+ normalized = Base.normalize_options(options)
8
9
  fields = Array(options[:fields] || %w[id name email picture email_verified]).join(",")
9
10
  provider = Base.oauth_provider(
10
11
  id: "facebook",
@@ -28,7 +29,100 @@ module BetterAuth
28
29
  },
29
30
  **options
30
31
  )
31
- provider.delete(:verify_id_token) unless provider[:verify_id_token]
32
+ verify_access_token = lambda do |access_token|
33
+ primary_client_id = Base.primary_client_id(client_id)
34
+ next nil if access_token.to_s.empty? || client_secret.to_s.empty?
35
+
36
+ response = Base.get_json(Base.authorization_url("https://graph.facebook.com/debug_token", {
37
+ input_token: access_token,
38
+ access_token: "#{primary_client_id}|#{client_secret}"
39
+ }))
40
+ data = response.is_a?(Hash) ? response["data"] : nil
41
+ next nil unless data.is_a?(Hash)
42
+ next nil unless data["is_valid"] == true
43
+ next nil unless Array(client_id).map(&:to_s).include?(data["app_id"].to_s)
44
+ next nil if data["app_id"].to_s.empty? || data["user_id"].to_s.empty?
45
+
46
+ data["user_id"]
47
+ rescue
48
+ nil
49
+ end
50
+ provider[:verify_id_token] = lambda do |token, nonce = nil|
51
+ return false if normalized[:disable_id_token_sign_in]
52
+
53
+ custom = normalized[:verify_id_token]
54
+ next custom.call(token, nonce) if custom
55
+
56
+ begin
57
+ unless token.to_s.split(".").length == 3
58
+ next !!verify_access_token.call(token)
59
+ end
60
+
61
+ profile = Base.verify_jwt_with_jwks(
62
+ token,
63
+ jwks: normalized[:jwks],
64
+ jwks_endpoint: normalized[:jwks_endpoint] || "https://limited.facebook.com/.well-known/oauth/openid/jwks/",
65
+ algorithms: ["RS256"],
66
+ issuers: "https://www.facebook.com",
67
+ audience: Array(client_id),
68
+ nonce: nonce
69
+ )
70
+ !!profile
71
+ rescue
72
+ false
73
+ end
74
+ end
75
+ provider[:get_user_info] = lambda do |tokens|
76
+ custom = normalized[:get_user_info]
77
+ next custom.call(tokens) if custom
78
+
79
+ begin
80
+ token = Base.id_token(tokens)
81
+ if token && token.split(".").length == 3
82
+ profile = Base.decode_jwt_payload(token)
83
+ next nil if profile["sub"].to_s.empty?
84
+
85
+ user = Base.apply_profile_mapping(
86
+ {
87
+ id: profile["sub"],
88
+ name: profile["name"],
89
+ email: profile["email"],
90
+ image: profile["picture"],
91
+ emailVerified: false
92
+ },
93
+ profile.merge("email_verified" => false),
94
+ normalized
95
+ )
96
+ next({user: user, data: profile})
97
+ end
98
+
99
+ access_token = Base.access_token(tokens)
100
+ next nil if access_token.to_s.empty?
101
+
102
+ token_user_id = verify_access_token.call(access_token)
103
+ next nil unless token_user_id
104
+
105
+ profile = Base.fetch_user_info("https://graph.facebook.com/me?fields=#{URI.encode_www_form_component(fields)}", tokens)
106
+ next nil unless profile.is_a?(Hash)
107
+ next nil unless profile["id"] == token_user_id
108
+
109
+ picture = profile.dig("picture", "data", "url") || profile["picture"]
110
+ user = Base.apply_profile_mapping(
111
+ {
112
+ id: profile["id"] || profile["sub"],
113
+ name: profile["name"],
114
+ email: profile["email"],
115
+ image: picture,
116
+ emailVerified: !!profile["email_verified"]
117
+ },
118
+ profile,
119
+ normalized
120
+ )
121
+ {user: user, data: profile}
122
+ rescue
123
+ nil
124
+ end
125
+ end
32
126
  provider
33
127
  end
34
128
  end
@@ -15,6 +15,8 @@ module BetterAuth
15
15
  user_info_endpoint: "https://api.figma.com/v1/me",
16
16
  scopes: scopes,
17
17
  pkce: true,
18
+ require_code_verifier: true,
19
+ token_authentication: :basic,
18
20
  profile_map: ->(profile) {
19
21
  {
20
22
  id: profile["id"],
@@ -4,6 +4,14 @@ module BetterAuth
4
4
  module SocialProviders
5
5
  module_function
6
6
 
7
+ def google_hosted_domain_allowed?(configured_hosted_domain, token_hosted_domain)
8
+ return true if configured_hosted_domain.nil? || configured_hosted_domain.to_s.empty?
9
+ return false unless token_hosted_domain.is_a?(String) && !token_hosted_domain.strip.empty?
10
+ return true if configured_hosted_domain == "*"
11
+
12
+ token_hosted_domain == configured_hosted_domain
13
+ end
14
+
7
15
  def google(client_id:, client_secret:, scopes: ["openid", "email", "profile"], **options)
8
16
  normalized = Base.normalize_options(options)
9
17
  primary_client_id = Base.primary_client_id(client_id)
@@ -12,6 +20,7 @@ module BetterAuth
12
20
  name: "Google",
13
21
  client_id: client_id,
14
22
  client_secret: client_secret,
23
+ options: normalized,
15
24
  create_authorization_url: lambda do |data|
16
25
  verifier = data[:code_verifier] || data[:codeVerifier]
17
26
  raise Error, "codeVerifier is required for Google" if verifier.to_s.empty?
@@ -57,7 +66,7 @@ module BetterAuth
57
66
  audience: audiences,
58
67
  nonce: nonce
59
68
  )
60
- !!profile&.fetch("sub", nil)
69
+ !!profile&.fetch("sub", nil) && google_hosted_domain_allowed?(normalized[:hd], profile["hd"])
61
70
  end,
62
71
  get_user_info: lambda do |tokens|
63
72
  custom = normalized[:get_user_info]
@@ -65,6 +74,8 @@ module BetterAuth
65
74
  next nil unless Base.id_token(tokens)
66
75
 
67
76
  profile = Base.decode_jwt_payload(Base.id_token(tokens))
77
+ next nil unless google_hosted_domain_allowed?(normalized[:hd], profile["hd"])
78
+
68
79
  user = Base.apply_profile_mapping(
69
80
  {
70
81
  id: profile["sub"],