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
@@ -31,14 +31,14 @@ module BetterAuth
31
31
  init: ->(context) {
32
32
  {
33
33
  options: {
34
- social_providers: generic_oauth_social_providers(config, context).merge(context.social_providers)
34
+ social_providers: context.social_providers.merge(generic_oauth_social_providers(config, context))
35
35
  }
36
36
  }
37
37
  },
38
38
  endpoints: {
39
39
  sign_in_with_oauth2: sign_in_with_oauth2_endpoint(config),
40
- o_auth2_callback: o_auth2_callback_endpoint(config),
41
- o_auth2_link_account: o_auth2_link_account_endpoint(config)
40
+ oauth2_callback: oauth2_callback_endpoint(config),
41
+ oauth2_link_account: oauth2_link_account_endpoint(config)
42
42
  },
43
43
  error_codes: GENERIC_OAUTH_ERROR_CODES,
44
44
  options: config
@@ -212,6 +212,36 @@ module BetterAuth
212
212
  )
213
213
  end
214
214
 
215
+ def yandex(options = {})
216
+ data = normalize_hash(options)
217
+ generic_oauth_provider_config(
218
+ data,
219
+ provider_id: "yandex",
220
+ authorization_url: "https://oauth.yandex.com/authorize",
221
+ token_url: "https://oauth.yandex.com/token",
222
+ scopes: ["login:info", "login:email", "login:avatar"],
223
+ get_user_info: ->(tokens) {
224
+ profile = generic_oauth_fetch_json(
225
+ "https://login.yandex.ru/info?format=json",
226
+ authorization: "OAuth #{fetch_value(tokens, "accessToken")}"
227
+ )
228
+ return nil unless profile
229
+
230
+ avatar_id = fetch_value(profile, "default_avatar_id")
231
+ image = if !fetch_value(profile, "is_avatar_empty") && !avatar_id.to_s.empty?
232
+ "https://avatars.yandex.net/get-yapic/#{avatar_id}/islands-200"
233
+ end
234
+ {
235
+ id: fetch_value(profile, "id"),
236
+ name: fetch_value(profile, "display_name") || fetch_value(profile, "real_name") || fetch_value(profile, "first_name") || fetch_value(profile, "login"),
237
+ email: fetch_value(profile, "default_email") || Array(fetch_value(profile, "emails")).first,
238
+ emailVerified: false,
239
+ image: image
240
+ }.compact
241
+ }
242
+ )
243
+ end
244
+
215
245
  def sign_in_with_oauth2_endpoint(config)
