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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth/sql_migration"
4
+
5
+ module BetterAuth
6
+ module Doctor
7
+ Result = Struct.new(:ok, :warnings, :errors) do
8
+ def success?
9
+ errors.empty?
10
+ end
11
+ end
12
+
13
+ module_function
14
+
15
+ def check(config_or_options)
16
+ config = BetterAuth::SQLMigration.configuration_for(config_or_options)
17
+ result = Result.new(ok: ["config loaded"], warnings: [], errors: [])
18
+
19
+ check_secret(config, result)
20
+ check_base_url(config, result)
21
+ check_rate_limit(config, result)
22
+ check_database(config, result)
23
+
24
+ result
25
+ end
26
+
27
+ def print(result, stdout:, stderr:)
28
+ result.ok.each { |message| stdout.puts "OK #{message}" }
29
+ result.warnings.each { |message| stdout.puts "WARN #{message}" }
30
+ result.errors.each { |message| stderr.puts "ERROR #{message}" }
31
+ result.success? ? 0 : 1
32
+ end
33
+
34
+ def as_json(result)
35
+ {
36
+ "ok" => result.ok,
37
+ "warnings" => result.warnings,
38
+ "errors" => result.errors,
39
+ "success" => result.success?
40
+ }
41
+ end
42
+
43
+ def check_secret(config, result)
44
+ secret = config.secret.to_s
45
+ if secret.empty?
46
+ result.errors << "secret is missing"
47
+ elsif secret == BetterAuth::Configuration::DEFAULT_SECRET
48
+ result.errors << "secret uses the default development value"
49
+ elsif secret.length < 32
50
+ result.errors << "secret should be at least 32 characters"
51
+ elsif entropy(secret) < 120
52
+ result.errors << "secret appears low-entropy; use a random production secret"
53
+ else
54
+ result.ok << "secret length and entropy look acceptable"
55
+ end
56
+ end
57
+
58
+ def check_base_url(config, result)
59
+ base_url = config.base_url.to_s
60
+ if base_url.empty?
61
+ result.errors << "base_url is not configured"
62
+ elsif !base_url.start_with?("https://")
63
+ result.warnings << "base_url is not HTTPS"
64
+ else
65
+ result.ok << "base_url uses HTTPS"
66
+ end
67
+ end
68
+
69
+ def check_rate_limit(config, result)
70
+ rate_limit = config.rate_limit || {}
71
+ result.warnings << "rate_limit is disabled" unless rate_limit[:enabled]
72
+ if rate_limit[:storage].to_s == "memory"
73
+ result.warnings << "rate_limit uses memory storage; use database or secondary-storage for multi-process production deployments"
74
+ else
75
+ result.ok << "rate_limit storage is #{rate_limit[:storage]}"
76
+ end
77
+ end
78
+
79
+ def check_database(config, result)
80
+ auth = BetterAuth.auth(config.to_h)
81
+ adapter = auth.context.adapter
82
+ unless adapter.respond_to?(:dialect) && adapter.respond_to?(:connection)
83
+ warning = "database adapter does not expose SQL migration introspection; schema drift check skipped"
84
+ warning += "; run `better-auth mongo indexes` to ensure MongoDB indexes" if adapter.respond_to?(:ensure_indexes!)
85
+ result.warnings << warning
86
+ return
87
+ end
88
+
89
+ result.ok << "database adapter supports SQL migrations"
90
+ plan = BetterAuth::SQLMigration.plan(config, connection: adapter.connection, dialect: adapter.dialect)
91
+ if plan.empty?
92
+ result.ok << "database schema is up to date"
93
+ else
94
+ result.warnings << "database has pending Better Auth migrations"
95
+ plan.warnings.each { |warning| result.warnings << warning }
96
+ end
97
+ rescue BetterAuth::SQLMigration::UnsupportedAdapterError => error
98
+ result.warnings << error.message
99
+ end
100
+
101
+ def entropy(value)
102
+ unique = value.chars.uniq.length
103
+ return 0 if unique.zero?
104
+
105
+ Math.log2(unique**value.length)
106
+ end
107
+ end
108
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "uri"
4
5
 
5
6
  module BetterAuth
6
7
  class Endpoint
@@ -24,6 +25,7 @@ module BetterAuth
24
25
  @headers_schema = headers_schema
