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,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ ANONYMOUS_ERROR_CODES = {
8
+ "INVALID_EMAIL_FORMAT" => "Email was not generated in a valid format",
9
+ "FAILED_TO_CREATE_USER" => "Failed to create user",
10
+ "COULD_NOT_CREATE_SESSION" => "Could not create session",
11
+ "ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY" => "Anonymous users cannot sign in again anonymously",
12
+ "FAILED_TO_DELETE_ANONYMOUS_USER" => "Failed to delete anonymous user",
13
+ "USER_IS_NOT_ANONYMOUS" => "User is not anonymous",
14
+ "DELETE_ANONYMOUS_USER_DISABLED" => "Deleting anonymous users is disabled"
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def anonymous(options = {})
20
+ config = normalize_hash(options)
21
+
22
+ Plugin.new(
23
+ id: "anonymous",
24
+ endpoints: {
25
+ sign_in_anonymous: sign_in_anonymous_endpoint(config),
26
+ delete_anonymous_user: delete_anonymous_user_endpoint(config)
27
+ },
28
+ hooks: {
29
+ after: [
30
+ {
31
+ matcher: ->(ctx) { anonymous_link_path?(ctx.path) },
32
+ handler: ->(ctx) { link_anonymous_user(ctx, config) }
33
+ }
34
+ ]
35
+ },
36
+ schema: anonymous_schema(config),
37
+ error_codes: ANONYMOUS_ERROR_CODES,
38
+ options: config
39
+ )
40
+ end
41
+
42
+ def sign_in_anonymous_endpoint(config)
43
+ Endpoint.new(path: "/sign-in/anonymous", method: "POST") do |ctx|
44
+ existing_session = Session.find_current(ctx, disable_refresh: true)
45
+ if existing_session&.dig(:user, "isAnonymous")
46
+ raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["ANONYMOUS_USERS_CANNOT_SIGN_IN_AGAIN_ANONYMOUSLY"])
47
+ end
48
+
49
+ email = anonymous_email(config)
50
+ name = anonymous_name(ctx, config)
51
+ user = ctx.context.internal_adapter.create_user(
52
+ email: email,
53
+ emailVerified: false,
54
+ isAnonymous: true,
55
+ name: name,
56
+ createdAt: Time.now,
57
+ updatedAt: Time.now
58
+ )
59
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless user
60
+
61
+ session = ctx.context.internal_adapter.create_session(user["id"])
62
+ raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["COULD_NOT_CREATE_SESSION"]) unless session
63
+
64
+ Cookies.set_session_cookie(ctx, {session: session, user: user})
65
+ ctx.json({token: session["token"], user: Schema.parse_output(ctx.context.options, "user", user)})
66
+ end
67
+ end
68
+
69
+ def delete_anonymous_user_endpoint(config)
70
+ Endpoint.new(path: "/delete-anonymous-user", method: "POST") do |ctx|
71
+ session = Routes.current_session(ctx, sensitive: true)
72
+
73
+ if config[:disable_delete_anonymous_user]
74
+ raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["DELETE_ANONYMOUS_USER_DISABLED"])
75
+ end
76
+
77
+ unless session[:user]["isAnonymous"]
78
+ raise APIError.new("FORBIDDEN", message: ANONYMOUS_ERROR_CODES["USER_IS_NOT_ANONYMOUS"])
79
+ end
80
+
81
+ begin
82
+ ctx.context.internal_adapter.delete_user(session[:user]["id"])
83
+ rescue
84
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: ANONYMOUS_ERROR_CODES["FAILED_TO_DELETE_ANONYMOUS_USER"])
85
+ end
86
+
87
+ Cookies.delete_session_cookie(ctx)
88
+ ctx.json({success: true})
89
+ end
90
+ end
91
+
92
+ def anonymous_schema(config)
93
+ field_name = anonymous_schema_field_name(config) || "is_anonymous"
94
+ {
95
+ user: {
96
+ fields: {
97
+ isAnonymous: {
98
+ type: "boolean",
99
+ required: false,
100
+ input: false,
101
+ default_value: false,
102
+ field_name: field_name
103
+ }
104
+ }
105
+ }
106
+ }
107
+ end
108
+
109
+ def anonymous_email(config)
110
+ generator = config[:generate_random_email]
111
+ email = generator.call if generator.respond_to?(:call)
112
+ if email && email != ""
113
+ unless email.is_a?(String) && !email.empty? && Routes::EMAIL_PATTERN.match?(email)
114
+ raise APIError.new("BAD_REQUEST", message: ANONYMOUS_ERROR_CODES["INVALID_EMAIL_FORMAT"])
115
+ end
116
+ return email
117
+ end
118
+
119
+ id = SecureRandom.hex(16)
120
+ domain = config[:email_domain_name]
121
+ domain ? "temp-#{id}@#{domain}" : "temp@#{id}.com"
122
+ end
123
+
124
+ def anonymous_name(ctx, config)
125
+ generator = config[:generate_name]
126
+ name = generator.call(ctx) if generator.respond_to?(:call)
127
+ return name if present_string?(name)
128
+
129
+ "Anonymous"
130
+ end
131
+
132
+ def link_anonymous_user(ctx, config)
133
+ set_cookie = ctx.response_headers["set-cookie"].to_s
134
+ return if set_cookie.empty?
135
+ return unless set_cookie_value(set_cookie, ctx.context.auth_cookies[:session_token].name)
136
+
137
+ anonymous_session = Session.find_current(ctx, disable_refresh: true)
138
+ return unless anonymous_session&.dig(:user, "isAnonymous")
139
+
140
+ new_session = ctx.context.new_session
141
+ return unless new_session && new_session[:user] && new_session[:session]
142
+
143
+ on_link_account = config[:on_link_account]
144
+ if on_link_account.respond_to?(:call)
145
+ on_link_account.call(
146
+ anonymous_user: anonymous_session,
147
+ new_user: new_session,
148
+ ctx: ctx
149
+ )
150
+ end
151
+
152
+ new_user = new_session[:user]
153
+ return if config[:disable_delete_anonymous_user]
154
+ return if new_user["id"] == anonymous_session[:user]["id"]
155
+ return if new_user["isAnonymous"]
156
+
157
+ ctx.context.internal_adapter.delete_user(anonymous_session[:user]["id"])
158
+ nil
159
+ end
160
+
161
+ def set_cookie_value(set_cookie, name)
162
+ set_cookie.to_s.lines.each do |line|
163
+ cookie_pair = line.split(";", 2).first.to_s.strip
164
+ cookie_name, value = cookie_pair.split("=", 2)
165
+ return value if cookie_name == name && !value.nil?
166
+ end
167
+
168
+ nil
169
+ end
170
+
171
+ def anonymous_link_path?(path)
172
+ path.to_s.start_with?(
173
+ "/sign-in",
174
+ "/sign-up",
175
+ "/callback",
176
+ "/oauth2/callback",
177
+ "/magic-link/verify",
178
+ "/email-otp/verify-email",
179
+ "/one-tap/callback",
180
+ "/passkey/verify-authentication",
181
+ "/phone-number/verify"
182
+ )
183
+ end
184
+
185
+ def anonymous_schema_field_name(config)
186
+ fields = config.dig(:schema, :user, :fields) || {}
187
+ mapping = fields[:is_anonymous] || fields[:isAnonymous] || fields["isAnonymous"]
188
+ return mapping if mapping.is_a?(String)
189
+ return mapping[:field_name] || mapping[:fieldName] if mapping.is_a?(Hash)
190
+
191
+ nil
192
+ end
193
+
194
+ def present_string?(value)
195
+ value.is_a?(String) && !value.empty?
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def api_key(*args)
8
+ Kernel.require "better_auth/api_key"
9
+ BetterAuth::Plugins.api_key(*args)
10
+ rescue LoadError => error
11
+ raise if error.path && error.path != "better_auth/api_key"
12
+
13
+ raise LoadError, "BetterAuth::Plugins.api_key requires the better_auth-api-key gem. Add `gem \"better_auth-api-key\"` and `require \"better_auth/api_key\"`."
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Plugins
7
+ BEARER_SCHEME = "bearer "
8
+
9
+ module_function
10
+
11
+ def bearer(options = {})
12
+ config = normalize_hash(options)
13
+
14
+ Plugin.new(
15
+ id: "bearer",
16
+ hooks: {
17
+ before: [
18
+ {
19
+ matcher: ->(ctx) { authorization_header(ctx) },
20
+ handler: ->(ctx) { apply_bearer_token(ctx, config) }
21
+ }
22
+ ],
23
+ after: [
24
+ {
25
+ matcher: ->(_ctx) { true },
26
+ handler: ->(ctx) { expose_auth_token(ctx) }
27
+ }
28
+ ]
29
+ },
30
+ options: config
31
+ )
32
+ end
33
+
34
+ def authorization_header(ctx)
35
+ ctx.headers["authorization"] || ctx.headers["Authorization"]
36
+ end
37
+
38
+ def apply_bearer_token(ctx, config)
39
+ auth_header = authorization_header(ctx).to_s
40
+ return unless auth_header[0, BEARER_SCHEME.length].to_s.downcase == BEARER_SCHEME
41
+
42
+ token = auth_header[BEARER_SCHEME.length..].to_s.strip
43
+ return if token.empty?
44
+
45
+ signed_token = if token.include?(".")
46
+ normalize_signed_bearer_token(token)
47
+ else
48
+ sign_bearer_token(ctx, token, config)
49
+ end
50
+ return unless signed_token && valid_signed_token?(ctx, signed_token)
51
+
52
+ cookie_name = ctx.context.auth_cookies[:session_token].name
53
+ cookie = [ctx.headers["cookie"], "#{cookie_name}=#{signed_token}"].compact.reject(&:empty?).join("; ")
54
+ {context: {headers: ctx.headers.merge("cookie" => cookie)}}
55
+ end
56
+
57
+ def sign_bearer_token(ctx, token, config)
58
+ return if config[:require_signature]
59
+
60
+ signature = Crypto.hmac_signature(token, ctx.context.secret, encoding: :base64url)
61
+ "#{token}.#{signature}"
62
+ end
63
+
64
+ def valid_signed_token?(ctx, signed_token)
65
+ payload, signature = signed_token.rpartition(".").values_at(0, 2)
66
+ return false if payload.empty? || signature.empty?
67
+
68
+ Crypto.verify_hmac_signature(payload, signature, ctx.context.secret, encoding: :base64url)
69
+ rescue
70
+ false
71
+ end
72
+
73
+ def normalize_signed_bearer_token(token)
74
+ token.include?("%") ? safe_decode_bearer_token(token) : safe_decode_bearer_token(safe_encode_bearer_token(token))
75
+ end
76
+
77
+ def safe_encode_bearer_token(token)
78
+ URI.encode_www_form_component(token.to_s).gsub("+", "%20")
79
+ rescue
80
+ token.to_s
81
+ end
82
+
83
+ def safe_decode_bearer_token(token)
84
+ token.to_s.gsub(/%[0-9a-fA-F]{2}/) { |encoded| encoded[1, 2].to_i(16).chr }
85
+ rescue
86
+ token.to_s
87
+ end
88
+
89
+ def bearer_session_cookie(line)
90
+ first, *attributes = line.to_s.split(";").map(&:strip)
91
+ name, value = first.split("=", 2)
92
+ return unless name && value
93
+
94
+ {
95
+ name: name,
96
+ value: value,
97
+ attributes: attributes.each_with_object({}) do |attribute, result|
98
+ key, attribute_value = attribute.split("=", 2)
99
+ result[key.to_s.downcase] = attribute_value || true unless key.to_s.empty?
100
+ end
101
+ }
102
+ end
103
+
104
+ def expired_bearer_cookie?(cookie)
105
+ max_age = cookie[:attributes]["max-age"]
106
+ max_age.to_s.strip.match?(/\A[+-]?\d+\z/) && max_age.to_i == 0
107
+ end
108
+
109
+ def expose_auth_token(ctx)
110
+ set_cookie = ctx.response_headers["set-cookie"].to_s
111
+ token_name = ctx.context.auth_cookies[:session_token].name
112
+ token = set_cookie.lines.filter_map do |line|
113
+ cookie = bearer_session_cookie(line)
114
+ next unless cookie && cookie[:name] == token_name
115
+ next if cookie[:value].empty? || expired_bearer_cookie?(cookie)
116
+
117
+ cookie[:value]
118
+ end.first
119
+ return unless token
120
+
121
+ exposed = ctx.response_headers["access-control-expose-headers"].to_s.split(",").map(&:strip).reject(&:empty?)
122
+ exposed << "set-auth-token"
123
+ ctx.set_header("set-auth-token", token)
124
+ ctx.set_header("access-control-expose-headers", exposed.uniq.join(", "))
125
+ nil
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
7
+ module BetterAuth
8
+ module Plugins
9
+ CAPTCHA_EXTERNAL_ERROR_CODES = {
10
+ "VERIFICATION_FAILED" => "Captcha verification failed",
11
+ "MISSING_RESPONSE" => "Missing CAPTCHA response",
12
+ "UNKNOWN_ERROR" => "Something went wrong"
13
+ }.freeze
14
+
15
+ CAPTCHA_INTERNAL_ERROR_CODES = {
16
+ "MISSING_SECRET_KEY" => "Missing secret key",
17
+ "SERVICE_UNAVAILABLE" => "CAPTCHA service unavailable"
18
+ }.freeze
19
+
20
+ CAPTCHA_DEFAULT_ENDPOINTS = [
21
+ "/sign-up/email",
22
+ "/sign-in/email",
23
+ "/request-password-reset"
24
+ ].freeze
25
+
26
+ CAPTCHA_SITE_VERIFY_URLS = {
27
+ "cloudflare-turnstile" => "https://challenges.cloudflare.com/turnstile/v0/siteverify",
28
+ "google-recaptcha" => "https://www.google.com/recaptcha/api/siteverify",
29
+ "hcaptcha" => "https://api.hcaptcha.com/siteverify",
30
+ "captchafox" => "https://api.captchafox.com/siteverify"
31
+ }.freeze
32
+
33
+ module_function
34
+
35
+ def captcha(options = {})
36
+ config = normalize_hash(options)
37
+ Plugin.new(
38
+ id: "captcha",
39
+ on_request: lambda do |request, context|
40
+ captcha_on_request(request, context, config)
41
+ end,
42
+ error_codes: CAPTCHA_EXTERNAL_ERROR_CODES,
43
+ options: config
44
+ )
45
+ end
46
+
47
+ def captcha_on_request(request, context, config)
48
+ endpoints = Array(config[:endpoints]).empty? ? CAPTCHA_DEFAULT_ENDPOINTS : Array(config[:endpoints])
49
+ return nil unless endpoints.any? { |endpoint| request.path_info.include?(endpoint.to_s) || request.url.include?(endpoint.to_s) }
50
+
51
+ raise CAPTCHA_INTERNAL_ERROR_CODES["MISSING_SECRET_KEY"] if config[:secret_key].to_s.empty?
52
+
53
+ response_token = request.get_header("HTTP_X_CAPTCHA_RESPONSE")
54
+ if response_token.to_s.empty?
55
+ return {response: captcha_response(400, "MISSING_RESPONSE", CAPTCHA_EXTERNAL_ERROR_CODES["MISSING_RESPONSE"])}
56
+ end
57
+
58
+ result = captcha_verify(config, response_token, captcha_remote_ip(request, context))
59
+ return nil if captcha_success?(config, result)
60
+
61
+ {response: captcha_response(403, "VERIFICATION_FAILED", CAPTCHA_EXTERNAL_ERROR_CODES["VERIFICATION_FAILED"])}
62
+ rescue => error
63
+ captcha_log(context, error.message)
64
+ {response: captcha_response(500, "UNKNOWN_ERROR", CAPTCHA_EXTERNAL_ERROR_CODES["UNKNOWN_ERROR"])}
65
+ end
66
+
67
+ def captcha_verify(config, response_token, remote_ip)
68
+ provider = config[:provider].to_s
69
+ url = config[:site_verify_url_override] || CAPTCHA_SITE_VERIFY_URLS.fetch(provider)
70
+ params = {
71
+ site_verify_url: url,
72
+ secret_key: config[:secret_key],
73
+ captcha_response: response_token,
74
+ remote_ip: remote_ip,
75
+ site_key: config[:site_key],
76
+ min_score: config[:min_score],
77
+ provider: provider
78
+ }
79
+ return captcha_normalize_verifier_response(config[:verifier].call(captcha_verifier_params(params))) if config[:verifier].respond_to?(:call)
80
+
81
+ captcha_http_verify(params)
82
+ end
83
+
84
+ def captcha_verifier_params(params)
85
+ provider = params.fetch(:provider)
86
+ payload = captcha_payload(provider, params)
87
+ content_type = (provider == "cloudflare-turnstile") ? "application/json" : "application/x-www-form-urlencoded"
88
+ {
89
+ url: params.fetch(:site_verify_url),
90
+ content_type: content_type,
91
+ payload: payload,
92
+ provider: provider
93
+ }
94
+ end
95
+
96
+ def captcha_http_verify(params)
97
+ verifier = captcha_verifier_params(params)
98
+ uri = URI.parse(verifier[:url])
99
+ request = Net::HTTP::Post.new(uri)
100
+ request["Content-Type"] = verifier[:content_type]
101
+ request.body = if verifier[:content_type] == "application/json"
102
+ JSON.generate(verifier[:payload])
103
+ else
104
+ URI.encode_www_form(verifier[:payload])
105
+ end
106
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") { |http| http.request(request) }
107
+ raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"] unless response.is_a?(Net::HTTPSuccess)
108
+
109
+ JSON.parse(response.body.to_s)
110
+ rescue JSON::ParserError
111
+ raise CAPTCHA_INTERNAL_ERROR_CODES["SERVICE_UNAVAILABLE"]
112
+ end
113
+
114
+ def captcha_payload(provider, params)
115
+ payload = {
116
+ "secret" => params[:secret_key],
117
+ "response" => params[:captcha_response]
118
+ }
119
+ payload["sitekey"] = params[:site_key] if params[:site_key] && ["hcaptcha", "captchafox"].include?(provider)
120
+ if params[:remote_ip]
121
+ payload[(provider == "captchafox") ? "remoteIp" : "remoteip"] = params[:remote_ip]
122
+ end
123
+ payload
124
+ end
125
+
126
+ def captcha_success?(config, result)
127
+ return false unless result && result["success"]
128
+
129
+ if config[:provider].to_s == "google-recaptcha" && result.key?("score")
130
+ return result["score"].to_f >= (config[:min_score] || 0.5).to_f
131
+ end
132
+
133
+ true
134
+ end
135
+
136
+ def captcha_normalize_verifier_response(value)
137
+ return value.transform_keys(&:to_s) if value.is_a?(Hash)
138
+
139
+ {}
140
+ end
141
+
142
+ def captcha_response(status, code, message)
143
+ [status, {"content-type" => "application/json"}, [JSON.generate({code: code, message: message})]]
144
+ end
145
+
146
+ def captcha_remote_ip(request, context)
147
+ RequestIP.client_ip(request, context.options)
148
+ end
149
+
150
+ def captcha_log(context, message)
151
+ logger = context.logger
152
+ if logger.respond_to?(:call)
153
+ logger.call(:error, message)
154
+ elsif logger.respond_to?(:error)
155
+ logger.error(message)
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Plugins
5
+ module_function
6
+
7
+ def custom_session(resolver, options = nil, plugin_options = nil, **keywords)
8
+ config = normalize_hash(plugin_options || {})
9
+ config = config.merge(normalize_hash(options)) if options && !options.key?(:plugins)
10
+ config = config.merge(normalize_hash(keywords))
11
+
12
+ Plugin.new(
13
+ id: "custom-session",
14
+ endpoints: {
15
+ get_session: Endpoint.new(
16
+ path: "/get-session",
17
+ method: "GET",
18
+ query_schema: ->(query) { query || {} },
19
+ metadata: {
20
+ CUSTOM_SESSION: true,
21
+ openapi: {
22
+ description: "Get custom session data",
23
+ responses: {
24
+ "200" => {
25
+ description: "Success",
26
+ content: {
27
+ "application/json" => {
28
+ schema: {
29
+ type: "object",
30
+ nullable: true
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ) do |ctx|
39
+ session = Session.find_current(
40
+ ctx,
41
+ disable_cookie_cache: truthy_value?(fetch_value(ctx.query, "disableCookieCache")),
42
+ disable_refresh: truthy_value?(fetch_value(ctx.query, "disableRefresh"))
43
+ )
44
+ next ctx.json(nil) unless session
45
+
46
+ Cookies.set_session_cookie(ctx, session, false) if ctx.response_headers["set-cookie"].to_s.empty?
47
+ ctx.json(resolver.call(Routes.parsed_session_response(ctx, session), ctx))
48
+ end
49
+ },
50
+ hooks: {
51
+ after: [
52
+ {
53
+ matcher: ->(ctx) { ctx.path == "/multi-session/list-device-sessions" && config[:should_mutate_list_device_sessions_endpoint] },
54
+ handler: lambda do |ctx|
55
+ list = Array(ctx.returned)
56
+ ctx.json(list.map { |entry| resolver.call(symbolize_session(entry), ctx) })
57
+ end
58
+ }
59
+ ]
60
+ },
61
+ options: config
62
+ )
63
+ end
64
+
65
+ def truthy_value?(value)
66
+ value == true || value.to_s == "true"
67
+ end
68
+
69
+ def symbolize_session(entry)
70
+ data = stringify_keys(entry)
71
+ {
72
+ session: data["session"],
73
+ user: data["user"]
74
+ }
75
+ end
76
+
77
+ def stringify_keys(value)
78
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
79
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
80
+
81
+ value
82
+ end
83
+ end
84
+ end