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
@@ -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",
@@ -235,7 +265,7 @@ module BetterAuth
235
265
  end
236
266
  end
237
267
 
238
- def o_auth2_link_account_endpoint(config)
268
+ def oauth2_link_account_endpoint(config)
239
269
  Endpoint.new(
240
270
  path: "/oauth2/link",
241
271
  method: "POST",
@@ -266,7 +296,7 @@ module BetterAuth
266
296
  end
267
297
  end
268
298
 
269
- def o_auth2_callback_endpoint(config)
299
+ def oauth2_callback_endpoint(config)
270
300
  Endpoint.new(
271
301
  path: "/oauth2/callback/:providerId",
272
302
  method: "GET",
@@ -291,9 +321,9 @@ module BetterAuth
291
321
  provider = generic_oauth_provider!(config, provider_id)
292
322
  state_data = generic_oauth_parse_state(ctx, query[:state].to_s)
293
323
  error_url = state_data["errorURL"] || state_data["errorCallbackURL"] || "#{ctx.context.base_url}/error"
294
- 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)) }
295
325
 
296
- 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?
297
327
  generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)
298
328
 
299
329
  tokens = begin
@@ -311,6 +341,7 @@ module BetterAuth
311
341
  account_id = fetch_value(mapped_user, "id").to_s
312
342
  redirect_error.call("email_is_missing") if email.empty?
313
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)
314
345
 
315
346
  link = state_data["link"]
316
347
  callback_url = state_data["callbackURL"] || "/"
@@ -323,21 +354,20 @@ module BetterAuth
323
354
  if !existing && (provider[:disable_sign_up] || (provider[:disable_implicit_sign_up] && !state_data["requestSignUp"]))
324
355
  redirect_error.call("signup_disabled")
325
356
  end
326
- if existing && provider[:override_user_info]
327
- ctx.context.internal_adapter.update_user(
328
- existing[:user]["id"],
329
- "name" => name,
330
- "image" => fetch_value(mapped_user, "image"),
331
- "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]
332
364
  )
333
- end
365
+ rescue APIError => error
366
+ raise if error.code.to_s.empty?
334
367
 
335
- session_data = Routes.persist_social_user(
336
- ctx,
337
- provider_id,
338
- mapped_user.merge("email" => email, "name" => name, "id" => account_id),
339
- generic_oauth_account_info(ctx, provider_id, account_id, tokens)
340
- )
368
+ redirect_error.call(error.code, error.message)
369
+ end
370
+ redirect_error.call(session_data[:error].tr(" ", "_")) if session_data[:error]
341
371
  generic_oauth_set_account_cookie(ctx, provider_id, account_id, session_data[:user]["id"])
342
372
  Cookies.set_session_cookie(ctx, session_data)
343
373
  raise ctx.redirect(existing ? callback_url : (state_data["newUserURL"] || state_data["newUserCallbackURL"] || callback_url))
@@ -358,31 +388,31 @@ module BetterAuth
358
388
  OpenAPI.object_schema(
359
389
  {
360
390
  providerId: {type: "string", description: "OAuth provider ID"},
361
- provider_id: {type: "string", description: "OAuth provider ID"},
362
391
  callbackURL: {type: "string", description: "URL to redirect to after success"},
363
- callback_url: {type: "string", description: "URL to redirect to after success"},
364
392
  errorCallbackURL: {type: "string", description: "URL to redirect to after error"},
365
- error_callback_url: {type: "string", description: "URL to redirect to after error"},
366
393
  newUserCallbackURL: {type: "string", description: "URL to redirect to for new users"},
367
- new_user_callback_url: {type: "string", description: "URL to redirect to for new users"},
368
394
  requestSignUp: {type: "boolean", description: "Whether this request is a sign-up flow"},
369
- request_sign_up: {type: "boolean", description: "Whether this request is a sign-up flow"},
370
395
  scopes: {type: "array", items: {type: "string"}, description: "Additional OAuth scopes"},
371
396
  disableRedirect: {type: "boolean", description: "Return the URL instead of redirecting"},
372
- disable_redirect: {type: "boolean", description: "Return the URL instead of redirecting"},
373
- additionalData: {type: "object", additionalProperties: true},
374
- additional_data: {type: "object", additionalProperties: true}
397
+ additionalData: {type: "object", additionalProperties: true}
375
398
  }
376
399
  )
377
400
  end
378
401
 
379
- def generic_oauth_authorization_url(ctx, provider, body, link:)
402
+ def generic_oauth_authorization_url(ctx, provider, body, link:, code_verifier: nil, preserve_additional_data: false)
380
403
  authorization_url = provider[:authorization_url] || generic_oauth_discovery(provider)["authorization_endpoint"]
381
404
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
382
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?
383
406
 
384
- code_verifier = Crypto.random_string(43)
385
- 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(
386
416
  "callbackURL" => body[:callback_url] || body[:callbackURL] || "/",
387
417
  "errorURL" => body[:error_callback_url] || body[:errorCallbackURL],
388
418
  "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL],
@@ -392,79 +422,59 @@ module BetterAuth
392
422
  "expiresAt" => Time.now.to_i + 600
393
423
  )