25
26
  @metadata = metadata || {}
26
27
  apply_default_open_api_metadata!
28
+ apply_open_api_defaults!
27
29
  apply_open_api_schemas!
28
30
  @options = endpoint_options
29
31
  @use = Array(use)
@@ -40,13 +42,12 @@ module BetterAuth
40
42
  end
41
43
 
42
44
  def call(context)
43
- apply_schemas!(context)
44
-
45
45
  use.each do |middleware|
46
46
  middleware_result = middleware.call(context)
47
47
  return Result.from_value(middleware_result, context) if middleware_result
48
48
  end
49
49
 
50
+ apply_schemas!(context)
50
51
  Result.from_value(handler.call(context), context)
51
52
  end
52
53
 
@@ -72,6 +73,41 @@ module BetterAuth
72
73
  metadata[:openapi] = BetterAuth::OpenAPI.default_metadata(path, methods)
73
74
  end
74
75
 
76
+ def apply_open_api_defaults!
77
+ return unless path
78
+ return unless defined?(BetterAuth::OpenAPI)
79
+
80
+ openapi = fetch_key(metadata, :openapi)
81
+ return unless openapi.is_a?(Hash)
82
+
83
+ defaults = BetterAuth::OpenAPI.default_metadata(path, methods)
84
+ openapi[:operationId] = defaults[:operationId] if fetch_key(openapi, :operationId).to_s.empty?
85
+ openapi[:description] = defaults[:description] if default_open_api_description?(fetch_key(openapi, :description))
86
+ openapi[:parameters] = merge_open_api_parameters(defaults[:parameters], fetch_key(openapi, :parameters))
87
+ openapi[:responses] = defaults[:responses].merge(fetch_key(openapi, :responses) || {})
88
+ if request_body_method? && !fetch_key(openapi, :requestBody).is_a?(Hash)
89
+ openapi[:requestBody] = defaults[:requestBody] || BetterAuth::OpenAPI.default_request_body
90
+ end
91
+ end
92
+
93
+ def default_open_api_description?(description)
94
+ methods.any? { |method| description.to_s == "#{method} #{path}" }
95
+ end
96
+
97
+ def request_body_method?
98
+ methods.any? { |method| %w[POST PUT PATCH].include?(method) }
99
+ end
100
+
101
+ def merge_open_api_parameters(default_parameters, custom_parameters)
102
+ merged = Array(custom_parameters).dup
103
+ Array(default_parameters).each do |parameter|
104
+ next if merged.any? { |entry| fetch_key(entry, :name).to_s == fetch_key(parameter, :name).to_s && fetch_key(entry, :in).to_s == fetch_key(parameter, :in).to_s }
105
+
106
+ merged << parameter
107
+ end
108
+ merged
109
+ end
110
+
75
111
  def apply_open_api_schemas!
76
112
  openapi = fetch_key(metadata, :openapi)
77
113
  return unless openapi.is_a?(Hash)
@@ -172,6 +208,42 @@ module BetterAuth
172
208
  class Result
173
209
  attr_accessor :response, :status, :headers
174
210
 
211
+ class SetCookieHeader < Array
212
+ def self.from(value)
213
+ new(value.lines.map(&:chomp))
214
+ end
215
+
216
+ def include?(value)
217
+ return super unless value.is_a?(String)
218
+
219
+ any? { |line| line.include?(value) }
220
+ end
221
+
222
+ def lines
223
+ self
224
+ end
225
+
226
+ def match(...)
227
+ to_s.match(...)
228
+ end
229
+
230
+ def =~(pattern)
231
+ to_s =~ pattern
232
+ end
233
+
234
+ def split(...)
235
+ to_s.split(...)
236
+ end
237
+
238
+ def to_s
239
+ join("\n")
240
+ end
241
+
242
+ def to_str
243
+ to_s
244
+ end
245
+ end
246
+
175
247
  def initialize(response:, status: 200, headers: {}, raw_response: nil)
176
248
  @response = response
177
249
  @status = status
@@ -223,7 +295,7 @@ module BetterAuth
223
295
  end
224
296
 
225
297
  def to_response
226
- return Response.from_rack(@raw_response) if raw_response?
298
+ return Response.from_rack([@raw_response[0], rack_headers(@raw_response[1]), @raw_response[2]]) if raw_response?
227
299
 
