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,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,183 @@
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
+ validate_callback_url!(ctx.context, callback_url)
45
+ verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
46
+
47
+ unless verification && !expired_time?(verification["expiresAt"])
48
+ raise ctx.redirect(absolute_callback(ctx.context, callback_url, error: "INVALID_TOKEN"))
49
+ end
50
+
51
+ raise ctx.redirect(absolute_callback(ctx.context, callback_url, token: token))
52
+ end
53
+ end
54
+
55
+ def self.reset_password
56
+ Endpoint.new(path: "/reset-password", method: "POST") do |ctx|
57
+ body = normalize_hash(ctx.body)
58
+ token = body["token"] || fetch_value(ctx.query, "token")
59
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) if token.to_s.empty?
60
+
61
+ password = body["newPassword"] || body["new_password"]
62
+ validate_password_length!(password, ctx.context.options.email_and_password)
63
+
64
+ verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
65
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) unless verification && !expired_time?(verification["expiresAt"])
66
+
67
+ user_id = verification["value"]
68
+ hashed = hash_password(ctx, password)
69
+ account = ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
70
+ if account
71
+ ctx.context.internal_adapter.update_password(user_id, hashed)
72
+ else
73
+ ctx.context.internal_adapter.create_account(userId: user_id, providerId: "credential", accountId: user_id, password: hashed)
74
+ end
75
+ ctx.context.internal_adapter.delete_verification_value(verification["id"])
76
+
77
+ if (callback = ctx.context.options.email_and_password[:on_password_reset])
78
+ user = ctx.context.internal_adapter.find_user_by_id(user_id)
79
+ callback.call({user: user}, ctx.request) if user
80
+ end
81
+ ctx.context.internal_adapter.delete_sessions(user_id) if ctx.context.options.email_and_password[:revoke_sessions_on_password_reset]
82
+
83
+ ctx.json({status: true})
84
+ end
85
+ end
86
+
87
+ def self.verify_password
88
+ Endpoint.new(path: "/verify-password", method: "POST") do |ctx|
89
+ session = current_session(ctx, sensitive: true)
90
+ password = normalize_hash(ctx.body)["password"].to_s
91
+ account = credential_account(ctx, session[:user]["id"])
92
+ valid = account && account["password"] && verify_password_value(ctx, password, account["password"])
93
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"]) unless valid
94
+
95
+ ctx.json({status: true})
96
+ end
97
+ end
98
+
99
+ def self.validate_password_length!(password, email_config)
100
+ unless password.is_a?(String) && !password.empty?
101
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_PASSWORD"])
102
+ end
103
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_SHORT"]) if password.length < email_config[:min_password_length].to_i
104
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["PASSWORD_TOO_LONG"]) if password.length > email_config[:max_password_length].to_i
105
+ end
106
+
107
+ def self.hash_password(ctx, password)
108
+ hasher = ctx.context.options.email_and_password.dig(:password, :hash)
109
+ if hasher.respond_to?(:call)
110
+ return hasher_arity_accepts_context?(hasher) ? hasher.call(password, ctx) : hasher.call(password)
111
+ end
112
+
113
+ Password.hash(
114
+ password,
115
+ algorithm: ctx.context.options.password_hasher
116
+ )
117
+ end
118
+
119
+ def self.hasher_arity_accepts_context?(hasher)
120
+ arity = hasher.arity
121
+ arity != 1 && arity != -1
122
+ end
123
+
124
+ def self.verify_password_value(ctx, password, digest)
125
+ Password.verify(
126
+ password: password,
127
+ hash: digest,
128
+ verifier: ctx.context.options.email_and_password.dig(:password, :verify),
129
+ algorithm: ctx.context.options.password_hasher
130
+ )
131
+ end
132
+
133
+ def self.credential_account(ctx, user_id)
134
+ ctx.context.internal_adapter.find_accounts(user_id).find { |entry| entry["providerId"] == "credential" }
135
+ end
136
+
137
+ def self.expired_time?(value)
138
+ value && value < Time.now
139
+ end
140
+
141
+ def self.fetch_value(hash, key)
142
+ snake_key = key.to_s
143
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
144
+ .tr("-", "_")
145
+ .downcase
146
+ hash[key] ||
147
+ hash[key.to_s] ||
148
+ hash[key.to_sym] ||
149
+ hash[Schema.storage_key(key)] ||
150
+ hash[Schema.storage_key(key).to_sym] ||
151
+ hash[snake_key] ||
152
+ hash[snake_key.to_sym]
153
+ end
154
+
155
+ def self.absolute_callback(context, callback_url, params)
156
+ validate_callback_url!(context, callback_url)
157
+ uri = URI.parse(callback_url.to_s)
158
+ origin = Configuration.origin_for(URI.parse(context.base_url))
159
+ url = uri.relative? ? URI.join("#{origin}/", callback_url.to_s.delete_prefix("/")) : uri
160
+ query = URI.decode_www_form(url.query.to_s)
161
+ params.each { |key, value| query << [key.to_s, value] }
162
+ url.query = URI.encode_www_form(query)
163
+ url.to_s
164
+ end
165
+
166
+ def self.validate_callback_url!(context, callback_url)
167
+ return if callback_url.nil? || callback_url.to_s.empty?
168
+
169
+ value = callback_url.to_s
170
+ if value.start_with?("/")
171
+ return if Configuration.relative_path_allowed?(value)
172
+ else
173
+ uri = Configuration.parse_uri(value)
174
+ base_uri = Configuration.parse_uri(context.base_url.to_s)
175
+ base_origin = base_uri && Configuration.origin_for(base_uri)
176
+ return if uri && Configuration.origin_for(uri) == base_origin
177
+ return if context.trusted_origin?(value)
178
+ end
179
+
180
+ raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"])
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,160 @@
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
+ update = body.merge("updatedAt" => Time.now)
38
+ updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
39
+ merged = session[:session].merge(updated || update)
40
+ Cookies.set_session_cookie(ctx, {session: merged, user: session[:user]}, Cookies.dont_remember?(ctx))
41
+ ctx.json(parsed_session_response(ctx, {session: merged, user: session[:user]}))
42
+ end
43
+ end
44
+
45
+ def self.revoke_session
46
+ Endpoint.new(path: "/revoke-session", method: "POST") do |ctx|
47
+ session = current_session(ctx, sensitive: true)
48
+ body = normalize_hash(ctx.body)
49
+ token = body["token"].to_s
50
+ found = ctx.context.internal_adapter.find_session(token)
51
+
52
+ if found && stringify_keys(found[:session] || found["session"])["userId"] == session[:user]["id"]
53
+ ctx.context.internal_adapter.delete_session(token)
54
+ end
55
+
56
+ ctx.json({status: true})
57
+ end
58
+ end
59
+
60
+ def self.revoke_sessions
61
+ Endpoint.new(path: "/revoke-sessions", method: "POST") do |ctx|
62
+ session = current_session(ctx, sensitive: true)
63
+ ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
64
+ Cookies.delete_session_cookie(ctx)
65
+ ctx.json({status: true})
66
+ end
67
+ end
68
+
69
+ def self.revoke_other_sessions
70
+ Endpoint.new(path: "/revoke-other-sessions", method: "POST") do |ctx|
71
+ session = current_session(ctx, sensitive: true)
72
+ current_token = session[:session]["token"]
73
+ sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
74
+ sessions.each do |entry|
75
+ data = stringify_keys(entry)
76
+ next if Session.expired?(data) || data["token"] == current_token
77
+
78
+ ctx.context.internal_adapter.delete_session(data["token"])
79
+ end
80
+ ctx.json({status: true})
81
+ end
82
+ end
83
+
84
+ def self.current_session(ctx, allow_nil: false, sensitive: false)
85
+ data = Session.find_current(
86
+ ctx,
87
+ disable_cookie_cache: truthy_query?(ctx.query, "disableCookieCache"),
88
+ disable_refresh: truthy_query?(ctx.query, "disableRefresh"),
89
+ sensitive: sensitive
90
+ )
91
+ return nil if allow_nil && data.nil?
92
+
93
+ raise APIError.new("UNAUTHORIZED") unless data
94
+
95
+ session = stringify_keys(data[:session] || data["session"])
96
+ ensure_fresh_session!(ctx, session) if sensitive
97
+
98
+ {
99
+ session: session,
100
+ user: stringify_keys(data[:user] || data["user"])
101
+ }
102
+ end
103
+
104
+ def self.ensure_fresh_session!(ctx, session)
105
+ fresh_age = ctx.context.session_config[:fresh_age].to_i
106
+ return if fresh_age.zero?
107
+
108
+ created_at = normalize_time(session["createdAt"])
109
+ return unless created_at && Time.now - created_at >= fresh_age
110
+
111
+ raise APIError.new("FORBIDDEN", code: "SESSION_NOT_FRESH", message: BASE_ERROR_CODES.fetch("SESSION_NOT_FRESH"))
112
+ end
113
+
114
+ def self.normalize_time(value)
115
+ return value if value.is_a?(Time)
116
+ return nil if value.nil? || value.to_s.empty?
117
+
118
+ Time.parse(value.to_s)
119
+ rescue ArgumentError
120
+ nil
121
+ end
122
+
123
+ def self.parsed_session_response(ctx, session)
124
+ {
125
+ session: Schema.parse_output(ctx.context.options, "session", session[:session]),
126
+ user: Schema.parse_output(ctx.context.options, "user", session[:user])
127
+ }
128
+ end
129
+
130
+ def self.truthy_query?(query, key)
131
+ snake_key = key.to_s
132
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
133
+ .tr("-", "_")
134
+ .downcase
135
+ value = query[key] ||
136
+ query[key.to_sym] ||
137
+ query[Schema.storage_key(key)] ||
138
+ query[Schema.storage_key(key).to_sym] ||
139
+ query[snake_key] ||
140
+ query[snake_key.to_sym]
141
+ value == true || value.to_s == "true"
142
+ end
143
+
144
+ def self.stringify_keys(value)
145
+ return value.each_with_object({}) { |(key, object_value), result| result[key.to_s] = stringify_keys(object_value) } if value.is_a?(Hash)
146
+ return value.map { |entry| stringify_keys(entry) } if value.is_a?(Array)
147
+
148
+ value
149
+ end
150
+
151
+ def self.log(context, level, message)
152
+ logger = context.logger
153
+ if logger.respond_to?(:call)
154
+ logger.call(level, message)
155
+ elsif logger.respond_to?(level)
156
+ logger.public_send(level, message)
157
+ end
158
+ end
159
+ end
160
+ 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] != true
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