216
246
  Endpoint.new(
217
247
  path: "/sign-in/oauth2",
@@ -220,6 +250,7 @@ module BetterAuth
220
250
  openapi: {
221
251
  operationId: "signInOAuth2",
222
252
  description: "Sign in with OAuth2",
253
+ requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
223
254
  responses: {
224
255
  "200" => OpenAPI.json_response("Sign in with OAuth2", generic_oauth_url_response_schema)
225
256
  }
@@ -234,7 +265,7 @@ module BetterAuth
234
265
  end
235
266
  end
236
267
 
237
- def o_auth2_link_account_endpoint(config)
268
+ def oauth2_link_account_endpoint(config)
238
269
  Endpoint.new(
239
270
  path: "/oauth2/link",
240
271
  method: "POST",
@@ -242,6 +273,7 @@ module BetterAuth
242
273
  openapi: {
243
274
  operationId: "linkOAuth2",
244
275
  description: "Link an OAuth2 account to the current user session",
276
+ requestBody: OpenAPI.json_request_body(generic_oauth_start_body_schema),
245
277
  responses: {
246
278
  "200" => OpenAPI.json_response("Authorization URL generated successfully for linking an OAuth2 account", generic_oauth_url_response_schema)
247
279
  }
@@ -264,7 +296,7 @@ module BetterAuth
264
296
  end
265
297
  end
266
298
 
267
- def o_auth2_callback_endpoint(config)
299
+ def oauth2_callback_endpoint(config)
268
300
  Endpoint.new(
269
301
  path: "/oauth2/callback/:providerId",
270
302
  method: "GET",
@@ -289,9 +321,9 @@ module BetterAuth
289
321
  provider = generic_oauth_provider!(config, provider_id)
290
322
  state_data = generic_oauth_parse_state(ctx, query[:state].to_s)
291
323
  error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error"
292
- redirect_error = ->(error) { raise ctx.redirect(generic_oauth_error_url(error_url, error)) }
324
+ redirect_error = ->(error, description = nil) { raise ctx.redirect(generic_oauth_error_url(error_url, error, description)) }
293
325
 
294
- redirect_error.call(query[:error] || "oAuth_code_missing") if query[:error] || query[:code].to_s.empty?
326
+ redirect_error.call(query[:error] || "oAuth_code_missing", query[:error_description]) if query[:error] || query[:code].to_s.empty?
295
327
  generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)
296
328
 
297
329
  tokens = begin
@@ -309,6 +341,7 @@ module BetterAuth
309
341
  account_id = fetch_value(mapped_user, "id").to_s
310
342
  redirect_error.call("email_is_missing") if email.empty?
311
343
  redirect_error.call("name_is_missing") if name.empty?
344
+ redirect_error.call("user_info_is_missing") if Routes.blank_remote_id?(account_id)
312
345
 
313
346
  link = state_data["link"]
314
347
  callback_url = state_data["callbackURL"] || "/"
@@ -321,21 +354,20 @@ module BetterAuth
321
354
  if !existing && (provider[:disable_sign_up] || (provider[:disable_implicit_sign_up] && !state_data["requestSignUp"]))
322
355
  redirect_error.call("signup_disabled")
323
356
  end
324
- if existing && provider[:override_user_info]
325
- ctx.context.internal_adapter.update_user(
326
- existing[:user]["id"],
327
- "name" => name,
328
- "image" => fetch_value(mapped_user, "image"),
329
- "emailVerified" => !!fetch_value(mapped_user, "emailVerified")
357
+ session_data = begin
358
+ Routes.persist_social_user(
359
+ ctx,
360
+ provider_id,
361
+ mapped_user.merge("email" => email, "name" => name, "id" => account_id),
362
+ generic_oauth_account_info(ctx, provider_id, account_id, tokens),
363
+ override_user_info: provider[:override_user_info]
330
364
  )
331
- end
365
+ rescue APIError => error
366
+ raise if error.code.to_s.empty?
332
367
 
333
- session_data = Routes.persist_social_user(
334
- ctx,
335
- provider_id,
336
- mapped_user.merge("email" => email, "name" => name, "id" => account_id),
337
- generic_oauth_account_info(ctx, provider_id, account_id, tokens)
338
- )
368
+ redirect_error.call(error.code, error.message)
369
+ end
370
+ redirect_error.call(session_data[:error].tr(" ", "_")) if session_data[:error]
339
371
  generic_oauth_set_account_cookie(ctx, provider_id, account_id, session_data[:user]["id"])
340
372
  Cookies.set_session_cookie(ctx, session_data)
341
373
  raise ctx.redirect(existing ? callback_url : (state_data["newUserURL"] || state_data["newUserCallbackURL"] || callback_url))
@@ -352,13 +384,35 @@ module BetterAuth
352
384
  )
353
385
  end
354
386
 
355
- def generic_oauth_authorization_url(ctx, provider, body, link:)
387
+ def generic_oauth_start_body_schema
388
+ OpenAPI.object_schema(
389
+ {
390
+ providerId: {type: "string", description: "OAuth provider ID"},
391
+ callbackURL: {type: "string", description: "URL to redirect to after success"},
392
+ errorCallbackURL: {type: "string", description: "URL to redirect to after error"},
393
+ newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"},
394
+ requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"},
395
+ scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"},
396
+ disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"},
397
+ additionalData: {type: "object", additionalProperties: true}
398
+ }
399
+ )
400
+ end
401
+
402
+ def generic_oauth_authorization_url(ctx, provider, body, link:, code_verifier: nil, preserve_additional_data: false)
356
403
  authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
357
404
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
358
405
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIGURATION"]) if authorization_url.to_s.empty? || token_url.to_s.empty?
359
406
 
360
- code_verifier = Crypto.random_string(43)
361
- state_data = normalize_hash(body[:additional_data] || body[:additionalData]).transform_keys(&:to_s).merge(
407
+ code_verifier ||= Crypto.random_string(43)
408
+ additional_data = if preserve_additional_data
409
+ raw = body[:additional_data] || body[:additionalData]
410
+ raw.is_a?(Hash) ? raw.each_with_object({}) { |(key, value), data| data[key.to_s] = value } : {}
411
+ else
412
+ normalize_hash(body[:additional_data] || body[:additionalData]).transform_keys(&:to_s)
413
+ end
414
+ additional_data = additional_data.reject { |key, _value| OAuthState::INTERNAL_KEYS.include?(key.to_s) }
415
+ state_data = additional_data.merge(
362
416
  "callbackURL" => body[:callback_url] || body[:callbackURL] || "/",
363
417
  "errorURL" => body[:error_callback_url] || body[:errorCallbackURL],
364
418
  "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL],
@@ -368,79 +422,59 @@ module BetterAuth
368
422
  "expiresAt" => Time.now.to_i + 600
369
423
  )
370
424
  state = generic_oauth_generate_state(ctx, state_data)
371
- legacy_state = Crypto.sign_jwt(
372
- {
373
- "callbackURL" => body[:callback_url] || body[:callbackURL] || "/",
374
- "errorURL" => body[:error_callback_url] || body[:errorCallbackURL],
375
- "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL],
376
- "requestSignUp" => body[:request_sign_up] || body[:requestSignUp],
377
- "codeVerifier" => code_verifier,
378
- "link" => link
379
- },
380
- ctx.context.secret,
381
- expires_in: 600
382
- )
383
- state ||= legacy_state
384
425
 
385
426
  uri = URI.parse(authorization_url.to_s)
386
427
  params = URI.decode_www_form(uri.query.to_s)
387
- params.concat([
388
- ["client_id", provider[:client_id].to_s],
389
- ["response_type", provider[:response_type] || "code"],
390
- ["redirect_uri", generic_oauth_redirect_uri(ctx, provider)],
391
- ["state", state]
392
- ])
428
+ generic_oauth_set_query_param(params, "client_id", provider[:client_id].to_s)
429
+ generic_oauth_set_query_param(params, "response_type", provider[:response_type] || "code")
430
+ generic_oauth_set_query_param(params, "redirect_uri", generic_oauth_redirect_uri(ctx, provider))
431
+ generic_oauth_set_query_param(params, "state", state)
393
432
  scopes = Array(body[:scopes]) + Array(provider[:scopes])
394
- params << ["scope", scopes.join(" ")] unless scopes.empty?
433
+ generic_oauth_set_query_param(params, "scope", scopes.join(" ")) unless scopes.empty?
395
434
  if provider[:pkce]
396
- params << ["code_challenge", generic_oauth_pkce_challenge(code_verifier)]
397
- params << ["code_challenge_method", "S256"]
435
+ generic_oauth_set_query_param(params, "code_challenge", generic_oauth_pkce_challenge(code_verifier))
436
+ generic_oauth_set_query_param(params, "code_challenge_method", "S256")
398
437
  end
399
- params << ["prompt", provider[:prompt]] if provider[:prompt]
400
- params << ["access_type", provider[:access_type]] if provider[:access_type]
401
- params << ["response_mode", provider[:response_mode]] if provider[:response_mode]
438
+ generic_oauth_set_query_param(params, "prompt", provider[:prompt]) if provider[:prompt]
439
+ generic_oauth_set_query_param(params, "access_type", provider[:access_type]) if provider[:access_type]
440
+ generic_oauth_set_query_param(params, "response_mode", provider[:response_mode]) if provider[:response_mode]
402
441
  authorization_params = if provider[:authorization_url_params].respond_to?(:call)
403
442
  provider[:authorization_url_params].call(ctx)
404
443
  else
405
444
  provider[:authorization_url_params]
406
445
  end
407
- normalize_hash(authorization_params || {}).each { |key, value| params << [key.to_s, value.to_s] }
446
+ normalize_hash(authorization_params || {}).each { |key, value| generic_oauth_set_query_param(params, key, value) }
408
447
  uri.query = URI.encode_www_form(params)
409
448
  uri.to_s
410
449
  end
411
450
 
412
- def generic_oauth_generate_state(ctx, state_data)
413
- strategy = ctx.context.options.account[:store_state_strategy]
414
- state = Crypto.random_string(32)
415
- if strategy.to_s == "cookie"
416
- cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
417
- encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: JSON.generate(state_data.merge("state" => state)))
418
- ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
419
- return state
451
+ def generic_oauth_set_query_param(params, key, value)
452
+ name = key.to_s
453
+ first_index = params.index { |param_name, _param_value| param_name == name }
454
+ if first_index
455
+ params[first_index] = [name, value.to_s]
456
+ (params.length - 1).downto(first_index + 1) do |index|
457
+ params.delete_at(index) if params[index].first == name
458
+ end
459
+ else
460
+ params << [name, value.to_s]
420
461
  end
462
+ end
421
463
 
422
- cookie = ctx.context.create_auth_cookie("state", max_age: 300)
423
- ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
424
- ctx.context.internal_adapter.create_verification_value(
425
- identifier: state,
426
- value: JSON.generate(state_data),
427
- expiresAt: Time.now + 600
428
- )
429
- state
430
- rescue
431
- nil
464
+ def generic_oauth_generate_state(ctx, state_data)
465
+ OAuthState.generate(ctx, state_data)
432
466
  end
433
467
 
434
468
  def generic_oauth_exchange_token(ctx, provider, code, state_data)
435
469
  token_callback = provider[:get_token]
436
470
  if token_callback.respond_to?(:call)
437
- return normalize_hash(token_callback.call(
471
+ return generic_oauth_normalize_tokens(token_callback.call(
438
472
  code: code,
439
473
  redirectURI: generic_oauth_redirect_uri(ctx, provider),
440
474
  redirect_uri: generic_oauth_redirect_uri(ctx, provider),
441
475
  codeVerifier: provider[:pkce] ? state_data["codeVerifier"] : nil,
442
476
  code_verifier: provider[:pkce] ? state_data["codeVerifier"] : nil
443
- ))
477
+ ), access_token_expires_in: provider[:access_token_expires_in])
444
478
  end
445
479
 
446
480
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
@@ -450,70 +484,37 @@ module BetterAuth
450
484
  end
451
485
 
452
486
  def generic_oauth_parse_state(ctx, state)
453
- if state.to_s.empty?
454
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
455
- end
456
-
457
- if ctx.context.options.account[:store_state_strategy].to_s == "cookie"
458
- cookie = ctx.context.create_auth_cookie("oauth_state")
459
- encrypted = ctx.get_cookie(cookie.name)
460
- unless encrypted
461
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
462
- end
463
-
464
- begin
465
- decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
466
- unless decrypted
467
- Cookies.expire_cookie(ctx, cookie)
468
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
469
- end
470
-
471
- parsed = JSON.parse(decrypted)
472
- rescue JSON::ParserError
473
- Cookies.expire_cookie(ctx, cookie)
474
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
475
- end
476
-
477
- Cookies.expire_cookie(ctx, cookie)
478
- if parsed["state"] != state
479
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
480
- end
481
- if parsed["expiresAt"].to_i.positive? && parsed["expiresAt"].to_i < Time.now.to_i
482
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
483
- end
484
- return parsed
485
- else
486
- verification = ctx.context.internal_adapter.find_verification_value(state)
487
- if verification
488
- cookie = ctx.context.create_auth_cookie("state")
489
- cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
490
- if cookie_state && cookie_state != state
491
- Cookies.expire_cookie(ctx, cookie)
492
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
493
- end
494
-
495
- parsed = JSON.parse(verification.fetch("value"))
496
- ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
497
- Cookies.expire_cookie(ctx, cookie) if cookie_state
498
- return parsed
499
- end
500
- end
501
-
502
- Crypto.verify_jwt(state.to_s, ctx.context.secret) || {}
503
- rescue JSON::ParserError
504
- {}
487
+ OAuthState.parse(ctx, state)
488
+ rescue OAuthState::Error => error
489
+ code = (error.code == "state_not_found") ? "please_restart_the_process" : error.code
490
+ error_url = error.error_url || generic_oauth_state_error_url(ctx)
491
+ raise ctx.redirect(generic_oauth_error_url(error_url, code))
505
492
  end
506
493
 
507
494
  def generic_oauth_state_error_url(ctx)
508
495
  ctx.context.options.on_api_error[:error_url] || "#{ctx.context.base_url}/error"
509
496
  end
510
497
 
498
+ def generic_oauth_recovered_state_error_url(ctx, state_data)
499
+ state_data["errorURL"] || state_data["errorCallbackURL"] || generic_oauth_state_error_url(ctx)
500
+ end
501
+
511
502
  def generic_oauth_user_info(provider, tokens)
512
503
  callback = provider[:get_user_info]
513
- return normalize_hash(callback.call(tokens)) if callback.respond_to?(:call)
504
+ if callback.respond_to?(:call)
505
+ user_info = callback.call(tokens)
506
+ return nil if user_info.nil?
507
+
508
+ return normalize_hash(user_info)
509
+ end
514
510
 
515
511
  id_token = tokens[:id_token] || tokens[:idToken]
516
- return generic_oauth_user_from_id_token(id_token) if id_token
512
+ if id_token
513
+ token_user = generic_oauth_user_from_id_token(id_token)
514
+ id = fetch_value(token_user, "id")
515
+ email = fetch_value(token_user, "email")
516
+ return token_user if !id.to_s.empty? && !email.to_s.empty?
517
+ end
517
518
 
518
519
  user_info_url = provider[:user_info_url] || generic_oauth_discovery(provider)["userinfo_endpoint"]
519
520
  return nil if user_info_url.to_s.empty?
@@ -521,7 +522,7 @@ module BetterAuth
521
522
  uri = URI(user_info_url)
522
523
  request = Net::HTTP::Get.new(uri)
523
524
  request["authorization"] = "Bearer #{fetch_value(tokens, "accessToken")}"
524
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
525
+ response = HTTPClient.request(uri, request)
525
526
  return nil unless response.is_a?(Net::HTTPSuccess)
526
527
 
527
528
  generic_oauth_normalize_user_info(JSON.parse(response.body))
@@ -593,7 +594,7 @@ module BetterAuth
593
594
  end
594
595
 
595
596
  def generic_oauth_redirect_uri(ctx, provider)
596
- provider[:redirect_uri] || provider[:redirectURI] || "#{ctx.context.base_url}/oauth2/callback/#{provider[:provider_id]}"
597
+ provider[:redirect_uri] || provider[:redirectURI] || "#{ctx.context.canonical_base_url}/oauth2/callback/#{provider[:provider_id]}"
597
598
  end
598
599
 
599
600
  def generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)
@@ -615,7 +616,7 @@ module BetterAuth
615
616
  normalize_hash(provider[:discovery_headers] || provider[:discoveryHeaders]).each do |key, value|
616
617
  request[key.to_s.tr("_", "-")] = value.to_s
617
618
  end
618
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
619
+ response = HTTPClient.request(uri, request)
619
620
  provider[:_discovery] = response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : {}
620
621
  rescue
621
622
  {}
@@ -649,16 +650,16 @@ module BetterAuth
649
650
  form_data[key] = value unless form_data.key?(key)
650
651
  end
651
652
  request.set_form_data(form_data)
652
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
653
+ response = HTTPClient.request(uri, request)
653
654
  return nil unless response.is_a?(Net::HTTPSuccess)
654
655
 
655
- generic_oauth_normalize_tokens(JSON.parse(response.body))
656
+ generic_oauth_normalize_tokens(JSON.parse(response.body), access_token_expires_in: provider[:access_token_expires_in])
656
657
  rescue
657
658
  nil
658
659
  end
659
660
 
660
661
  def generic_oauth_user_from_id_token(id_token)
661
- payload = JWT.decode(id_token, nil, false).first
662
+ payload = ::JWT.decode(id_token, nil, false).first
662
663
  normalize_hash(
663
664
  id: payload["sub"],
664
665
  email: payload["email"],
@@ -670,15 +671,17 @@ module BetterAuth
670
671
  nil
671
672
  end
672
673
 
673
- def generic_oauth_normalize_tokens(data)
674
+ def generic_oauth_normalize_tokens(data, access_token_expires_in: nil)
674
675
  token_data = normalize_hash(data)
676
+ access_token_expires_at = token_data[:access_token_expires_at] || generic_oauth_expiry_time(token_data[:expires_in])
677
+ access_token_expires_at ||= generic_oauth_expiry_time(access_token_expires_in)
675
678
  token_data.merge(
676
679
  access_token: token_data[:access_token],
677
680
  refresh_token: token_data[:refresh_token],
678
681
  id_token: token_data[:id_token],
679
- access_token_expires_at: generic_oauth_expiry_time(token_data[:expires_in]),
680
- refresh_token_expires_at: generic_oauth_expiry_time(token_data[:refresh_token_expires_in]),
681
- scopes: generic_oauth_token_scopes(token_data[:scope]),
682
+ access_token_expires_at: access_token_expires_at,
683
+ refresh_token_expires_at: token_data[:refresh_token_expires_at] || generic_oauth_expiry_time(token_data[:refresh_token_expires_in]),
684
+ scopes: generic_oauth_token_scopes(token_data[:scopes] || token_data[:scope]),
682
685
  raw: token_data
683
686
  ).compact
684
687
  end
@@ -714,7 +717,7 @@ module BetterAuth
714
717
  uri = URI(url)
715
718
  request = Net::HTTP::Get.new(uri)
716
719
  normalize_hash(headers).each { |key, value| request[key.to_s.tr("_", "-")] = value.to_s }
717
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
720
+ response = HTTPClient.request(uri, request)
718
721
  return nil unless response.is_a?(Net::HTTPSuccess)
719
722
 
720
723
  JSON.parse(response.body)
@@ -740,7 +743,8 @@ module BetterAuth
740
743
  pkce: data[:pkce],
741
744
  disable_implicit_sign_up: data[:disable_implicit_sign_up],
742
745
  disable_sign_up: data[:disable_sign_up],
743
- override_user_info: data[:override_user_info]
746
+ override_user_info: data[:override_user_info],
747
+ access_token_expires_in: data[:access_token_expires_in]
744
748
  )
745
749
  config[:scopes] = data[:scopes] if data[:scopes]
746
750
  config.compact
@@ -753,7 +757,20 @@ module BetterAuth
753
757
  id: provider_id,
754
758
  name: provider_id,
755
759
  get_user_info: ->(tokens) { generic_oauth_provider_user_info(provider, tokens) },
756
- refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) }
760
+ refresh_access_token: ->(refresh_token) { generic_oauth_refresh_access_token(context, provider, refresh_token) },
761
+ oauth_popup_authorization_url: lambda do |ctx, data|
762
+ additional_data = data[:additionalData] || data["additionalData"] || data[:additional_data] || data["additional_data"]
763
+ normalized = normalize_hash(data)
764
+ normalized[:additional_data] = additional_data if additional_data.is_a?(Hash)
765
+ generic_oauth_authorization_url(
766
+ ctx,
767
+ provider,
768
+ normalized,
769
+ link: nil,
770
+ code_verifier: normalized[:code_verifier],
771
+ preserve_additional_data: true
772
+ )
773
+ end
757
774
  }