228
300
  body = if response.nil?
229
301
  [JSON.generate(nil)]
@@ -232,12 +304,23 @@ module BetterAuth
232
304
  else
233
305
  [JSON.generate(response)]
234
306
  end
235
- response_headers = {"content-type" => "application/json"}.merge(headers)
307
+ response_headers = rack_headers({"content-type" => "application/json"}.merge(headers))
236
308
  Response.new(status: status, headers: response_headers, body: body)
237
309
  end
238
310
 
239
311
  private
240
312
 
313
+ def rack_headers(value)
314
+ value.each_with_object({}) do |(key, header_value), result|
315
+ normalized = key.to_s.downcase
316
+ result[normalized] = if normalized == "set-cookie" && header_value.is_a?(String) && header_value.include?("\n")
317
+ SetCookieHeader.from(header_value)
318
+ else
319
+ header_value
320
+ end
321
+ end
322
+ end
323
+
241
324
  def normalize_headers(headers)
242
325
  headers.each_with_object({}) do |(key, value), result|
243
326
  result[key.to_s.downcase] = value
@@ -290,7 +373,7 @@ module BetterAuth
290
373
 
291
374
  def set_cookie(name, value, options = {})
292
375
  attributes = cookie_attributes(options)
293
- cookie = (["#{name}=#{value}"] + attributes).join("; ")
376
+ cookie = (["#{name}=#{URI.encode_uri_component(value.to_s)}"] + attributes).join("; ")
294
377
  set_header("set-cookie", cookie)
295
378
  end
296
379
 
@@ -17,6 +17,7 @@ module BetterAuth
17
17
  "PROVIDER_NOT_FOUND" => "Provider not found",
18
18
  "INVALID_TOKEN" => "Invalid token",
19
19
  "TOKEN_EXPIRED" => "Token expired",
20
+ "TOKEN_ALREADY_USED" => "Token has already been used",
20
21
  "ID_TOKEN_NOT_SUPPORTED" => "id_token not supported",
21
22
  "FAILED_TO_GET_USER_INFO" => "Failed to get user info",
22
23
  "USER_EMAIL_NOT_FOUND" => "User email not found",
@@ -54,6 +55,7 @@ module BetterAuth
54
55
  "PASSWORD_ALREADY_SET" => "User already has a password set",
55
56
  "RESET_PASSWORD_DISABLED" => "Reset password isn't enabled",
56
57
  "EMAIL_PASSWORD_DISABLED" => "Email and password is not enabled",
57
- "EMAIL_PASSWORD_SIGN_UP_DISABLED" => "Email and password sign up is not enabled"
58
+ "EMAIL_PASSWORD_SIGN_UP_DISABLED" => "Email and password sign up is not enabled",
59
+ "CHANGE_EMAIL_DISABLED" => "Change email is disabled"
58
60
  }.freeze
59
61
  end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module HTTPClient
9
+ DEFAULT_OPEN_TIMEOUT = 5
10
+ DEFAULT_READ_TIMEOUT = 5
11
+
12
+ module_function
13
+
14
+ def request(uri, request, open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT)
15
+ Net::HTTP.start(
16
+ uri.hostname || uri.host,
17
+ uri.port,
18
+ use_ssl: uri.scheme == "https",
19
+ open_timeout: open_timeout,
20
+ read_timeout: read_timeout
21
+ ) do |http|
22
+ http.request(request)
23
+ end
24
+ end
25
+
26
+ def get_response(uri, headers = {})
27
+ request = Net::HTTP::Get.new(uri)
28
+ headers.each { |key, value| request[key.to_s] = value.to_s }
29
+ request(uri, request)
30
+ end
31
+
32
+ def post_form(uri, form_body, headers = {})
33
+ request = Net::HTTP::Post.new(uri)
34
+ headers.each { |key, value| request[key.to_s] = value.to_s }
35
+ request["Content-Type"] ||= "application/x-www-form-urlencoded"
36
+ request.body = form_body
37
+ request(uri, request)
38
+ end
39
+
40
+ def get_json(url, headers = {})
41
+ uri = url.is_a?(URI) ? url : URI.parse(url.to_s)
42
+ response = get_response(uri, headers)
43
+ response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body.to_s) : nil
44
+ end
45
+ end
46
+ end
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module Logger
5
5
  LEVELS = [:debug, :info, :success, :warn, :error].freeze
