better_auth 0.1.1 → 0.2.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +6 -0
  3. data/README.md +106 -16
  4. data/lib/better_auth/adapters/base.rb +49 -0
  5. data/lib/better_auth/adapters/internal_adapter.rb +439 -0
  6. data/lib/better_auth/adapters/memory.rb +232 -0
  7. data/lib/better_auth/adapters/mongodb.rb +369 -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 +425 -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 +210 -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 +129 -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 +348 -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 +990 -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 +215 -0
  68. data/lib/better_auth/request_ip.rb +70 -0
  69. data/lib/better_auth/router.rb +365 -0
  70. data/lib/better_auth/routes/account.rb +211 -0
  71. data/lib/better_auth/routes/email_verification.rb +108 -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 +164 -0
  75. data/lib/better_auth/routes/session.rb +137 -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 +145 -0
  79. data/lib/better_auth/routes/social.rb +188 -0
  80. data/lib/better_auth/routes/user.rb +193 -0
  81. data/lib/better_auth/schema/sql.rb +191 -0
  82. data/lib/better_auth/schema.rb +275 -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 +55 -0
  86. data/lib/better_auth/social_providers/base.rb +67 -0
  87. data/lib/better_auth/social_providers/discord.rb +59 -0
  88. data/lib/better_auth/social_providers/github.rb +59 -0
  89. data/lib/better_auth/social_providers/gitlab.rb +54 -0
  90. data/lib/better_auth/social_providers/google.rb +65 -0
  91. data/lib/better_auth/social_providers/microsoft_entra_id.rb +65 -0
  92. data/lib/better_auth/social_providers.rb +9 -0
  93. data/lib/better_auth/version.rb +1 -1
  94. data/lib/better_auth.rb +87 -2
  95. metadata +218 -21
  96. data/.ruby-version +0 -1
  97. data/.standard.yml +0 -12
  98. data/.vscode/settings.json +0 -22
  99. data/AGENTS.md +0 -50
  100. data/CLAUDE.md +0 -1
  101. data/CODE_OF_CONDUCT.md +0 -173
  102. data/CONTRIBUTING.md +0 -187
  103. data/Gemfile +0 -12
  104. data/Makefile +0 -207
  105. data/Rakefile +0 -25
  106. data/SECURITY.md +0 -28
  107. data/docker-compose.yml +0 -63
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Routes
7
+ def self.error
8
+ Endpoint.new(
9
+ path: "/error",
10
+ method: "GET",
11
+ metadata: {hide: true}
12
+ ) do |ctx|
13
+ query = ctx.query || {}
14
+ raw_code = query["error"] || query[:error] || "UNKNOWN"
15
+ raw_description = query["error_description"] || query[:error_description]
16
+ safe_code = valid_error_code?(raw_code) ? raw_code.to_s : "UNKNOWN"
17
+ query_params = error_query_params(safe_code, raw_description)
18
+ error_url = ctx.context.options.on_api_error[:error_url]
19
+
20
+ if error_url
21
+ location = append_query(error_url, query_params)
22
+ next [302, {"location" => location}, [""]]
23
+ end
24
+
25
+ if ctx.context.options.production? && !ctx.context.options.on_api_error[:customize_default_error_page]
26
+ next [302, {"location" => "/?#{query_params}"}, [""]]
27
+ end
28
+
29
+ [
30
+ 200,
31
+ {"content-type" => "text/html"},
32
+ [error_html(safe_code, raw_description)]
33
+ ]
34
+ end
35
+ end
36
+
37
+ def self.valid_error_code?(value)
38
+ /\A['A-Za-z0-9_-]+\z/.match?(value.to_s)
39
+ end
40
+
41
+ def self.error_query_params(code, description)
42
+ params = {error: code}
43
+ params[:error_description] = description if description
44
+ URI.encode_www_form(params)
45
+ end
46
+
47
+ def self.append_query(url, query)
48
+ separator = url.include?("?") ? "&" : "?"
49
+ "#{url}#{separator}#{query}"
50
+ end
51
+
52
+ def self.error_html(code, description)
53
+ safe_code = sanitize_html(code)
54
+ safe_description = description ? sanitize_html(description) : default_error_description(safe_code)
55
+
56
+ <<~HTML
57
+ <!DOCTYPE html>
58
+ <html lang="en">
59
+ <head>
60
+ <meta charset="UTF-8">
61
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
62
+ <title>Error</title>
63
+ <style>
64
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; min-height: 100vh; display: grid; place-items: center; background: #fff; color: #171717; }
65
+ main { width: min(42rem, calc(100% - 2rem)); border: 2px solid #d4d4d4; padding: 1.5rem; text-align: center; }
66
+ h1 { margin: 0 0 1rem; font-size: 3rem; line-height: 1; }
67
+ code { display: inline-block; border: 1px solid #d4d4d4; padding: 0.375rem 0.75rem; margin-bottom: 1rem; word-break: break-all; }
68
+ p { color: #525252; line-height: 1.5; }
69
+ a { color: inherit; }
70
+ @media (prefers-color-scheme: dark) {
71
+ body { background: #171717; color: #fafafa; }
72
+ main, code { border-color: #404040; }
73
+ p { color: #d4d4d4; }
74
+ }
75
+ </style>
76
+ </head>
77
+ <body>
78
+ <main>
79
+ <h1>ERROR</h1>
80
+ <code>#{safe_code}</code>
81
+ <p>#{safe_description}</p>
82
+ </main>
83
+ </body>
84
+ </html>
85
+ HTML
86
+ end
87
+
88
+ def self.default_error_description(code)
89
+ "We encountered an unexpected error. You can find more information about this error at " \
90
+ "<a href=\"https://better-auth.com/docs/reference/errors/#{URI.encode_www_form_component(code)}\">Better Auth docs</a>."
91
+ end
92
+
93
+ def self.sanitize_html(value)
94
+ value.to_s
95
+ .gsub("<", "&lt;")
96
+ .gsub(">", "&gt;")
97
+ .gsub('"', "&quot;")
98
+ .gsub("'", "&#39;")
99
+ .gsub(/&(?!(?:amp|lt|gt|quot|#39|#x[0-9a-fA-F]+|#[0-9]+);)/, "&amp;")
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Routes
5
+ def self.ok
6
+ Endpoint.new(
7
+ path: "/ok",
8
+ method: "GET",
9
+ metadata: {hide: true}
10
+ ) do |ctx|
11
+ ctx.json({ok: true})
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "uri"
5
+
6
+ module BetterAuth
7
+ module Routes
8
+ PASSWORD_RESET_MESSAGE = "If this email exists in our system, check your email for the reset link"
9
+
10
+ def self.request_password_reset
11
+ Endpoint.new(path: "/request-password-reset", method: "POST") do |ctx|
12
+ sender = ctx.context.options.email_and_password[:send_reset_password]
13
+ raise APIError.new("BAD_REQUEST", message: "Reset password isn't enabled") unless sender.respond_to?(:call)
14
+
15
+ body = normalize_hash(ctx.body)
16
+ email = body["email"].to_s.downcase
17
+ found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
18
+ unless found
19
+ SecureRandom.hex(12)
20
+ ctx.context.internal_adapter.find_verification_value("dummy-verification-token")
21
+ next ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
22
+ end
23
+
24
+ token = SecureRandom.hex(12)
25
+ expires_in = ctx.context.options.email_and_password[:reset_password_token_expires_in] || 3600
26
+ ctx.context.internal_adapter.create_verification_value(
27
+ identifier: "reset-password:#{token}",
28
+ value: found[:user]["id"],
29
+ expiresAt: Time.now + expires_in.to_i
30
+ )
31
+
32
+ redirect_to = body["redirectTo"] || body["redirect_to"]
33
+ callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
34
+ url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
35
+ sender.call({user: found[:user], url: url, token: token}, ctx.request)
36
+ ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
37
+ end
38
+ end
39
+
40
+ def self.request_password_reset_callback
41
+ Endpoint.new(path: "/reset-password/:token", method: "GET") do |ctx|
42
+ token = ctx.params[:token].to_s
43
+ callback_url = fetch_value(ctx.query, "callbackURL") || "/error"
44
+ verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
45
+
46
+ unless verification && !expired_time?(verification["expiresAt"])
47
+ raise ctx.redirect(absolute_callback(ctx.context, callback_url, error: "INVALID_TOKEN"))
48
+ end
49
+
50
+ raise ctx.redirect(absolute_callback(ctx.context, callback_url, token: token))
51
+ end
52
+ end
53
+
54
+ def self.reset_password
55
+ Endpoint.new(path: "/reset-password", method: "POST") do |ctx|
56
+ body = normalize_hash(ctx.body)
57
+ token = body["token"] || fetch_value(ctx.query, "token")
58
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) if token.to_s.empty?
59
+
60
+ password = body["newPassword"] || body["new_password"]
61
+ validate_password_length!(password, ctx.context.options.email_and_password)
62
+
63
+ verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
64
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification && !expired_time?(verification["expiresAt"])
65
+
66
+ user_id = verification["value"]
67
+ hashed = hash_password(ctx, password)
68
+ account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
69
+ if account
70
+ ctx.context.internal_adapter.update_password(user_id, hashed)
71
+ else
72
+ ctx.context.internal_adapter.create_account(userId: user_id, providerId: "credential", accountId: user_id, password: hashed)
73
+ end
74
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
75
+
76
+ if (callback = ctx.context.options.email_and_password[:on_password_reset])
77
+ user = ctx.context.internal_adapter.find_user_by_id(user_id)
78
+ callback.call({user: user}, ctx.request) if user
79
+ end
80
+ ctx.context.internal_adapter.delete_sessions(user_id) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
81
+
82
+ ctx.json({status: true})
83
+ end
84
+ end
85
+
86
+ def self.verify_password
87
+ Endpoint.new(path: "/verify-password", method: "POST") do |ctx|
88
+ session = current_session(ctx, sensitive: true)
89
+ password = normalize_hash(ctx.body)["password"].to_s
90
+ account = credential_account(ctx, session[:user]["id"])
91
+ valid = account && account["password"] && verify_password_value(ctx, password, account["password"])
92
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) unless valid
93
+
94
+ ctx.json({status: true})
95
+ end
96
+ end
97
+
98
+ def self.validate_password_length!(password, email_config)
99
+ unless password.is_a?(String) && !password.empty?
100
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
101
+ end
102
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_SHORT"]) if password.length < email_config[:min_password_length].to_i
103
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_LONG"]) if password.length > email_config[:max_password_length].to_i
104
+ end
105
+
106
+ def self.hash_password(ctx, password)
107
+ hasher = ctx.context.options.email_and_password.dig(:password, :hash)
108
+ if hasher.respond_to?(:call)
109
+ return hasher_arity_accepts_context?(hasher) ? hasher.call(password, ctx) : hasher.call(password)
110
+ end
111
+
112
+ Password.hash(
113
+ password,
114
+ algorithm: ctx.context.options.password_hasher
115
+ )
116
+ end
117
+
118
+ def self.hasher_arity_accepts_context?(hasher)
119
+ arity = hasher.arity
120
+ arity != 1 && arity != -1
121
+ end
122
+
123
+ def self.verify_password_value(ctx, password, digest)
124
+ Password.verify(
125
+ password: password,
126
+ hash: digest,
127
+ verifier: ctx.context.options.email_and_password.dig(:password, :verify),
128
+ algorithm: ctx.context.options.password_hasher
129
+ )
130
+ end
131
+
132
+ def self.credential_account(ctx, user_id)
133
+ ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
134
+ end
135
+
136
+ def self.expired_time?(value)
137
+ value && value < Time.now
138
+ end
139
+
140
+ def self.fetch_value(hash, key)
141
+ snake_key = key.to_s
142
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
143
+ .tr("-", "_")
144
+ .downcase
145
+ hash[key] ||
146
+ hash[key.to_s] ||
147
+ hash[key.to_sym] ||
148
+ hash[Schema.storage_key(key)] ||
149
+ hash[Schema.storage_key(key).to_sym] ||
150
+ hash[snake_key] ||
151
+ hash[snake_key.to_sym]
152
+ end
153
+
154
+ def self.absolute_callback(context, callback_url, params)
155
+ uri = URI.parse(callback_url.to_s)
156
+ origin = Configuration.origin_for(URI.parse(context.base_url))
157
+ url = uri.relative? ? URI.join("#{origin}/", callback_url.to_s.delete_prefix("/")) : uri
158
+ query = URI.decode_www_form(url.query.to_s)
159
+ params.each { |key, value| query << [key.to_s, value] }
160
+ url.query = URI.encode_www_form(query)
161
+ url.to_s
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Routes
5
+ def self.get_session
6
+ Endpoint.new(path: "/get-session", method: "GET") do |ctx|
7
+ session = current_session(ctx, allow_nil: true)
8
+ next ctx.json(nil) unless session
9
+
10
+ ctx.json(parsed_session_response(ctx, session))
11
+ rescue APIError
12
+ raise
13
+ rescue => error
14
+ log(ctx.context, :error, "FAILED_TO_GET_SESSION #{error.message}")
15
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: BASE_ERROR_CODES["FAILED_TO_GET_SESSION"])
16
+ end
17
+ end
18
+
19
+ def self.list_sessions
20
+ Endpoint.new(path: "/list-sessions", method: "GET") do |ctx|
21
+ session = current_session(ctx)
22
+ sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
23
+ active = sessions
24
+ .map { |entry| stringify_keys(entry) }
25
+ .select { |entry| !Session.expired?(entry) }
26
+ .map { |entry| Schema.parse_output(ctx.context.options, "session", entry) }
27
+ ctx.json(active)
28
+ end
29
+ end
30
+
31
+ def self.update_session
32
+ Endpoint.new(path: "/update-session", method: "POST") do |ctx|
33
+ session = current_session(ctx, sensitive: true)
34
+ body = Routes.parse_declared_input(ctx, "session", ctx.body, allowed_base: [])
35
+ raise APIError.new("BAD_REQUEST", message: "No fields to update") if body.empty?
36
+
37
+ updated = ctx.context.internal_adapter.update_session(session[:session]["token"], body)
38
+ merged = session[:session].merge(updated || body)
39
+ Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
40
+ ctx.json({status: true})
41
+ end
42
+ end
43
+
44
+ def self.revoke_session
45
+ Endpoint.new(path: "/revoke-session", method: "POST") do |ctx|
46
+ session = current_session(ctx, sensitive: true)
47
+ body = normalize_hash(ctx.body)
48
+ token = body["token"].to_s
49
+ found = ctx.context.internal_adapter.find_session(token)
50
+
51
+ if found && stringify_keys(found[:session] || found["session"])["userId"] == session[:user]["id"]
52
+ ctx.context.internal_adapter.delete_session(token)
53
+ end
54
+
55
+ ctx.json({status: true})
56
+ end
57
+ end
58
+
59
+ def self.revoke_sessions
60
+ Endpoint.new(path: "/revoke-sessions", method: "POST") do |ctx|
61
+ session = current_session(ctx, sensitive: true)
62
+ ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
63
+ Cookies.delete_session_cookie(ctx)
64
+ ctx.json({status: true})
65
+ end
66
+ end
67
+
68
+ def self.revoke_other_sessions
69
+ Endpoint.new(path: "/revoke-other-sessions", method: "POST") do |ctx|
70
+ session = current_session(ctx, sensitive: true)
71
+ current_token = session[:session]["token"]
72
+ sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
73
+ sessions.each do |entry|
74
+ data = stringify_keys(entry)
75
+ next if Session.expired?(data) || data["token"] == current_token
76
+
77
+ ctx.context.internal_adapter.delete_session(data["token"])
78
+ end
79
+ ctx.json({status: true})
80
+ end
81
+ end
82
+
83
+ def self.current_session(ctx, allow_nil: false, sensitive: false)
84
+ data = Session.find_current(
85
+ ctx,
86
+ disable_cookie_cache: truthy_query?(ctx.query, "disableCookieCache"),
87
+ disable_refresh: truthy_query?(ctx.query, "disableRefresh"),
88
+ sensitive: sensitive
89
+ )
90
+ return nil if allow_nil && data.nil?
91
+
92
+ raise APIError.new("UNAUTHORIZED") unless data
93
+
94
+ {
95
+ session: stringify_keys(data[:session] || data["session"]),
96
+ user: stringify_keys(data[:user] || data["user"])
97
+ }
98
+ end
99
+
100
+ def self.parsed_session_response(ctx, session)
101
+ {
102
+ session: Schema.parse_output(ctx.context.options, "session", session[:session]),
103
+ user: Schema.parse_output(ctx.context.options, "user", session[:user])
104
+ }
105
+ end
106
+
107
+ def self.truthy_query?(query, key)
108
+ snake_key = key.to_s
109
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
110
+ .tr("-", "_")
111
+ .downcase
112
+ value = query[key] ||
113
+ query[key.to_sym] ||
114
+ query[Schema.storage_key(key)] ||
115
+ query[Schema.storage_key(key).to_sym] ||
116
+ query[snake_key] ||
117
+ query[snake_key.to_sym]
118
+ value == true || value.to_s == "true"
119
+ end
120
+
121
+ def self.stringify_keys(value)
122
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
123
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
124
+
125
+ value
126
+ end
127
+
128
+ def self.log(context, level, message)
129
+ logger = context.logger
130
+ if logger.respond_to?(:call)
131
+ logger.call(level, message)
132
+ elsif logger.respond_to?(level)
133
+ logger.public_send(level, message)
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Routes
7
+ def self.sign_in_email
8
+ Endpoint.new(
9
+ path: "/sign-in/email",
10
+ method: "POST",
11
+ metadata: {
12
+ allowed_media_types: [
13
+ "application/x-www-form-urlencoded",
14
+ "application/json"
15
+ ]
16
+ }
17
+ ) do |ctx|
18
+ options = ctx.context.options
19
+ email_config = options.email_and_password
20
+ if email_config[:enabled] == false
21
+ raise APIError.new("BAD_REQUEST", message: "Email and password is not enabled")
22
+ end
23
+
24
+ body = normalize_hash(ctx.body)
25
+ email = body["email"].to_s
26
+ password = body["password"].to_s
27
+ callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
28
+ remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
29
+
30
+ unless EMAIL_PATTERN.match?(email)
31
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
32
+ end
33
+
34
+ found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
35
+ unless found
36
+ hash_password(ctx, password)
37
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_EMAIL_OR_PASSWORD"])
38
+ end
39
+
40
+ user = found[:user] || found["user"]
41
+ accounts = found[:accounts] || found["accounts"] || []
42
+ credential_account = accounts.find { |account| account["providerId"] == "credential" || account[:providerId] == "credential" }
43
+ current_password = credential_account && (credential_account["password"] || credential_account[:password])
44
+ unless current_password && verify_password_value(ctx, password, current_password)
45
+ hash_password(ctx, password) unless current_password
46
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["INVALID_EMAIL_OR_PASSWORD"])
47
+ end
48
+
49
+ if email_config[:require_email_verification] && !user["emailVerified"]
50
+ send_sign_in_verification_email(ctx, user, callback_url)
51
+ raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["EMAIL_NOT_VERIFIED"])
52
+ end
53
+
54
+ dont_remember_me = remember_me == false || remember_me.to_s == "false"
55
+ session = ctx.context.internal_adapter.create_session(
56
+ user["id"],
57
+ dont_remember_me,
58
+ session_overrides(ctx),
59
+ true,
60
+ ctx
61
+ )
62
+ raise APIError.new("UNAUTHORIZED", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
63
+
64
+ Cookies.set_session_cookie(ctx, {session: session, user: user}, dont_remember_me)
65
+ ctx.set_header("location", callback_url) if callback_url
66
+ ctx.json({
67
+ redirect: !!callback_url,
68
+ token: session["token"],
69
+ url: callback_url,
70
+ user: Schema.parse_output(options, "user", user)
71
+ })
72
+ end
73
+ end
74
+
75
+ def self.send_sign_in_verification_email(ctx, user, callback_url)
76
+ verification = ctx.context.options.email_verification
77
+ sender = verification[:send_verification_email]
78
+ return unless verification[:send_on_sign_in] && sender.respond_to?(:call)
79
+
80
+ token = Crypto.sign_jwt(
81
+ {"email" => user["email"].to_s.downcase},
82
+ ctx.context.secret,
83
+ expires_in: verification[:expires_in] || 3600
84
+ )
85
+ callback = URI.encode_www_form_component(callback_url || "/")
86
+ url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
87
+ sender.call({user: user, url: url, token: token}, ctx.request)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Routes
5
+ def self.sign_out
6
+ Endpoint.new(path: "/sign-out", method: "POST") do |ctx|
7
+ token_cookie = ctx.context.auth_cookies[:session_token]
8
+ token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
9
+ ctx.context.internal_adapter.delete_session(token) if token
10
+ Cookies.delete_session_cookie(ctx)
11
+ ctx.json({success: true})
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module BetterAuth
6
+ module Routes
7
+ EMAIL_PATTERN = /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
8
+
9
+ def self.sign_up_email
10
+ Endpoint.new(
11
+ path: "/sign-up/email",
12
+ method: "POST",
13
+ metadata: {
14
+ allowed_media_types: [
15
+ "application/x-www-form-urlencoded",
16
+ "application/json"
17
+ ]
18
+ }
19
+ ) do |ctx|
20
+ options = ctx.context.options
21
+ email_config = options.email_and_password
22
+ if email_config[:enabled] == false || email_config[:disable_sign_up]
23
+ raise APIError.new("BAD_REQUEST", message: "Email and password sign up is not enabled")
24
+ end
25
+
26
+ body = normalize_hash(ctx.body)
27
+ name = body["name"].to_s
28
+ email = body["email"].to_s
29
+ password = body["password"]
30
+ image = body["image"]
31
+ callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
32
+ remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
33
+
34
+ validate_sign_up_input!(email, password, email_config)
35
+
36
+ ctx.context.adapter.transaction do
37
+ existing = ctx.context.internal_adapter.find_user_by_email(email)
38
+ if existing
39
+ raise APIError.new(
40
+ "UNPROCESSABLE_ENTITY",
41
+ message: BASE_ERROR_CODES["USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL"]
42
+ )
43
+ end
44
+
45
+ hashed_password = hash_password(ctx, password)
46
+ created_user = create_sign_up_user(ctx, body, email, name, image)
47
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"]) unless created_user
48
+
49
+ ctx.context.internal_adapter.link_account(
50
+ userId: created_user["id"],
51
+ providerId: "credential",
52
+ accountId: created_user["id"],
53
+ password: hashed_password
54
+ )
55
+
56
+ send_sign_up_verification_email(ctx, created_user, callback_url)
57
+
58
+ if email_config[:auto_sign_in] == false || email_config[:require_email_verification]
59
+ next ctx.json({token: nil, user: Schema.parse_output(options, "user", created_user)})
60
+ end
61
+
62
+ dont_remember_me = remember_me == false || remember_me.to_s == "false"
63
+ session = ctx.context.internal_adapter.create_session(
64
+ created_user["id"],
65
+ dont_remember_me,
66
+ session_overrides(ctx),
67
+ true,
68
+ ctx
69
+ )
70
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["FAILED_TO_CREATE_SESSION"]) unless session
71
+
72
+ Cookies.set_session_cookie(ctx, {session: session, user: created_user}, dont_remember_me)
73
+ ctx.json({token: session["token"], user: Schema.parse_output(options, "user", created_user)})
74
+ end
75
+ end
76
+ end
77
+
78
+ def self.validate_sign_up_input!(email, password, email_config)
79
+ unless EMAIL_PATTERN.match?(email)
80
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
81
+ end
82
+
83
+ unless password.is_a?(String) && !password.empty?
84
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
85
+ end
86
+
87
+ if password.length < email_config[:min_password_length].to_i
88
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_SHORT"])
89
+ end
90
+
91
+ if password.length > email_config[:max_password_length].to_i
92
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_LONG"])
93
+ end
94
+ end
95
+
96
+ def self.create_sign_up_user(ctx, body, email, name, image)
97
+ reserved = %w[email password name image callbackURL callbackUrl callback_url rememberMe remember_me]
98
+ additional = parse_declared_input(ctx, "user", body.except(*reserved), allowed_base: [])
99
+ ctx.context.internal_adapter.create_user(
100
+ additional.merge(
101
+ "email" => email.downcase,
102
+ "name" => name,
103
+ "image" => image,
104
+ "emailVerified" => false
105
+ )
106
+ )
107
+ rescue APIError
108
+ raise
109
+ rescue
110
+ raise APIError.new("UNPROCESSABLE_ENTITY", message: BASE_ERROR_CODES["FAILED_TO_CREATE_USER"])
111
+ end
112
+
113
+ def self.send_sign_up_verification_email(ctx, user, callback_url)
114
+ verification = ctx.context.options.email_verification
115
+ password_config = ctx.context.options.email_and_password
116
+ send_on_sign_up = verification.key?(:send_on_sign_up) ? verification[:send_on_sign_up] : password_config[:require_email_verification]
117
+ return unless send_on_sign_up
118
+
119
+ sender = verification[:send_verification_email]
120
+ return unless sender.respond_to?(:call)
121
+
122
+ token = Crypto.sign_jwt(
123
+ {"email" => user["email"].to_s.downcase},
124
+ ctx.context.secret,
125
+ expires_in: verification[:expires_in] || 3600
126
+ )
127
+ callback = URI.encode_www_form_component(callback_url || "/")
128
+ url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
129
+ sender.call({user: user, url: url, token: token}, ctx.request)
130
+ end
131
+
132
+ def self.session_overrides(ctx)
133
+ {
134
+ ipAddress: RequestIP.client_ip(ctx, ctx.context.options).to_s,
135
+ userAgent: ctx.headers["user-agent"].to_s
136
+ }
137
+ end
138
+
139
+ def self.normalize_hash(value)
140
+ value.each_with_object({}) do |(key, object_value), result|
141
+ result[Schema.storage_key(key)] = object_value
142
+ end
143
+ end
144
+ end
145
+ end