758
775
  end
759
776
  end
@@ -790,16 +807,21 @@ module BetterAuth
790
807
  token_url_params = token_url_params.call(ctx) if token_url_params.respond_to?(:call)
791
808
  normalize_hash(token_url_params || {}).each { |key, value| form_data[key] = value }
792
809
  request.set_form_data(form_data.compact)
793
- response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
810
+ response = HTTPClient.request(uri, request)
794
811
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)
795
812
 
796
- generic_oauth_normalize_tokens(JSON.parse(response.body))
813
+ generic_oauth_normalize_tokens(JSON.parse(response.body), access_token_expires_in: provider[:access_token_expires_in])
797
814
  end
798
815
 
799
- def generic_oauth_error_url(base_url, error)
816
+ def generic_oauth_error_url(base_url, error, description = nil)
800
817
  uri = URI.parse(base_url.to_s)
801
818
  query = URI.decode_www_form(uri.query.to_s)
802
- query << ["error", error.to_s]
819
+ generic_oauth_set_query_param(query, "error", error)
820
+ if description.to_s.empty?
821
+ query.delete_if { |name, _value| name == "error_description" }
822
+ else
823
+ generic_oauth_set_query_param(query, "error_description", description)
824
+ end
803
825
  uri.query = URI.encode_www_form(query)
804
826
  uri.to_s
805
827
  end
@@ -83,7 +83,7 @@ module BetterAuth
83
83
  request = Net::HTTP::Get.new(uri)
84
84
  request["Add-Padding"] = "true"
85
85
  request["User-Agent"] = "BetterAuth Password Checker"
86
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |http| http.request(request) }
86
+ response = HTTPClient.request(uri, request)
87
87
  unless response.is_a?(Net::HTTPSuccess)
88
88
  raise APIError.new("INTERNAL_SERVER_ERROR", message: "Failed to check password. Status: #{response.code}")
89
89
  end