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
@@ -5,19 +5,24 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def paypal(client_id:, client_secret:, scopes: [], **options)
8
+ normalized = Base.normalize_options(options)
8
9
  sandbox = (options[:environment] || "sandbox").to_s == "sandbox"
9
10
  auth_host = sandbox ? "https://www.sandbox.paypal.com" : "https://www.paypal.com"
10
11
  api_host = sandbox ? "https://api-m.sandbox.paypal.com" : "https://api-m.paypal.com"
12
+ token_endpoint = normalized[:token_endpoint] || "#{api_host}/v1/oauth2/token"
13
+ issuer = sandbox ? "https://www.sandbox.paypal.com" : "https://www.paypal.com"
14
+ jwks_endpoint = normalized[:jwks_endpoint] || (sandbox ? "https://api.sandbox.paypal.com/v1/oauth2/certs" : "https://api.paypal.com/v1/oauth2/certs")
11
15
  provider = Base.oauth_provider(
12
16
  id: "paypal",
13
17
  name: "PayPal",
14
18
  client_id: client_id,
15
19
  client_secret: client_secret,
16
20
  authorization_endpoint: "#{auth_host}/signin/authorize",
17
- token_endpoint: "#{api_host}/v1/oauth2/token",
21
+ token_endpoint: token_endpoint,
18
22
  user_info_endpoint: "#{api_host}/v1/identity/oauth2/userinfo?schema=paypalv1.1",
19
23
  scopes: scopes,
20
24
  pkce: true,
25
+ token_authentication: :basic,
21
26
  profile_map: ->(profile) {
22
27
  {
23
28
  id: profile["user_id"],
@@ -29,7 +34,118 @@ module BetterAuth
29
34
  },
30
35
  **options
31
36
  )
32
- provider[:verify_id_token] = provider[:options][:verify_id_token] || ->(token, _nonce = nil) { provider[:options][:disable_id_token_sign_in] ? false : !!Base.decode_jwt_payload(token)["sub"] }
37
+ provider[:validate_authorization_code] = lambda do |data|
38
+ Base.normalize_tokens(Base.post_token_form(
39
+ token_endpoint,
40
+ {
41
+ code: data[:code] || data["code"],
42
+ grant_type: "authorization_code",
43
+ redirect_uri: data[:redirect_uri] || data[:redirectURI] || data["redirect_uri"] || data["redirectURI"]
44
+ },
45
+ client_id: Base.primary_client_id(client_id),
46
+ client_secret: client_secret,
47
+ authentication: :basic,
48
+ headers: {"Accept-Language" => "en_US"}
49
+ ))
50
+ end
51
+ provider[:refresh_access_token] = lambda do |refresh_token|
52
+ Base.normalize_tokens(Base.post_token_form(
53
+ token_endpoint,
54
+ {
55
+ grant_type: "refresh_token",
56
+ refresh_token: refresh_token
57
+ },
58
+ client_id: Base.primary_client_id(client_id),
59
+ client_secret: client_secret,
60
+ authentication: :basic,
61
+ headers: {"Accept-Language" => "en_US"}
62
+ ))
63
+ end
64
+ provider[:verify_id_token] = lambda do |token, nonce = nil|
65
+ return false if normalized[:disable_id_token_sign_in]
66
+
67
+ custom = normalized[:verify_id_token]
68
+ next custom.call(token, nonce) if custom
69
+
70
+ begin
71
+ encoded_header, _encoded_payload, signature = token.to_s.split(".", 3)
72
+ next false if encoded_header.to_s.empty? || signature.to_s.empty?
73
+
74
+ header = JSON.parse(Base64.urlsafe_decode64(Base.padded_base64(encoded_header)))
75
+ algorithm = header["alg"]
76
+ profile = case algorithm
77
+ when "RS256"
78
+ Base.verify_jwt_with_jwks(
79
+ token,
80
+ jwks: normalized[:jwks],
81
+ jwks_endpoint: jwks_endpoint,
82
+ algorithms: ["RS256"],
83
+ issuers: issuer,
84
+ audience: Array(client_id),
85
+ nonce: nonce,
86
+ max_age: 3600
87
+ )
88
+ when "HS256"
89
+ next false if client_secret.to_s.empty?
90
+
91
+ claims, = JWT.decode(token.to_s, client_secret, true, {
92
+ algorithm: "HS256",
93
+ aud: Array(client_id),
94
+ verify_aud: true,
95
+ iss: issuer,
96
+ verify_iss: true
97
+ })
98
+ next false if nonce && claims["nonce"] != nonce
99
+
100
+ claims
101
+ else
102
+ next false
103
+ end
104
+
105
+ issued_at = Integer(profile.fetch("iat"))
106
+ token_age = Time.now.to_i - issued_at
107
+ next false if token_age.negative? || token_age > 3600
108
+
109
+ !!profile.fetch("sub", nil)
110
+ rescue
111
+ false
112
+ end
113
+ end
114
+ provider[:get_user_info] = lambda do |tokens|
115
+ custom = normalized[:get_user_info]
116
+ next custom.call(tokens) if custom
117
+
118
+ begin
119
+ access_token = Base.access_token(tokens)
120
+ next nil if access_token.to_s.empty?
121
+
122
+ profile = Base.fetch_user_info("#{api_host}/v1/identity/oauth2/userinfo?schema=paypalv1.1", tokens)
123
+ next nil unless profile.is_a?(Hash)
124
+ next nil if profile["user_id"].to_s.empty?
125
+
126
+ id_token = Base.id_token(tokens)
127
+ if id_token
128
+ id_token_subject = Base.decode_jwt_payload(id_token)["sub"]
129
+ profile_subject = profile["sub"] || profile["user_id"]
130
+ next nil if id_token_subject.to_s.empty? || profile_subject.to_s.empty? || id_token_subject != profile_subject
131
+ end
132
+
133
+ user = Base.apply_profile_mapping(
134
+ {
135
+ id: profile["user_id"],
136
+ name: profile["name"],
137
+ email: profile["email"],
138
+ image: profile["picture"],
139
+ emailVerified: !!profile["email_verified"]
140
+ },
141
+ profile,
142
+ normalized
143
+ )
144
+ {user: user, data: profile}
145
+ rescue
146
+ nil
147
+ end
148
+ end
33
149
  provider
34
150
  end
35
151
  end
@@ -5,28 +5,49 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def reddit(client_id:, client_secret:, scopes: ["identity"], **options)
8
- Base.oauth_provider(
8
+ normalized = Base.normalize_options(options)
9
+ token_endpoint = normalized[:token_endpoint] || "https://www.reddit.com/api/v1/access_token"
10
+ provider = Base.oauth_provider(
9
11
  id: "reddit",
10
12
  name: "Reddit",
11
13
  client_id: client_id,
12
14
  client_secret: client_secret,
13
15
  authorization_endpoint: "https://www.reddit.com/api/v1/authorize",
14
- token_endpoint: "https://www.reddit.com/api/v1/access_token",
16
+ token_endpoint: token_endpoint,
15
17
  user_info_endpoint: "https://oauth.reddit.com/api/v1/me",
16
18
  user_info_headers: {"User-Agent" => "better-auth"},
17
19
  scopes: scopes,
18
20
  auth_params: ->(_data, opts) { {duration: opts[:duration]} },
21
+ token_authentication: :basic,
19
22
  profile_map: ->(profile) {
20
23
  {
21
24
  id: profile["id"],
22
25
  name: profile["name"],
23
- email: profile["oauth_client_id"],
26
+ email: "#{profile["id"]}@reddit.invalid",
24
27
  image: profile["icon_img"].to_s.split("?").first,
25
- emailVerified: !!profile["has_verified_email"]
28
+ emailVerified: false
26
29
  }
27
30
  },
28
31
  **options
29
32
  )
33
+ provider[:validate_authorization_code] = lambda do |data|
34
+ Base.post_token_form(
35
+ token_endpoint,
36
+ {
37
+ code: data[:code] || data["code"],
38
+ grant_type: "authorization_code",
39
+ redirect_uri: data[:redirect_uri] || data[:redirectURI] || data["redirect_uri"] || data["redirectURI"]
40
+ },
41
+ client_id: Base.primary_client_id(client_id),
42
+ client_secret: client_secret,
43
+ authentication: :basic,
44
+ headers: {
45
+ "accept" => "text/plain",
46
+ "user-agent" => "better-auth"
47
+ }
48
+ )
49
+ end
50
+ provider
30
51
  end
31
52
  end
32
53
  end
@@ -22,6 +22,7 @@ module BetterAuth
22
22
  user_info_endpoint: "#{host}/services/oauth2/userinfo",
23
23
  scopes: scopes,
24
24
  pkce: true,
25
+ require_code_verifier: true,
25
26
  profile_map: ->(profile) {
26
27
  {
27
28
  id: profile["user_id"],
@@ -5,7 +5,8 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def twitch(client_id:, client_secret:, scopes: ["user:read:email", "openid"], **options)
8
- Base.oauth_provider(
8
+ normalized = Base.normalize_options(options)
9
+ provider = Base.oauth_provider(
9
10
  id: "twitch",
10
11
  name: "Twitch",
11
12
  client_id: client_id,
@@ -34,6 +35,28 @@ module BetterAuth
34
35
  },
35
36
  **options
36
37
  )
38
+ provider[:get_user_info] = lambda do |tokens|
39
+ custom = normalized[:get_user_info]
40
+ next custom.call(tokens) if custom
41
+ next nil unless Base.id_token(tokens)
42
+
43
+ profile = Base.decode_jwt_payload(Base.id_token(tokens))
44
+ next nil if profile.empty?
45
+
46
+ user = Base.apply_profile_mapping(
47
+ {
48
+ id: profile["sub"],
49
+ name: profile["preferred_username"],
50
+ email: profile["email"],
51
+ image: profile["picture"],
52
+ emailVerified: !!profile["email_verified"]
53
+ },
54
+ profile,
55
+ normalized
56
+ )
57
+ {user: user, data: profile}
58
+ end
59
+ provider
37
60
  end
38
61
  end
39
62
  end
@@ -5,7 +5,8 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def twitter(client_id:, client_secret:, scopes: ["users.read", "tweet.read", "offline.access", "users.email"], **options)
8
- Base.oauth_provider(
8
+ normalized = Base.normalize_options(options)
9
+ provider = Base.oauth_provider(
9
10
  id: "twitter",
10
11
  name: "Twitter",
11
12
  client_id: client_id,
@@ -15,6 +16,7 @@ module BetterAuth
15
16
  user_info_endpoint: "https://api.x.com/2/users/me?user.fields=profile_image_url,verified",
16
17
  scopes: scopes,
17
18
  pkce: true,
19
+ token_authentication: :basic,
18
20
  profile_map: ->(profile) {
19
21
  data = profile["data"] || profile
20
22
  {
@@ -27,6 +29,39 @@ module BetterAuth
27
29
  },
28
30
  **options
29
31
  )
32
+ provider[:get_user_info] = lambda do |tokens|
33
+ custom = normalized[:get_user_info]
34
+ next custom.call(tokens) if custom
35
+
36
+ profile = Base.get_json(
37
+ "https://api.x.com/2/users/me?user.fields=profile_image_url",
38
+ "Authorization" => "Bearer #{Base.access_token(tokens)}"
39
+ )
40
+ next nil unless profile
41
+
42
+ email_data = Base.get_json(
43
+ "https://api.x.com/2/users/me?user.fields=confirmed_email",
44
+ "Authorization" => "Bearer #{Base.access_token(tokens)}"
45
+ )
46
+ data = profile["data"] || profile
47
+ confirmed_email = email_data&.dig("data", "confirmed_email")
48
+ data["email"] = confirmed_email if confirmed_email
49
+ data["confirmed_email"] = true if confirmed_email
50
+
51
+ user = Base.apply_profile_mapping(
52
+ {
53
+ id: data["id"],
54
+ name: data["name"],
55
+ email: data["email"] || data["username"],
56
+ image: data["profile_image_url"],
57
+ emailVerified: !!data["confirmed_email"]
58
+ },
59
+ profile,
60
+ normalized
61
+ )
62
+ {user: user, data: profile}
63
+ end
64
+ provider
30
65
  end
31
66
  end
32
67
  end
@@ -5,7 +5,8 @@ module BetterAuth
5
5
  module_function
6
6
 
7
7
  def vk(client_id:, client_secret:, scopes: ["email", "phone"], **options)
8
- Base.oauth_provider(
8
+ normalized = Base.normalize_options(options)
9
+ provider = Base.oauth_provider(
9
10
  id: "vk",
10
11
  name: "VK",
11
12
  client_id: client_id,
@@ -29,6 +30,38 @@ module BetterAuth
29
30
  },
30
31
  **options
31
32
  )
33
+ provider[:get_user_info] = lambda do |tokens|
34
+ custom = normalized[:get_user_info]
35
+ next custom.call(tokens) if custom
36
+
37
+ access_token = Base.access_token(tokens)
38
+ next nil unless access_token
39
+
40
+ profile = Base.post_json(
41
+ "https://id.vk.com/oauth2/user_info",
42
+ {access_token: access_token, client_id: client_id},
43
+ "Content-Type" => "application/x-www-form-urlencoded"
44
+ )
45
+ next nil unless profile
46
+
47
+ user_profile = profile["user"] || profile
48
+ user_map = normalized[:map_profile_to_user]&.call(profile) || {}
49
+ next nil if user_profile["email"].to_s.empty? && user_map[:email].to_s.empty? && user_map["email"].to_s.empty?
50
+
51
+ user = {
52
+ id: user_profile["user_id"],
53
+ first_name: user_profile["first_name"],
54
+ last_name: user_profile["last_name"],
55
+ name: [user_profile["first_name"], user_profile["last_name"]].compact.join(" "),
56
+ email: user_profile["email"],
57
+ image: user_profile["avatar"],
58
+ emailVerified: false,
59
+ birthday: user_profile["birthday"],
60
+ sex: user_profile["sex"]
61
+ }.merge(user_map)
62
+ {user: user, data: profile}
63
+ end
64
+ provider
32
65
  end
33
66
  end
34
67
  end
@@ -17,10 +17,11 @@ module BetterAuth
17
17
  scopes: scopes,
18
18
  scope_separator: ",",
19
19
  profile_map: ->(profile) {
20
+ id = profile["unionid"] || profile["openid"]
20
21
  {
21
- id: profile["unionid"] || profile["openid"],
22
+ id: id,
22
23
  name: profile["nickname"],
23
- email: profile["email"],
24
+ email: profile["email"] || "#{id}@wechat.invalid",
24
25
  image: profile["headimgurl"],
25
26
  emailVerified: false
26
27
  }
@@ -85,11 +86,12 @@ module BetterAuth
85
86
  profile = Base.get_json(url)
86
87
  next nil if !profile || profile["errcode"]
87
88
 
89
+ id = profile["unionid"] || profile["openid"] || openid
88
90
  user = Base.apply_profile_mapping(
89
91
  {
90
- id: profile["unionid"] || profile["openid"] || openid,
92
+ id: id,
91
93
  name: profile["nickname"],
92
- email: profile["email"],
94
+ email: profile["email"] || "#{id}@wechat.invalid",
93
95
  image: profile["headimgurl"],
94
96
  emailVerified: false
95
97
  },