394
424
  state = generic_oauth_generate_state(ctx, state_data)
395
- legacy_state = Crypto.sign_jwt(
396
- {
397
- "callbackURL" => body[:callback_url] || body[:callbackURL] || "/",
398
- "errorURL" => body[:error_callback_url] || body[:errorCallbackURL],
399
- "newUserURL" => body[:new_user_callback_url] || body[:newUserCallbackURL],
400
- "requestSignUp" => body[:request_sign_up] || body[:requestSignUp],
401
- "codeVerifier" => code_verifier,
402
- "link" => link
403
- },
404
- ctx.context.secret,
405
- expires_in: 600
406
- )
407
- state ||= legacy_state
408
425
 
409
426
  uri = URI.parse(authorization_url.to_s)
410
427
  params = URI.decode_www_form(uri.query.to_s)
411
- params.concat([
412
- ["client_id", provider[:client_id].to_s],
413
- ["response_type", provider[:response_type] || "code"],
414
- ["redirect_uri", generic_oauth_redirect_uri(ctx, provider)],
415
- ["state", state]
416
- ])
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)
417
432
  scopes = Array(body[:scopes]) + Array(provider[:scopes])
418
- params << ["scope", scopes.join(" ")] unless scopes.empty?
433
+ generic_oauth_set_query_param(params, "scope", scopes.join(" ")) unless scopes.empty?
419
434
  if provider[:pkce]
420
- params << ["code_challenge", generic_oauth_pkce_challenge(code_verifier)]
421
- 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")
422
437
  end
423
- params << ["prompt", provider[:prompt]] if provider[:prompt]
424
- params << ["access_type", provider[:access_type]] if provider[:access_type]
425
- 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]
426
441
  authorization_params = if provider[:authorization_url_params].respond_to?(:call)
427
442
  provider[:authorization_url_params].call(ctx)
428
443
  else
429
444
  provider[:authorization_url_params]
430
445
  end
431
- 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) }
432
447
  uri.query = URI.encode_www_form(params)
433
448
  uri.to_s
434
449
  end
435
450
 
436
- def generic_oauth_generate_state(ctx, state_data)
437
- strategy = ctx.context.options.account[:store_state_strategy]
438
- state = Crypto.random_string(32)
439
- if strategy.to_s == "cookie"
440
- cookie = ctx.context.create_auth_cookie("oauth_state", max_age: 600)
441
- encrypted = Crypto.symmetric_encrypt(key: ctx.context.secret_config, data: JSON.generate(state_data.merge("state" => state)))
442
- ctx.set_cookie(cookie.name, encrypted, cookie.attributes)
443
- 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]
444
461
  end
462
+ end
445
463
 
446
- cookie = ctx.context.create_auth_cookie("state", max_age: 300)
447
- ctx.set_signed_cookie(cookie.name, state, ctx.context.secret, cookie.attributes)
448
- ctx.context.internal_adapter.create_verification_value(
449
- identifier: state,
450
- value: JSON.generate(state_data),
451
- expiresAt: Time.now + 600
452
- )
453
- state
454
- rescue
455
- nil
464
+ def generic_oauth_generate_state(ctx, state_data)
465
+ OAuthState.generate(ctx, state_data)
456
466
  end
457
467
 
458
468
  def generic_oauth_exchange_token(ctx, provider, code, state_data)
459
469
  token_callback = provider[:get_token]
460
470
  if token_callback.respond_to?(:call)
461
- return normalize_hash(token_callback.call(
471
+ return generic_oauth_normalize_tokens(token_callback.call(
462
472
  code: code,
463
473
  redirectURI: generic_oauth_redirect_uri(ctx, provider),
464
474
  redirect_uri: generic_oauth_redirect_uri(ctx, provider),
465
475
  codeVerifier: provider[:pkce] ? state_data["codeVerifier"] : nil,
466
476
  code_verifier: provider[:pkce] ? state_data["codeVerifier"] : nil
467
- ))
477
+ ), access_token_expires_in: provider[:access_token_expires_in])
468
478
  end
469
479
 
470
480
  token_url = provider[:token_url] || generic_oauth_discovery(provider)["token_endpoint"]
@@ -474,73 +484,37 @@ module BetterAuth
474
484
  end
475
485
 
476
486
  def generic_oauth_parse_state(ctx, state)