6
6
 
7
- Internal = Struct.new(:level, :disabled, :handler, keyword_init: true) do
7
+ Internal = Struct.new(:level, :disabled, :handler) do
8
8
  LEVELS.each do |log_level|
9
9
  define_method(log_level) do |message, *args|
10
10
  return if disabled || !Logger.should_publish?(level, log_level)
@@ -55,7 +55,11 @@ module BetterAuth
55
55
  mode = headers["sec-fetch-mode"]
56
56
  dest = headers["sec-fetch-dest"]
57
57
  has_metadata = [site, mode, dest].any? { |value| value && !value.to_s.strip.empty? }
58
- return unless has_metadata
58
+ unless has_metadata
59
+ origin = headers["origin"] || headers["referer"]
60
+ validate_origin(endpoint_context, force: true) if login_or_signup_path?(endpoint_context.path) && origin && !origin.to_s.empty?
61
+ return
62
+ end
59
63
 
60
64
  if site == "cross-site" && mode == "navigate"
61
65
  log(endpoint_context.context, :error, "Blocked cross-site navigation login attempt (CSRF protection)")
@@ -109,6 +113,10 @@ module BetterAuth
109
113
  skip.any? { |path| endpoint_context.path.start_with?(path.to_s) }
110
114
  end
111
115
 
116
+ def login_or_signup_path?(path)
117
+ path.to_s.match?(%r{/(?:sign-in|sign-up)(?:/|$)})
118
+ end
119
+
112
120
  def fetch_data(data, key)
113
121
  return unless data.is_a?(Hash)
114
122
 
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module MigrationPlan
5
+ TableChange = Struct.new(:logical_name, :table_name, :table, :order)
6
+ FieldChange = Struct.new(:logical_name, :table_name, :fields, :table, :order)
7
+ IndexChange = Struct.new(:table_name, :field_name, :name, :unique, :field)
8
+
9
+ Plan = Struct.new(:to_create, :to_add, :to_index, :warnings, :dialect, :tables) do
10
+ def empty?
11
+ to_create.empty? && to_add.empty? && to_index.empty?
12
+ end
13
+ end
14
+ end
15
+ end
@@ -81,7 +81,7 @@ module BetterAuth
81
81
 
82
82
  def post_form(token_endpoint, request)
83
83
  uri = URI.parse(token_endpoint)
84
- response = Net::HTTP.post(uri, URI.encode_www_form(request[:body]), request[:headers])
84
+ response = HTTPClient.post_form(uri, URI.encode_www_form(request[:body]), request[:headers])
85
85
  JSON.parse(response.body)
86
86
  end
