better_auth 0.1.1 → 0.3.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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +110 -18
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +589 -0
  6. data/lib/better_auth/adapters/memory.rb +235 -0
  7. data/lib/better_auth/adapters/mongodb.rb +9 -0
  8. data/lib/better_auth/adapters/mssql.rb +42 -0
  9. data/lib/better_auth/adapters/mysql.rb +33 -0
  10. data/lib/better_auth/adapters/postgres.rb +17 -0
  11. data/lib/better_auth/adapters/sql.rb +441 -0
  12. data/lib/better_auth/adapters/sqlite.rb +20 -0
  13. data/lib/better_auth/api.rb +226 -0
  14. data/lib/better_auth/api_error.rb +53 -0
  15. data/lib/better_auth/auth.rb +42 -0
  16. data/lib/better_auth/configuration.rb +399 -0
  17. data/lib/better_auth/context.rb +211 -0
  18. data/lib/better_auth/cookies.rb +278 -0
  19. data/lib/better_auth/core.rb +37 -1
  20. data/lib/better_auth/crypto/jwe.rb +76 -0
  21. data/lib/better_auth/crypto.rb +191 -0
  22. data/lib/better_auth/database_hooks.rb +114 -0
  23. data/lib/better_auth/endpoint.rb +326 -0
  24. data/lib/better_auth/error.rb +52 -0
  25. data/lib/better_auth/middleware/origin_check.rb +128 -0
  26. data/lib/better_auth/password.rb +120 -0
  27. data/lib/better_auth/plugin.rb +142 -0
  28. data/lib/better_auth/plugin_context.rb +16 -0
  29. data/lib/better_auth/plugin_registry.rb +67 -0
  30. data/lib/better_auth/plugins/access.rb +87 -0
  31. data/lib/better_auth/plugins/additional_fields.rb +29 -0
  32. data/lib/better_auth/plugins/admin/schema.rb +28 -0
  33. data/lib/better_auth/plugins/admin.rb +518 -0
  34. data/lib/better_auth/plugins/anonymous.rb +198 -0
  35. data/lib/better_auth/plugins/api_key.rb +16 -0
  36. data/lib/better_auth/plugins/bearer.rb +128 -0
  37. data/lib/better_auth/plugins/captcha.rb +159 -0
  38. data/lib/better_auth/plugins/custom_session.rb +84 -0
  39. data/lib/better_auth/plugins/device_authorization.rb +302 -0
  40. data/lib/better_auth/plugins/email_otp.rb +536 -0
  41. data/lib/better_auth/plugins/expo.rb +88 -0
  42. data/lib/better_auth/plugins/generic_oauth.rb +780 -0
  43. data/lib/better_auth/plugins/have_i_been_pwned.rb +94 -0
  44. data/lib/better_auth/plugins/jwt.rb +482 -0
  45. data/lib/better_auth/plugins/last_login_method.rb +92 -0
  46. data/lib/better_auth/plugins/magic_link.rb +181 -0
  47. data/lib/better_auth/plugins/mcp.rb +342 -0
  48. data/lib/better_auth/plugins/multi_session.rb +173 -0
  49. data/lib/better_auth/plugins/oauth_protocol.rb +694 -0
  50. data/lib/better_auth/plugins/oauth_provider.rb +16 -0
  51. data/lib/better_auth/plugins/oauth_proxy.rb +257 -0
  52. data/lib/better_auth/plugins/oidc_provider.rb +597 -0
  53. data/lib/better_auth/plugins/one_tap.rb +154 -0
  54. data/lib/better_auth/plugins/one_time_token.rb +106 -0
  55. data/lib/better_auth/plugins/open_api.rb +489 -0
  56. data/lib/better_auth/plugins/organization/schema.rb +106 -0
  57. data/lib/better_auth/plugins/organization.rb +995 -0
  58. data/lib/better_auth/plugins/passkey.rb +16 -0
  59. data/lib/better_auth/plugins/phone_number.rb +321 -0
  60. data/lib/better_auth/plugins/scim.rb +16 -0
  61. data/lib/better_auth/plugins/siwe.rb +242 -0
  62. data/lib/better_auth/plugins/sso.rb +16 -0
  63. data/lib/better_auth/plugins/stripe.rb +16 -0
  64. data/lib/better_auth/plugins/two_factor.rb +514 -0
  65. data/lib/better_auth/plugins/username.rb +278 -0
  66. data/lib/better_auth/plugins.rb +46 -0
  67. data/lib/better_auth/rate_limiter.rb +232 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +378 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +111 -0
  72. data/lib/better_auth/routes/error.rb +102 -0
  73. data/lib/better_auth/routes/ok.rb +15 -0
  74. data/lib/better_auth/routes/password.rb +183 -0
  75. data/lib/better_auth/routes/session.rb +160 -0
  76. data/lib/better_auth/routes/sign_in.rb +90 -0
  77. data/lib/better_auth/routes/sign_out.rb +15 -0
  78. data/lib/better_auth/routes/sign_up.rb +196 -0
  79. data/lib/better_auth/routes/social.rb +367 -0
  80. data/lib/better_auth/routes/user.rb +205 -0
  81. data/lib/better_auth/schema/sql.rb +202 -0
  82. data/lib/better_auth/schema.rb +291 -0
  83. data/lib/better_auth/session.rb +122 -0
  84. data/lib/better_auth/session_store.rb +91 -0
  85. data/lib/better_auth/social_providers/apple.rb +91 -0
  86. data/lib/better_auth/social_providers/atlassian.rb +32 -0
  87. data/lib/better_auth/social_providers/base.rb +325 -0
  88. data/lib/better_auth/social_providers/cognito.rb +32 -0
  89. data/lib/better_auth/social_providers/discord.rb +81 -0
  90. data/lib/better_auth/social_providers/dropbox.rb +33 -0
  91. data/lib/better_auth/social_providers/facebook.rb +35 -0
  92. data/lib/better_auth/social_providers/figma.rb +31 -0
  93. data/lib/better_auth/social_providers/github.rb +74 -0
  94. data/lib/better_auth/social_providers/gitlab.rb +67 -0
  95. data/lib/better_auth/social_providers/google.rb +90 -0
  96. data/lib/better_auth/social_providers/huggingface.rb +31 -0
  97. data/lib/better_auth/social_providers/kakao.rb +32 -0
  98. data/lib/better_auth/social_providers/kick.rb +32 -0
  99. data/lib/better_auth/social_providers/line.rb +33 -0
  100. data/lib/better_auth/social_providers/linear.rb +44 -0
  101. data/lib/better_auth/social_providers/linkedin.rb +30 -0
  102. data/lib/better_auth/social_providers/microsoft_entra_id.rb +137 -0
  103. data/lib/better_auth/social_providers/naver.rb +31 -0
  104. data/lib/better_auth/social_providers/notion.rb +33 -0
  105. data/lib/better_auth/social_providers/paybin.rb +31 -0
  106. data/lib/better_auth/social_providers/paypal.rb +36 -0
  107. data/lib/better_auth/social_providers/polar.rb +31 -0
  108. data/lib/better_auth/social_providers/railway.rb +49 -0
  109. data/lib/better_auth/social_providers/reddit.rb +32 -0
  110. data/lib/better_auth/social_providers/roblox.rb +31 -0
  111. data/lib/better_auth/social_providers/salesforce.rb +38 -0
  112. data/lib/better_auth/social_providers/slack.rb +30 -0
  113. data/lib/better_auth/social_providers/spotify.rb +31 -0
  114. data/lib/better_auth/social_providers/tiktok.rb +35 -0
  115. data/lib/better_auth/social_providers/twitch.rb +39 -0
  116. data/lib/better_auth/social_providers/twitter.rb +32 -0
  117. data/lib/better_auth/social_providers/vercel.rb +47 -0
  118. data/lib/better_auth/social_providers/vk.rb +34 -0
  119. data/lib/better_auth/social_providers/wechat.rb +104 -0
  120. data/lib/better_auth/social_providers/zoom.rb +31 -0
  121. data/lib/better_auth/social_providers.rb +38 -0
  122. data/lib/better_auth/version.rb +1 -1
  123. data/lib/better_auth.rb +86 -2
  124. metadata +233 -21
  125. data/.ruby-version +0 -1
  126. data/.standard.yml +0 -12
  127. data/.vscode/settings.json +0 -22
  128. data/AGENTS.md +0 -50
  129. data/CLAUDE.md +0 -1
  130. data/CODE_OF_CONDUCT.md +0 -173
  131. data/CONTRIBUTING.md +0 -187
  132. data/Gemfile +0 -12
  133. data/Makefile +0 -207
  134. data/Rakefile +0 -25
  135. data/SECURITY.md +0 -28
  136. data/docker-compose.yml +0 -63
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def oauth_provider(*args)
8
+ Kernel.require "better_auth/oauth_provider"
9
+ BetterAuth::Plugins.oauth_provider(*args)
10
+ rescue LoadError => error
11
+ raise if error.path && error.path != "better_auth/oauth_provider"
12
+
13
+ raise LoadError, "BetterAuth::Plugins.oauth_provider requires the better_auth-oauth-provider gem. Add `gem \"better_auth-oauth-provider\"` and `require \"better_auth/oauth_provider\"`."
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "rack/utils"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module Plugins
9
+ module_function
10
+
11
+ def oauth_proxy(options = {})
12
+ config = {max_age: 60}.merge(normalize_hash(options))
13
+
14
+ Plugin.new(
15
+ id: "oauth-proxy",
16
+ endpoints: {
17
+ o_auth_proxy: oauth_proxy_endpoint(config)
18
+ },
19
+ hooks: {
20
+ before: [
21
+ {
22
+ matcher: ->(ctx) { oauth_proxy_sign_in_path?(ctx.path) },
23
+ handler: ->(ctx) { oauth_proxy_before_sign_in(ctx, config) }
24
+ },
25
+ {
26
+ matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) },
27
+ handler: ->(ctx) { oauth_proxy_restore_state_package(ctx, config) }
28
+ }
29
+ ],
30
+ after: [
31
+ {
32
+ matcher: ->(ctx) { oauth_proxy_sign_in_path?(ctx.path) },
33
+ handler: ->(ctx) { oauth_proxy_after_sign_in(ctx, config) }
34
+ },
35
+ {
36
+ matcher: ->(ctx) { oauth_proxy_callback_path?(ctx.path) },
37
+ handler: ->(ctx) { oauth_proxy_after_callback(ctx, config) }
38
+ }
39
+ ]
40
+ },
41
+ options: config
42
+ )
43
+ end
44
+
45
+ def oauth_proxy_endpoint(config)
46
+ Endpoint.new(path: "/oauth-proxy-callback", method: "GET") do |ctx|
47
+ query = normalize_hash(ctx.query)
48
+ callback_url = query[:callback_url] || "/"
49
+ oauth_proxy_validate_callback!(ctx, callback_url)
50
+
51
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: query[:cookies].to_s)
52
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid cookies or secret")) unless decrypted
53
+
54
+ payload = JSON.parse(decrypted)
55
+ cookies = payload["cookies"]
56
+ timestamp = payload["timestamp"]
57
+ unless cookies.is_a?(String) && timestamp.is_a?(Numeric)
58
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload structure"))
59
+ end
60
+
61
+ age = ((Time.now.to_f * 1000) - timestamp.to_f) / 1000
62
+ if age > config[:max_age].to_i || age < -10
63
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Payload expired or invalid"))
64
+ end
65
+
66
+ oauth_proxy_parse_set_cookie(cookies).each do |cookie|
67
+ ctx.set_cookie(cookie[:name], cookie[:value], cookie[:options])
68
+ end
69
+ raise ctx.redirect(callback_url)
70
+ rescue JSON::ParserError
71
+ raise ctx.redirect(oauth_proxy_error_url(ctx, "OAuthProxy - Invalid payload format"))
72
+ end
73
+ end
74
+
75
+ def oauth_proxy_before_sign_in(ctx, config)
76
+ return if oauth_proxy_skip?(ctx, config)
77
+ return unless ctx.body.is_a?(Hash)
78
+
79
+ original_callback = ctx.body["callbackURL"] || ctx.body["callbackUrl"] || ctx.body["callback_url"] || ctx.body[:callbackURL] || ctx.body[:callback_url] || ctx.context.base_url
80
+ current = oauth_proxy_current_uri(ctx, config)
81
+ callback = "#{oauth_proxy_strip_trailing(current.origin)}#{ctx.context.options.base_path}/oauth-proxy-callback?callbackURL=#{URI.encode_www_form_component(original_callback)}"
82
+ ctx.body = ctx.body.merge("callbackURL" => callback, :callback_url => callback)
83
+ nil
84
+ end
85
+
86
+ def oauth_proxy_restore_state_package(ctx, _config)
87
+ state = fetch_value(ctx.query, "state") || fetch_value(ctx.body, "state")
88
+ return if state.to_s.empty?
89
+
90
+ decrypted = Crypto.symmetric_decrypt(key: ctx.context.secret, data: state.to_s)
91
+ return unless decrypted
92
+
93
+ package = JSON.parse(decrypted)
94
+ return unless package["isOAuthProxy"] && package["state"] && package["stateCookie"]
95
+
96
+ cookie = ctx.context.create_auth_cookie("oauth_state")
97
+ current_cookie = ctx.headers["cookie"].to_s
98
+ restored_cookie = "#{cookie.name}=#{package["stateCookie"]}"
99
+ ctx.headers["cookie"] = current_cookie.empty? ? restored_cookie : "#{current_cookie}; #{restored_cookie}"
100
+ ctx.query = ctx.query.merge(:state => package["state"], "state" => package["state"])
101
+ ctx.body = ctx.body.merge(:state => package["state"], "state" => package["state"]) if ctx.body.is_a?(Hash)
102
+ nil
103
+ rescue JSON::ParserError
104
+ nil
105
+ end
106
+
107
+ def oauth_proxy_after_sign_in(ctx, config)
108
+ return if oauth_proxy_skip?(ctx, config)
109
+ return unless ctx.context.options.account[:store_state_strategy].to_s == "cookie"
110
+ return unless ctx.returned.is_a?(Hash)
111
+
112
+ provider_url = fetch_value(ctx.returned, "url").to_s
113
+ return if provider_url.empty?
114
+
115
+ uri = URI.parse(provider_url)
116
+ params = Rack::Utils.parse_query(uri.query)
117
+ original_state = params["state"]
118
+ return if original_state.to_s.empty?
119
+
120
+ state_cookie = oauth_proxy_state_cookie_value(ctx)
121
+ return if state_cookie.to_s.empty?
122
+
123
+ encrypted_package = Crypto.symmetric_encrypt(
124
+ key: ctx.context.secret,
125
+ data: JSON.generate({
126
+ state: original_state,
127
+ stateCookie: state_cookie,
128
+ isOAuthProxy: true
129
+ })
130
+ )
131
+ params["state"] = encrypted_package
132
+ uri.query = URI.encode_www_form(params)
133
+
134
+ response = ctx.returned.dup
135
+ if response.key?(:url)
136
+ response[:url] = uri.to_s
137
+ else
138
+ response["url"] = uri.to_s
139
+ end
140
+ ctx.returned = response
141
+ ctx.json(response)
142
+ rescue URI::InvalidURIError
143
+ nil
144
+ end
145
+
146
+ def oauth_proxy_after_callback(ctx, config)
147
+ location = ctx.response_headers["location"]
148
+ return unless location.to_s.include?("/oauth-proxy-callback?callbackURL")
149
+ return unless location.to_s.start_with?("http")
150
+
151
+ location_uri = URI.parse(location)
152
+ production = oauth_proxy_production_uri(ctx, config)
153
+ if location_uri.origin == production.origin
154
+ original = Rack::Utils.parse_query(location_uri.query).fetch("callbackURL", nil)
155
+ oauth_proxy_set_location(ctx, original) if original
156
+ return nil
157
+ end
158
+
159
+ set_cookie = ctx.response_headers["set-cookie"]
160
+ return if set_cookie.to_s.empty?
161
+
162
+ encrypted = Crypto.symmetric_encrypt(
163
+ key: ctx.context.secret,
164
+ data: JSON.generate({
165
+ cookies: set_cookie,
166
+ timestamp: (Time.now.to_f * 1000).to_i
167
+ })
168
+ )
169
+ separator = location.include?("?") ? "&" : "?"
170
+ oauth_proxy_set_location(ctx, "#{location}#{separator}cookies=#{URI.encode_www_form_component(encrypted)}")
171
+ nil
172
+ rescue URI::InvalidURIError
173
+ nil
174
+ end
175
+
176
+ def oauth_proxy_state_cookie_value(ctx)
177
+ cookie = ctx.context.create_auth_cookie("oauth_state")
178
+ parsed = oauth_proxy_parse_set_cookie(ctx.response_headers["set-cookie"])
179
+ exact = parsed.find { |entry| entry[:name] == cookie.name || entry[:name] == Cookies.strip_secure_cookie_prefix(cookie.name) }
180
+ exact && exact[:value]
181
+ end
182
+
183
+ def oauth_proxy_sign_in_path?(path)
184
+ path.to_s.start_with?("/sign-in/social", "/sign-in/oauth2")
185
+ end
186
+
187
+ def oauth_proxy_callback_path?(path)
188
+ path.to_s.start_with?("/callback", "/oauth2/callback")
189
+ end
190
+
191
+ def oauth_proxy_skip?(ctx, config)
192
+ current = oauth_proxy_current_uri(ctx, config)
193
+ production = oauth_proxy_production_uri(ctx, config)
194
+ current.origin == production.origin
195
+ rescue URI::InvalidURIError
196
+ false
197
+ end
198
+
199
+ def oauth_proxy_current_uri(ctx, config)
200
+ URI.parse((config[:current_url] || ctx.context.options.base_url || ctx.context.base_url).to_s)
201
+ end
202
+
203
+ def oauth_proxy_production_uri(ctx, config)
204
+ URI.parse((config[:production_url] || ctx.context.options.base_url || ctx.context.base_url).to_s)
205
+ end
206
+
207
+ def oauth_proxy_strip_trailing(value)
208
+ value.to_s.sub(%r{/+\z}, "")
209
+ end
210
+
211
+ def oauth_proxy_validate_callback!(ctx, callback_url)
212
+ return if callback_url.to_s.empty?
213
+ return if ctx.context.trusted_origin?(callback_url.to_s, allow_relative_paths: true)
214
+
215
+ raise APIError.new("FORBIDDEN", message: "Invalid callbackURL")
216
+ end
217
+
218
+ def oauth_proxy_error_url(ctx, message)
219
+ base = ctx.context.options.on_api_error[:error_url] || "#{oauth_proxy_strip_trailing(ctx.context.base_url)}/error"
220
+ uri = URI.parse(base)
221
+ params = URI.decode_www_form(uri.query.to_s)
222
+ params << ["error", message]
223
+ uri.query = URI.encode_www_form(params)
224
+ uri.to_s
225
+ end
226
+
227
+ def oauth_proxy_set_location(ctx, location)
228
+ ctx.set_header("location", location)
229
+ return unless ctx.returned.is_a?(APIError)
230
+
231
+ headers = ctx.returned.headers.merge("location" => location)
232
+ ctx.returned.instance_variable_set(:@headers, headers)
233
+ end
234
+
235
+ def oauth_proxy_parse_set_cookie(header)
236
+ header.to_s.split(/\n|,(?=\s*[^;,]+=)/).filter_map do |line|
237
+ parts = line.strip.split(/;\s*/)
238
+ name, value = parts.shift.to_s.split("=", 2)
239
+ next if name.to_s.empty?
240
+
241
+ options = {}
242
+ parts.each do |part|
243
+ key, option_value = part.split("=", 2)
244
+ case key.to_s.downcase
245
+ when "path" then options[:path] = option_value
246
+ when "expires" then options[:expires] = option_value
247
+ when "samesite" then options[:same_site] = option_value
248
+ when "httponly" then options[:http_only] = true
249
+ when "secure" then options[:secure] = true
250
+ when "max-age" then options[:max_age] = option_value
251
+ end
252
+ end
253
+ {name: Cookies.strip_secure_cookie_prefix(name), value: URI.decode_www_form_component(value.to_s), options: options}
254
+ end
255
+ end
256
+ end
257
+ end