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,330 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ module Plugins
8
+ OAUTH_POPUP_MESSAGE_TYPE = "better-auth:oauth-popup"
9
+ OAUTH_POPUP_DATA_ELEMENT_ID = "better-auth-oauth-popup"
10
+ OAUTH_POPUP_MARKER_COOKIE = "oauth_popup"
11
+ OAUTH_POPUP_ERROR_CODES = {
12
+ "POPUP_SIGN_IN_FAILED" => "Popup sign-in failed",
13
+ "POPUP_BLOCKED" => "Sign-in popup was blocked by the browser",
14
+ "POPUP_CLOSED" => "Sign-in popup was closed before completing",
15
+ "POPUP_TIMEOUT" => "Sign-in popup timed out"
16
+ }.freeze
17
+ OAUTH_POPUP_INTERNAL_STATE_KEYS = OAuthState::INTERNAL_KEYS
18
+ OAUTH_POPUP_COMPLETE_SCRIPT = [
19
+ "(function () {",
20
+ "\tvar el = document.getElementById(\"better-auth-oauth-popup\");",
21
+ "\tif (!el) return;",
22
+ "\tvar payload;",
23
+ "\ttry {",
24
+ "\t\tpayload = JSON.parse(el.textContent || \"\");",
25
+ "\t} catch (e) {",
26
+ "\t\treturn;",
27
+ "\t}",
28
+ "\tvar target = window.opener || window.parent;",
29
+ "\tif (target && target !== window) {",
30
+ "\t\ttry {",
31
+ "\t\t\ttarget.postMessage(",
32
+ "\t\t\t\t{",
33
+ "\t\t\t\t\ttype: payload.type,",
34
+ "\t\t\t\t\tnonce: payload.nonce,",
35
+ "\t\t\t\t\ttoken: payload.token,",
36
+ "\t\t\t\t\tredirectTo: payload.redirectTo,",
37
+ "\t\t\t\t\terror: payload.error,",
38
+ "\t\t\t\t},",
39
+ "\t\t\t\tpayload.targetOrigin,",
40
+ "\t\t\t);",
41
+ "\t\t} catch (e) {}",
42
+ "\t}",
43
+ "\twindow.close();",
44
+ "})();",
45
+ ""
46
+ ].join("\n")
47
+ OAUTH_POPUP_SCRIPT_CSP_HASH = "sha256-tIo2K8VBC9SnhvdZ+9GsGkQoZm+jm/JcxL+d+i8b8KQ="
48
+
49
+ module_function
50
+
51
+ def oauth_popup
52
+ Plugin.new(
53
+ id: "oauth-popup",
54
+ endpoints: {
55
+ oauth_popup_start: oauth_popup_start_endpoint
56
+ },
57
+ hooks: {
58
+ after: [
59
+ {
60
+ matcher: ->(ctx) { oauth_popup_callback_path?(ctx.path) },
61
+ handler: ->(ctx) { oauth_popup_after_callback(ctx) }
62
+ }
63
+ ]
64
+ },
65
+ error_codes: OAUTH_POPUP_ERROR_CODES
66
+ )
67
+ end
68
+
69
+ def oauth_popup_start_endpoint
70
+ Endpoint.new(
71
+ path: "/oauth-popup/start",
72
+ method: "GET",
73
+ query_schema: Routes.request_query_schema(
74
+ required_strings: %w[provider popupOrigin],
75
+ optional_strings: %w[popupNonce callbackURL errorCallbackURL newUserCallbackURL scopes requestSignUp additionalData]
76
+ ),
77
+ metadata: {
78
+ hide: true,
79
+ openapi: {
80
+ parameters: [
81
+ {name: "provider", in: "query", required: true, schema: {type: "string"}},
82
+ {name: "popupOrigin", in: "query", required: true, schema: {type: "string"}},
83
+ {name: "popupNonce", in: "query", required: false, schema: {type: "string"}},
84
+ {name: "callbackURL", in: "query", required: false, schema: {type: "string"}},
85
+ {name: "errorCallbackURL", in: "query", required: false, schema: {type: "string"}},
86
+ {name: "newUserCallbackURL", in: "query", required: false, schema: {type: "string"}},
87
+ {name: "scopes", in: "query", required: false, schema: {type: "string"}},
88
+ {name: "requestSignUp", in: "query", required: false, schema: {type: "string"}},
89
+ {name: "additionalData", in: "query", required: false, schema: {type: "string"}}
90
+ ]
91
+ }
92
+ }
93
+ ) do |ctx|
94
+ query = normalize_hash(ctx.query)
95
+ popup_origin = oauth_popup_validate_origin!(ctx, query[:popup_origin].to_s)
96
+
97
+ nonce = query[:popup_nonce].to_s
98
+ invalid_redirect = oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce)
99
+ next invalid_redirect if invalid_redirect
100
+
101
+ provider_id = query[:provider].to_s
102
+ provider = Routes.social_provider(ctx.context, provider_id)
103
+ unless provider
104
+ next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
105
+ code: "provider_not_found",
106
+ description: "Unknown provider: #{provider_id}"
107
+ })
108
+ end
109
+
110
+ code_verifier = Crypto.random_string(128)
111
+ scopes = query[:scopes]&.to_s&.split(",")
112
+ additional_data = oauth_popup_additional_data(query[:additional_data])
113
+ callback_url = query[:callback_url].to_s
114
+ callback_url = ctx.context.base_url if callback_url.empty?
115
+ request_sign_up = (query[:request_sign_up].to_s == "true") ? true : nil
116
+
117
+ begin
118
+ authorization_url = if (start = fetch_value(provider, "oauthPopupAuthorizationUrl")).respond_to?(:call)
119
+ start.call(
120
+ ctx,
121
+ callbackURL: callback_url,
122
+ errorCallbackURL: query[:error_callback_url],
123
+ newUserCallbackURL: query[:new_user_callback_url],
124
+ requestSignUp: request_sign_up,
125
+ scopes: scopes,
126
+ additionalData: additional_data,
127
+ codeVerifier: code_verifier
128
+ )
129
+ else
130
+ state_data = additional_data.merge(
131
+ "callbackURL" => callback_url,
132
+ "errorURL" => query[:error_callback_url],
133
+ "newUserURL" => query[:new_user_callback_url],
134
+ "requestSignUp" => request_sign_up,
135
+ "codeVerifier" => code_verifier,
136
+ "expiresAt" => Time.now.to_i + 600
137
+ ).compact
138
+ state = OAuthState.generate(ctx, state_data)
139
+ Routes.call_provider(provider, :create_authorization_url, {
140
+ state: state,
141
+ codeVerifier: code_verifier,
142
+ code_verifier: code_verifier,
143
+ redirectURI: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
144
+ redirect_uri: "#{ctx.context.canonical_base_url}/callback/#{provider_id}",
145
+ scopes: scopes
146
+ })
147
+ end
148
+
149
+ oauth_popup_set_marker(ctx, popup_origin, nonce)
150
+ rescue => error
151
+ oauth_popup_log(ctx.context, :error, "OAuth popup failed to start", error)
152
+ next oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
153
+ code: "popup_sign_in_failed",
154
+ description: "Failed to start the OAuth flow."
155
+ })
156
+ end
157
+
158
+ raise ctx.redirect(authorization_url.to_s)
159
+ end
160
+ end
161
+
162
+ def oauth_popup_validate_origin!(ctx, popup_origin)
163
+ uri = URI.parse(popup_origin)
164
+ valid_scheme = %w[http https].include?(uri.scheme)
165
+ valid_path = uri.path.to_s.empty? || uri.path == "/"
166
+ absolute = valid_scheme && !uri.host.to_s.empty?
167
+ canonical = Configuration.origin_for(uri) if absolute
168
+ valid = absolute &&
169
+ uri.userinfo.nil? &&
170
+ valid_path &&
171
+ uri.query.nil? &&
172
+ uri.fragment.nil? &&
173
+ !popup_origin.match?(/[?*]/) &&
174
+ ctx.context.trusted_origin?(canonical, allow_relative_paths: false)
175
+ return canonical if valid
176
+
177
+ oauth_popup_log(ctx.context, :error, "OAuth popup origin is not trusted")
178
+ raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"])
179
+ rescue URI::InvalidURIError
180
+ raise APIError.new("FORBIDDEN", code: "INVALID_ORIGIN", message: BASE_ERROR_CODES["INVALID_ORIGIN"])
181
+ end
182
+
183
+ def oauth_popup_invalid_redirect(ctx, query, popup_origin, nonce)
184
+ {
185
+ callback_url: "invalid_callback_url",
186
+ error_callback_url: "invalid_error_callback_url",
187
+ new_user_callback_url: "invalid_new_user_callback_url"
188
+ }.each do |key, code|
189
+ value = query[key]
190
+ next if value.to_s.empty?
191
+ next if ctx.context.trusted_origin?(value.to_s, allow_relative_paths: true)
192
+
193
+ oauth_popup_log(ctx.context, :error, "OAuth popup redirect URL is not trusted")
194
+ return oauth_popup_completion(ctx, popup_origin, nonce: nonce, error: {
195
+ code: code,
196
+ description: "Untrusted redirect URL"
197
+ })
198
+ end
199
+ nil
200
+ end
201
+
202
+ def oauth_popup_additional_data(value)
203
+ parsed = value.is_a?(Hash) ? value : JSON.parse(value.to_s)
204
+ return {} unless parsed.is_a?(Hash)
205
+
206
+ parsed.reject { |key, _entry| OAUTH_POPUP_INTERNAL_STATE_KEYS.include?(key.to_s) }
207
+ rescue JSON::ParserError
208
+ {}
209
+ end
210
+
211
+ def oauth_popup_set_marker(ctx, popup_origin, nonce)
212
+ cookie = ctx.context.create_auth_cookie(OAUTH_POPUP_MARKER_COOKIE, max_age: 600)
213
+ ctx.set_signed_cookie(
214
+ cookie.name,
215
+ JSON.generate({popupOrigin: popup_origin, popupNonce: nonce}),
216
+ ctx.context.secret,
217
+ cookie.attributes
218
+ )
219
+ end
220
+
221
+ def oauth_popup_callback_path?(path)
222
+ path.to_s.start_with?("/callback/", "/oauth2/callback/")
223
+ end
224
+
225
+ def oauth_popup_after_callback(ctx)
226
+ location = ctx.response_headers["location"].to_s
227
+ return if location.empty?
228
+
229
+ marker_cookie = ctx.context.create_auth_cookie(OAUTH_POPUP_MARKER_COOKIE)
230
+ marker = ctx.get_signed_cookie(marker_cookie.name, ctx.context.secret)
231
+ return unless marker
232
+
233
+ Cookies.expire_cookie(ctx, marker_cookie)
234
+ data = JSON.parse(marker)
235
+ popup_origin = data.fetch("popupOrigin").to_s
236
+ nonce = data["popupNonce"].to_s
237
+ return if popup_origin.empty?
238
+
239
+ token = oauth_popup_session_token(ctx)
240
+ message = if token
241
+ {nonce: nonce, token: token, redirectTo: location}
242
+ else
243
+ error = oauth_popup_redirect_error(location)
244
+ return unless error
245
+
246
+ {nonce: nonce, error: error}
247
+ end
248
+
249
+ oauth_popup_completion(ctx, popup_origin, message)
250
+ rescue JSON::ParserError, KeyError
251
+ nil
252
+ end
253
+
254
+ def oauth_popup_session_token(ctx)
255
+ name = ctx.context.auth_cookies[:session_token].name
256
+ Cookies.split_set_cookie_header(ctx.response_headers["set-cookie"]).each do |line|
257
+ cookie = Cookies.parse_set_cookie(line)
258
+ next unless cookie && cookie[:name] == name
259
+ max_age = cookie.dig(:attributes, "max-age")
260
+ next if cookie[:value].empty? || (max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0)
261
+
262
+ return URI.decode_uri_component(cookie[:value])
263
+ rescue ArgumentError
264
+ return cookie[:value]
265
+ end
266
+ nil
267
+ end
268
+
269
+ def oauth_popup_redirect_error(location)
270
+ uri = URI.parse(location)
271
+ params = URI.decode_www_form(uri.query.to_s).to_h
272
+ return if params["error"].to_s.empty?
273
+
274
+ {code: params["error"], description: params["error_description"]}.compact
275
+ rescue URI::InvalidURIError
276
+ nil
277
+ end
278
+
279
+ def oauth_popup_completion(ctx, popup_origin, message)
280
+ oauth_popup_warn_missing_bearer(ctx) if message[:token]
281
+ payload = {
282
+ type: OAUTH_POPUP_MESSAGE_TYPE,
283
+ targetOrigin: popup_origin
284
+ }.merge(message)
285
+ serialized = JSON.generate(payload)
286
+ .gsub("<", "\\u003c")
287
+ .gsub("\u2028", "\\u2028")
288
+ .gsub("\u2029", "\\u2029")
289
+ html = <<~HTML
290
+ <!doctype html>
291
+ <html>
292
+ <head><meta charset="utf-8"><title>Completing sign-in</title></head>
293
+ <body>
294
+ <script type="application/json" id="#{OAUTH_POPUP_DATA_ELEMENT_ID}">#{serialized}</script>
295
+ <script>#{OAUTH_POPUP_COMPLETE_SCRIPT}</script>
296
+ </body>
297
+ </html>
298
+ HTML
299
+ headers = ctx.response_headers.merge(
300
+ "content-type" => "text/html; charset=utf-8",
301
+ "content-security-policy" => "default-src 'none'; script-src '#{OAUTH_POPUP_SCRIPT_CSP_HASH}'; base-uri 'none'",
302
+ "cache-control" => "no-store",
303
+ "pragma" => "no-cache"
304
+ )
305
+ headers.delete("location")
306
+ Endpoint::Result.new(response: html, status: 200, headers: headers)
307
+ end
308
+
309
+ def oauth_popup_warn_missing_bearer(ctx)
310
+ return if ctx.context.options.plugins.any? { |plugin| plugin.id == "bearer" }
311
+ return if @oauth_popup_warned_missing_bearer
312
+
313
+ @oauth_popup_warned_missing_bearer = true
314
+ oauth_popup_log(
315
+ ctx.context,
316
+ :warn,
317
+ "OAuth popup hands the session token back via postMessage, but the `bearer` plugin is not registered. Add bearer() for embedded cross-site authentication."
318
+ )
319
+ end
320
+
321
+ def oauth_popup_log(context, level, message, *details)
322
+ logger = context.logger
323
+ if logger.respond_to?(:call)
324
+ logger.call(level, message, *details)
325
+ elsif logger.respond_to?(level)
326
+ logger.public_send(level, message, *details)
327
+ end
328
+ end
329
+ end
330
+ end