477
- if state.to_s.empty?
478
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
479
- end
480
-
481
- if ctx.context.options.account[:store_state_strategy].to_s == "cookie"
482
- cookie = ctx.context.create_auth_cookie("oauth_state")
483
- encrypted = ctx.get_cookie(cookie.name)
484
- unless encrypted
485
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
486
- end
487
-
488
- begin
489
- decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: encrypted)
490
- unless decrypted
491
- Cookies.expire_cookie(ctx, cookie)
492
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
493
- end
494
-
495
- parsed = JSON.parse(decrypted)
496
- rescue JSON::ParserError
497
- Cookies.expire_cookie(ctx, cookie)
498
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "please_restart_the_process"))
499
- end
500
-
501
- Cookies.expire_cookie(ctx, cookie)
502
- if parsed["state"] != state
503
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
504
- end
505
- if parsed["expiresAt"].to_i.positive? && parsed["expiresAt"].to_i < Time.now.to_i
506
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
507
- end
508
- return parsed
509
- else
510
- verification = ctx.context.internal_adapter.find_verification_value(state)
511
- if verification
512
- cookie = ctx.context.create_auth_cookie("state")
513
- cookie_state = ctx.get_signed_cookie(cookie.name, ctx.context.secret)
514
- if ctx.request && cookie_state != state
515
- Cookies.expire_cookie(ctx, cookie)
516
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
517
- elsif !ctx.request && cookie_state && cookie_state != state
518
- Cookies.expire_cookie(ctx, cookie)
519
- raise ctx.redirect(generic_oauth_error_url(generic_oauth_state_error_url(ctx), "state_mismatch"))
520
- end
521
-
522
- parsed = JSON.parse(verification.fetch("value"))
523
- ctx.context.internal_adapter.delete_verification_value(verification.fetch("id"))
524
- Cookies.expire_cookie(ctx, cookie) if ctx.request || cookie_state
525
- return parsed
526
- end
527
- end
528
-
529
- Crypto.verify_jwt(state.to_s, ctx.context.secret) || {}
530
- rescue JSON::ParserError
531
- {}
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))
532
492
  end
533
493
 
534
494
  def generic_oauth_state_error_url(ctx)
535
495
  ctx.context.options.on_api_error[:error_url] || "#{ctx.context.base_url}/error"
536
496
  end
537
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
+
538
502
  def generic_oauth_user_info(provider, tokens)
539
503
  callback = provider[:get_user_info]
540
- 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
541
510
 
542
511
  id_token = tokens[:id_token] || tokens[:idToken]
543
- 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
544
518
 
545
519
  user_info_url = provider[:user_info_url] || generic_oauth_discovery(provider)["userinfo_endpoint"]
546
520
  return nil if user_info_url.to_s.empty?
@@ -620,7 +594,7 @@ module BetterAuth
620
594
  end
621
595
 
622
596
  def generic_oauth_redirect_uri(ctx, provider)
623
- 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]}"
624
598
  end
625
599
 
626
600
  def generic_oauth_validate_issuer!(ctx, provider, query, redirect_error)
@@ -679,13 +653,13 @@ module BetterAuth
679
653
  response = HTTPClient.request(uri, request)
680
654
  return nil unless response.is_a?(Net::HTTPSuccess)
681
655
 
682
- 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])
683
657
  rescue
684
658
  nil
685
659
  end
686
660
 
687
661
  def generic_oauth_user_from_id_token(id_token)
688
- payload = JWT.decode(id_token, nil, false).first
662
+ payload = ::JWT.decode(id_token, nil, false).first
689
663
  normalize_hash(
690
664
  id: payload["sub"],
691
665
  email: payload["email"],
@@ -697,15 +671,17 @@ module BetterAuth
697
671
  nil
698
672
  end
699
673
 
700
- def generic_oauth_normalize_tokens(data)
674
+ def generic_oauth_normalize_tokens(data, access_token_expires_in: nil)
701
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)
702
678
  token_data.merge(
703
679
  access_token: token_data[:access_token],
704
680
  refresh_token: token_data[:refresh_token],
705
681
  id_token: token_data[:id_token],
706
- access_token_expires_at: generic_oauth_expiry_time(token_data[:expires_in]),
707
- refresh_token_expires_at: generic_oauth_expiry_time(token_data[:refresh_token_expires_in]),
708
- 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]),
709
685
  raw: token_data
710
686
  ).compact
711
687
  end
@@ -767,7 +743,8 @@ module BetterAuth
767
743
  pkce: data[:pkce],
768
744
  disable_implicit_sign_up: data[:disable_implicit_sign_up],
769
745
  disable_sign_up: data[:disable_sign_up],
770
- 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]
771
748
  )
772
749
  config[:scopes] = data[:scopes] if data[:scopes]
773
750
  config.compact
@@ -780,7 +757,20 @@ module BetterAuth
780
757
  id: provider_id,
781
758
  name: provider_id,
782
759
  get_user_info: ->(tokens) { generic_oauth_provider_user_info(provider, tokens) },
783
- 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
784
774
  }
785
775
  end
786
776
  end
@@ -820,13 +810,18 @@ module BetterAuth
820
810
  response = HTTPClient.request(uri, request)
821
811
  raise APIError.new("BAD_REQUEST", message: GENERIC_OAUTH_ERROR_CODES["INVALID_OAUTH_CONFIG"]) unless response.is_a?(Net::HTTPSuccess)
822
812
 
823
- 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])
824
814
  end
825
815
 
826
- def generic_oauth_error_url(base_url, error)
816
+ def generic_oauth_error_url(base_url, error, description = nil)
827
817
  uri = URI.parse(base_url.to_s)
828
818
  query = URI.decode_www_form(uri.query.to_s)
829
- 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
830
825
  uri.query = URI.encode_www_form(query)
831
826
  uri.to_s
832
827
  end