87
87
 
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module BetterAuth
6
+ # Shared OAuth state storage matching upstream's opaque-state adaptation.
7
+ # State data is encrypted client-side for the cookie strategy or consumed
8
+ # from verification storage for the database strategy. Signed JWT state is
9
+ # still accepted so in-flight legacy sign-in and link flows keep working.
10
+ module OAuthState
11
+ INTERNAL_KEYS = %w[
12
+ callbackURL codeVerifier errorURL newUserURL expiresAt oauthState link requestSignUp
13
+ ].freeze
14
+
15
+ class Error < BetterAuth::Error
16
+ attr_reader :code, :error_url
17
+
18
+ def initialize(code, error_url: nil)
19
+ @code = code
20
+ @error_url = error_url
21
+ super(code)
22
+ end
23
+ end
24
+
25
+ module_function
26
+
27
+ def generate(ctx, state_data)
28
+ state = Crypto.random_string(32)
29
+ payload = stringify_keys(state_data).merge("oauthState" => state)
30
+
31
+ if cookie_strategy?(ctx)
32
+ cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
33
+ encrypted = Crypto.symmetric_encrypt(
34
+ key: ctx.context.secret_config,
35
+ data: JSON.generate(payload)
36
+ )
37
+ ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
38
+ else
39
+ cookie = ctx.context.create_auth_cookie("state", max_age: 300)
40
+ ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
41
+ verification = ctx.context.internal_adapter.create_verification_value(
42
+ identifier: state,
43
+ value: JSON.generate(payload),
44
+ expiresAt: Time.now + 600
45
+ )
46
+ raise Error, "state_generation_error" unless verification
47
+ end
48
+
49
+ state
50
+ end
51
+
52
+ def parse(ctx, state)
53
+ raise Error, "state_not_found" if state.to_s.empty?
54
+
55
+ legacy = Crypto.verify_jwt(state.to_s, ctx.context.secret)
56
+ return parse_legacy(ctx, state, legacy) if legacy
57
+
58
+ cookie_strategy?(ctx) ? parse_cookie_state(ctx, state) : parse_database_state(ctx, state)
59
+ rescue JSON::ParserError
60
+ raise Error, "state_invalid"
61
+ end
62
+
63
+ def parse_legacy(ctx, state, data)
64
+ cookie = ctx.context.create_auth_cookie("state", max_age: 600)
65
+ stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
66
+ Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
67
+ valid = ctx.request ? stored == state : (stored.nil? || stored == state)
68
+ return data if valid
69
+
70
+ raise Error.new("state_mismatch", error_url: recovered_error_url(data))
71
+ end
72
+
73
+ def parse_cookie_state(ctx, state)
74
+ cookie = ctx.context.create_auth_cookie("oauth_state")
75
+ encrypted = ctx.get_cookie(cookie.name)
76
+ raise Error, "state_mismatch" if encrypted.to_s.empty?
77
+
78
+ data = begin
79
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
80
+ JSON.parse(decrypted)
81
+ rescue JSON::ParserError, ArgumentError, TypeError
82
+ raise Error, "state_invalid"
83
+ end
84
+
85
+ expected = data["oauthState"] || data["state"]
86
+ unless expected == state
87
+ raise Error.new("state_mismatch", error_url: recovered_error_url(data))
88
+ end
89
+
90
+ Cookies.expire_cookie(ctx, cookie)
91
+ validate_expiration!(data)
92
+ data
93
+ end
94
+
95
+ def parse_database_state(ctx, state)
96
+ preview = ctx.context.internal_adapter.find_verification_value(state)
97
+ raise Error, "state_mismatch" unless preview
98
+
99
+ data = JSON.parse(preview.fetch("value"))
100
+ error_url = recovered_error_url(data)
101
+ expected = data["oauthState"] || data["state"]
102
+ raise Error.new("state_mismatch", error_url: error_url) if expected && expected != state
103
+
104
+ cookie = ctx.context.create_auth_cookie("state")
105
+ stored = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
106
+ valid = ctx.request ? stored == state : (stored.nil? || stored == state)
107
+ raise Error.new("state_mismatch", error_url: error_url) unless valid
108
+
109
+ consumed = ctx.context.internal_adapter.consume_verification_value(state)
110
+ Cookies.expire_cookie(ctx, cookie) if ctx.request || stored
111
+ raise Error.new("state_mismatch", error_url: error_url) unless consumed
112
+
113
+ consumed_data = JSON.parse(consumed.fetch("value"))
114
+ consumed_expected = consumed_data["oauthState"] || consumed_data["state"]
115
+ same_payload = consumed.fetch("value") == preview.fetch("value")
116
+ expected_matches = !consumed_expected || consumed_expected == state
117
+ unless same_payload && expected_matches
118
+ raise Error.new("state_mismatch", error_url: recovered_error_url(consumed_data) || error_url)
119
+ end
120
+
121
+ validate_expiration!(consumed_data)
122
+ consumed_data
123
+ end
124
+
125
+ def validate_expiration!(data)
126
+ expires_at = data["expiresAt"].to_i
127
+ return data unless expires_at.positive? && expires_at < Time.now.to_i
128
+
129
+ raise Error.new("state_mismatch", error_url: recovered_error_url(data))
130
+ end
131
+
132
+ def recovered_error_url(data)
133
+ data["errorURL"] || data["errorCallbackURL"]
134
+ end
135
+
136
+ def cookie_strategy?(ctx)
137
+ ctx.context.options.account[:store_state_strategy].to_s == "cookie"
138
+ end
139
+
140
+ def stringify_keys(value)
141
+ return value.each_with_object({}) { |(key, object), result| result[key.to_s] = stringify_keys(object) } if value.is_a?(Hash)
142
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
143
+
144
+ value
145
+ end
146
+ end
147
+ end