better_auth 0.3.0 → 0.5.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +24 -0
  4. data/lib/better_auth/adapters/internal_adapter.rb +10 -7
  5. data/lib/better_auth/adapters/memory.rb +57 -11
  6. data/lib/better_auth/adapters/sql.rb +123 -20
  7. data/lib/better_auth/api.rb +114 -9
  8. data/lib/better_auth/async.rb +70 -0
  9. data/lib/better_auth/configuration.rb +97 -7
  10. data/lib/better_auth/context.rb +165 -12
  11. data/lib/better_auth/cookies.rb +6 -4
  12. data/lib/better_auth/core.rb +2 -0
  13. data/lib/better_auth/crypto/jwe.rb +27 -5
  14. data/lib/better_auth/crypto.rb +32 -0
  15. data/lib/better_auth/database_hooks.rb +8 -8
  16. data/lib/better_auth/deprecate.rb +28 -0
  17. data/lib/better_auth/endpoint.rb +92 -5
  18. data/lib/better_auth/error.rb +8 -1
  19. data/lib/better_auth/host.rb +166 -0
  20. data/lib/better_auth/instrumentation.rb +74 -0
  21. data/lib/better_auth/logger.rb +31 -0
  22. data/lib/better_auth/middleware/origin_check.rb +2 -2
  23. data/lib/better_auth/oauth2.rb +94 -0
  24. data/lib/better_auth/plugins/admin/schema.rb +2 -2
  25. data/lib/better_auth/plugins/admin.rb +344 -16
  26. data/lib/better_auth/plugins/anonymous.rb +37 -3
  27. data/lib/better_auth/plugins/device_authorization.rb +102 -5
  28. data/lib/better_auth/plugins/dub.rb +148 -0
  29. data/lib/better_auth/plugins/email_otp.rb +261 -19
  30. data/lib/better_auth/plugins/expo.rb +17 -1
  31. data/lib/better_auth/plugins/generic_oauth.rb +67 -35
  32. data/lib/better_auth/plugins/jwt.rb +37 -4
  33. data/lib/better_auth/plugins/last_login_method.rb +2 -2
  34. data/lib/better_auth/plugins/magic_link.rb +66 -3
  35. data/lib/better_auth/plugins/mcp/authorization.rb +111 -0
  36. data/lib/better_auth/plugins/mcp/config.rb +51 -0
  37. data/lib/better_auth/plugins/mcp/consent.rb +31 -0
  38. data/lib/better_auth/plugins/mcp/legacy_aliases.rb +39 -0
  39. data/lib/better_auth/plugins/mcp/metadata.rb +81 -0
  40. data/lib/better_auth/plugins/mcp/registration.rb +31 -0
  41. data/lib/better_auth/plugins/mcp/resource_handler.rb +37 -0
  42. data/lib/better_auth/plugins/mcp/schema.rb +91 -0
  43. data/lib/better_auth/plugins/mcp/token.rb +108 -0
  44. data/lib/better_auth/plugins/mcp/userinfo.rb +37 -0
  45. data/lib/better_auth/plugins/mcp.rb +111 -263
  46. data/lib/better_auth/plugins/multi_session.rb +61 -3
  47. data/lib/better_auth/plugins/oauth_protocol.rb +173 -30
  48. data/lib/better_auth/plugins/oauth_proxy.rb +26 -6
  49. data/lib/better_auth/plugins/oidc_provider.rb +118 -14
  50. data/lib/better_auth/plugins/one_tap.rb +7 -2
  51. data/lib/better_auth/plugins/one_time_token.rb +42 -2
  52. data/lib/better_auth/plugins/open_api.rb +163 -318
  53. data/lib/better_auth/plugins/organization/schema.rb +6 -0
  54. data/lib/better_auth/plugins/organization.rb +186 -56
  55. data/lib/better_auth/plugins/phone_number.rb +141 -6
  56. data/lib/better_auth/plugins/siwe.rb +69 -3
  57. data/lib/better_auth/plugins/two_factor.rb +118 -41
  58. data/lib/better_auth/plugins/username.rb +57 -2
  59. data/lib/better_auth/rate_limiter.rb +38 -0
  60. data/lib/better_auth/request_state.rb +44 -0
  61. data/lib/better_auth/response.rb +42 -0
  62. data/lib/better_auth/router.rb +7 -1
  63. data/lib/better_auth/routes/account.rb +220 -42
  64. data/lib/better_auth/routes/email_verification.rb +98 -14
  65. data/lib/better_auth/routes/password.rb +126 -8
  66. data/lib/better_auth/routes/session.rb +128 -13
  67. data/lib/better_auth/routes/sign_in.rb +26 -2
  68. data/lib/better_auth/routes/sign_out.rb +13 -1
  69. data/lib/better_auth/routes/sign_up.rb +70 -4
  70. data/lib/better_auth/routes/social.rb +132 -7
  71. data/lib/better_auth/routes/user.rb +228 -20
  72. data/lib/better_auth/routes/validation.rb +50 -0
  73. data/lib/better_auth/secret_config.rb +115 -0
  74. data/lib/better_auth/session.rb +13 -2
  75. data/lib/better_auth/url_helpers.rb +206 -0
  76. data/lib/better_auth/version.rb +1 -1
  77. data/lib/better_auth.rb +12 -0
  78. metadata +23 -1
@@ -5,7 +5,28 @@ require "uri"
5
5
  module BetterAuth
6
6
  module Routes
7
7
  def self.send_verification_email
8
- Endpoint.new(path: "/send-verification-email", method: "POST") do |ctx|
8
+ Endpoint.new(
9
+ path: "/send-verification-email",
10
+ method: "POST",
11
+ metadata: {
12
+ openapi: {
13
+ operationId: "sendVerificationEmail",
14
+ description: "Send an email verification link",
15
+ requestBody: OpenAPI.json_request_body(
16
+ OpenAPI.object_schema(
17
+ {
18
+ email: {type: "string", description: "The email address to verify"},
19
+ callbackURL: {type: ["string", "null"], description: "The URL to redirect to after verification"}
20
+ },
21
+ required: ["email"]
22
+ )
23
+ ),
24
+ responses: {
25
+ "200" => OpenAPI.json_response("Verification email sent", OpenAPI.status_response_schema)
26
+ }
27
+ }
28
+ }
29
+ ) do |ctx|
9
30
  sender = ctx.context.options.email_verification[:send_verification_email]
10
31
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["VERIFICATION_EMAIL_NOT_ENABLED"]) unless sender.respond_to?(:call)
11
32
 
@@ -32,7 +53,42 @@ module BetterAuth
32
53
  end
33
54
 
34
55
  def self.verify_email
35
- Endpoint.new(path: "/verify-email", method: "GET") do |ctx|
56
+ Endpoint.new(
57
+ path: "/verify-email",
58
+ method: "GET",
59
+ metadata: {
60
+ openapi: {
61
+ operationId: "verifyEmail",
62
+ description: "Verify an email address by token",
63
+ parameters: [
64
+ {
65
+ name: "token",
66
+ in: "query",
67
+ required: true,
68
+ schema: {type: "string"}
69
+ },
70
+ {
71
+ name: "callbackURL",
72
+ in: "query",
73
+ required: false,
74
+ schema: {type: "string"}
75
+ }
76
+ ],
77
+ responses: {
78
+ "200" => OpenAPI.json_response(
79
+ "Email verified",
80
+ OpenAPI.object_schema(
81
+ {
82
+ status: {type: "boolean"},
83
+ user: {type: ["object", "null"], "$ref": "#/components/schemas/User"}
84
+ },
85
+ required: ["status"]
86
+ )
87
+ )
88
+ }
89
+ }
90
+ }
91
+ ) do |ctx|
36
92
  token = fetch_value(ctx.query, "token").to_s
37
93
  callback_url = fetch_value(ctx.query, "callbackURL")
38
94
  validate_callback_url!(ctx.context, callback_url)
@@ -44,11 +100,27 @@ module BetterAuth
44
100
 
45
101
  user = user_data[:user]
46
102
  if update_to
47
- updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
48
- updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
49
- send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
50
- set_verified_session_cookie(ctx, updated_user)
51
- next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
103
+ session = current_session(ctx, allow_nil: true)
104
+ return redirect_or_error(ctx, callback_url, "invalid_user") if session && session[:user]["email"] != email
105
+
106
+ request_type = payload["requestType"] || payload["request_type"]
107
+ case request_type
108
+ when "change-email-confirmation"
109
+ send_change_email_verification_payload(ctx, user, update_to, callback_url)
110
+ next redirect_or_json(ctx, callback_url, {status: true})
111
+ when "change-email-verification"
112
+ updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: true)
113
+ updated_user = updated || user.merge("email" => update_to, "emailVerified" => true)
114
+ call_option(ctx.context.options.email_verification[:after_email_verification], updated_user, ctx.request)
115
+ set_verified_session_cookie(ctx, updated_user)
116
+ next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated_user)})
117
+ else
118
+ updated = ctx.context.internal_adapter.update_user_by_email(email, email: update_to, emailVerified: false)
119
+ updated_user = updated || user.merge("email" => update_to, "emailVerified" => false)
120
+ send_verification_email_payload(ctx, updated_user, callback_url) if ctx.context.options.email_verification[:send_verification_email].respond_to?(:call)
121
+ set_verified_session_cookie(ctx, updated_user)
122
+ next redirect_or_json(ctx, callback_url, {status: true, user: Schema.parse_output(ctx.context.options, "user", updated)})
123
+ end
52
124
  end
53
125
 
54
126
  if user["emailVerified"]
@@ -71,6 +143,16 @@ module BetterAuth
71
143
  ctx.context.options.email_verification[:send_verification_email].call({user: user, url: url, token: token}, ctx.request)
72
144
  end
73
145
 
146
+ def self.send_change_email_verification_payload(ctx, user, update_to, callback_url)
147
+ sender = ctx.context.options.email_verification[:send_verification_email]
148
+ return unless sender.respond_to?(:call)
149
+
150
+ token = create_email_verification_token(ctx, user["email"], update_to: update_to, extra: {"requestType" => "change-email-verification"})
151
+ callback = URI.encode_www_form_component(callback_url || "/")
152
+ url = "#{ctx.context.base_url}/verify-email?token=#{URI.encode_www_form_component(token)}&callbackURL=#{callback}"
153
+ sender.call({user: user.merge("email" => update_to), url: url, token: token}, ctx.request)
154
+ end
155
+
74
156
  def self.create_email_verification_token(ctx, email, update_to: nil, extra: {})
75
157
  payload = {"email" => email.to_s.downcase}.merge(extra)
76
158
  payload["updateTo"] = update_to if update_to
@@ -78,18 +160,20 @@ module BetterAuth
78
160
  end
79
161
 
80
162
  def self.verify_email_token(ctx, token, callback_url)
81
- payload = Crypto.verify_jwt(token, ctx.context.secret)
82
- return payload if payload
83
-
84
- redirect_or_error(ctx, callback_url, "invalid_token")
163
+ decoded, = JWT.decode(token.to_s, ctx.context.secret.to_s, true, algorithm: "HS256")
164
+ decoded
165
+ rescue JWT::ExpiredSignature
166
+ redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["TOKEN_EXPIRED"], code: "TOKEN_EXPIRED")
167
+ rescue JWT::DecodeError
168
+ redirect_or_error(ctx, callback_url, BASE_ERROR_CODES["INVALID_TOKEN"], code: "INVALID_TOKEN")
85
169
  end
86
170
 
87
- def self.redirect_or_error(ctx, callback_url, error)
171
+ def self.redirect_or_error(ctx, callback_url, error, code: nil)
88
172
  if callback_url
89
173
  separator = callback_url.include?("?") ? "&" : "?"
90
- raise ctx.redirect("#{callback_url}#{separator}error=#{error}")
174
+ raise ctx.redirect("#{callback_url}#{separator}error=#{code || error}")
91
175
  end
92
- raise APIError.new("UNAUTHORIZED", message: error)
176
+ raise APIError.new("UNAUTHORIZED", code: code, message: error)
93
177
  end
94
178
 
95
179
  def self.redirect_or_json(ctx, callback_url, data)
@@ -8,12 +8,46 @@ module BetterAuth
8
8
  PASSWORD_RESET_MESSAGE = "If this email exists in our system, check your email for the reset link"
9
9
 
10
10
  def self.request_password_reset
11
- Endpoint.new(path: "/request-password-reset", method: "POST") do |ctx|
11
+ Endpoint.new(
12
+ path: "/request-password-reset",
13
+ method: "POST",
14
+ body_schema: request_body_schema(email_strings: %w[email]),
15
+ metadata: {
16
+ openapi: {
17
+ operationId: "requestPasswordReset",
18
+ description: "Request a password reset link",
19
+ requestBody: OpenAPI.json_request_body(
20
+ OpenAPI.object_schema(
21
+ {
22
+ email: {type: "string", description: "The email address of the user"},
23
+ redirectTo: {type: ["string", "null"], description: "The URL to redirect to after reset"}
24
+ },
25
+ required: ["email"]
26
+ )
27
+ ),
28
+ responses: {
29
+ "200" => OpenAPI.json_response(
30
+ "Password reset request processed",
31
+ OpenAPI.status_response_schema(
32
+ {
33
+ message: {type: "string"}
34
+ },
35
+ required: ["status", "message"]
36
+ )
37
+ )
38
+ }
39
+ }
40
+ }
41
+ ) do |ctx|
12
42
  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)
43
+ unless sender.respond_to?(:call)
44
+ raise APIError.new("BAD_REQUEST", code: "RESET_PASSWORD_DISABLED", message: BASE_ERROR_CODES["RESET_PASSWORD_DISABLED"])
45
+ end
14
46
 
15
47
  body = normalize_hash(ctx.body)
16
48
  email = body["email"].to_s.downcase
49
+ redirect_to = body["redirectTo"] || body["redirect_to"]
50
+ validate_redirect_url!(ctx.context, redirect_to)
17
51
  found = ctx.context.internal_adapter.find_user_by_email(email, include_accounts: true)
18
52
  unless found
19
53
  SecureRandom.hex(12)
@@ -29,18 +63,48 @@ module BetterAuth
29
63
  expiresAt: Time.now + expires_in.to_i
30
64
  )
31
65
 
32
- redirect_to = body["redirectTo"] || body["redirect_to"]
33
66
  callback = redirect_to ? URI.encode_www_form_component(redirect_to) : ""
34
67
  url = "#{ctx.context.base_url}/reset-password/#{token}?callbackURL=#{callback}"
35
- sender.call({user: found[:user], url: url, token: token}, ctx.request)
68
+ begin
69
+ sender.call({user: found[:user], url: url, token: token}, ctx.request)
70
+ rescue => error
71
+ log(ctx.context, :error, "RESET_PASSWORD_EMAIL_ERROR #{error.message}")
72
+ end
36
73
  ctx.json({status: true, message: PASSWORD_RESET_MESSAGE})
37
74
  end
38
75
  end
39
76
 
40
77
  def self.request_password_reset_callback
41
- Endpoint.new(path: "/reset-password/:token", method: "GET") do |ctx|
78
+ Endpoint.new(
79
+ path: "/reset-password/:token",
80
+ method: "GET",
81
+ query_schema: request_query_schema(required_strings: %w[callbackURL]),
82
+ metadata: {
83
+ openapi: {
84
+ operationId: "requestPasswordResetCallback",
85
+ description: "Validate a password reset token and redirect to the callback URL",
86
+ parameters: [
87
+ {
88
+ name: "token",
89
+ in: "path",
90
+ required: true,
91
+ schema: {type: "string"}
92
+ },
93
+ {
94
+ name: "callbackURL",
95
+ in: "query",
96
+ required: true,
97
+ schema: {type: "string"}
98
+ }
99
+ ],
100
+ responses: {
101
+ "302" => {description: "Redirects to callback URL with token or error"}
102
+ }
103
+ }
104
+ }
105
+ ) do |ctx|
42
106
  token = ctx.params[:token].to_s
43
- callback_url = fetch_value(ctx.query, "callbackURL") || "/error"
107
+ callback_url = fetch_value(ctx.query, "callbackURL")
44
108
  validate_callback_url!(ctx.context, callback_url)
45
109
  verification = ctx.context.internal_adapter.find_verification_value("reset-password:#{token}")
46
110
 
@@ -53,7 +117,33 @@ module BetterAuth
53
117
  end
54
118
 
55
119
  def self.reset_password
56
- Endpoint.new(path: "/reset-password", method: "POST") do |ctx|
120
+ Endpoint.new(
121
+ path: "/reset-password",
122
+ method: "POST",
123
+ body_schema: request_body_schema(
124
+ required_strings: %w[newPassword],
125
+ optional_strings: %w[token]
126
+ ),
127
+ query_schema: request_query_schema(optional_strings: %w[token]),
128
+ metadata: {
129
+ openapi: {
130
+ operationId: "resetPassword",
131
+ description: "Reset a password using a reset token",
132
+ requestBody: OpenAPI.json_request_body(
133
+ OpenAPI.object_schema(
134
+ {
135
+ token: {type: "string", description: "The password reset token"},
136
+ newPassword: {type: "string", description: "The new password to set"}
137
+ },
138
+ required: ["token", "newPassword"]
139
+ )
140
+ ),
141
+ responses: {
142
+ "200" => OpenAPI.json_response("Password reset successfully", OpenAPI.status_response_schema)
143
+ }
144
+ }
145
+ }
146
+ ) do |ctx|
57
147
  body = normalize_hash(ctx.body)
58
148
  token = body["token"] || fetch_value(ctx.query, "token")
59
149
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_TOKEN"]) if token.to_s.empty?
@@ -85,7 +175,27 @@ module BetterAuth
85
175
  end
86
176
 
87
177
  def self.verify_password
88
- Endpoint.new(path: "/verify-password", method: "POST") do |ctx|
178
+ Endpoint.new(
179
+ path: "/verify-password",
180
+ method: "POST",
181
+ metadata: {
182
+ openapi: {
183
+ operationId: "verifyPassword",
184
+ description: "Verify the current user's password",
185
+ requestBody: OpenAPI.json_request_body(
186
+ OpenAPI.object_schema(
187
+ {
188
+ password: {type: "string", description: "The password to verify"}
189
+ },
190
+ required: ["password"]
191
+ )
192
+ ),
193
+ responses: {
194
+ "200" => OpenAPI.json_response("Password verified", OpenAPI.status_response_schema)
195
+ }
196
+ }
197
+ }
198
+ ) do |ctx|
89
199
  session = current_session(ctx, sensitive: true)
90
200
  password = normalize_hash(ctx.body)["password"].to_s
91
201
  account = credential_account(ctx, session[:user]["id"])
@@ -179,5 +289,13 @@ module BetterAuth
179
289
 
180
290
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_CALLBACK_URL"])
181
291
  end
292
+
293
+ def self.validate_redirect_url!(context, redirect_url)
294
+ validate_callback_url!(context, redirect_url)
295
+ rescue APIError => error
296
+ raise error unless error.message == BASE_ERROR_CODES["INVALID_CALLBACK_URL"]
297
+
298
+ raise APIError.new("FORBIDDEN", message: BASE_ERROR_CODES["INVALID_REDIRECT_URL"])
299
+ end
182
300
  end
183
301
  end
@@ -3,11 +3,34 @@
3
3
  module BetterAuth
4
4
  module Routes
5
5
  def self.get_session
6
- Endpoint.new(path: "/get-session", method: "GET") do |ctx|
7
- session = current_session(ctx, allow_nil: true)
6
+ Endpoint.new(
7
+ path: "/get-session",
8
+ method: ["GET", "POST"],
9
+ metadata: {
10
+ openapi: {
11
+ operationId: "getSession",
12
+ description: "Get the current session",
13
+ responses: {
14
+ "200" => OpenAPI.json_response("Current session or null", OpenAPI.session_response_schema_pair.merge(nullable: true))
15
+ }
16
+ }
17
+ }
18
+ ) do |ctx|
19
+ defer_refresh = ctx.context.session_config[:defer_session_refresh]
20
+ if ctx.method == "POST" && !defer_refresh
21
+ raise APIError.new(
22
+ "METHOD_NOT_ALLOWED",
23
+ code: "METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED",
24
+ message: BASE_ERROR_CODES["METHOD_NOT_ALLOWED_DEFER_SESSION_REQUIRED"]
25
+ )
26
+ end
27
+
28
+ session = current_session(ctx, allow_nil: true, disable_refresh: defer_refresh && ctx.method == "GET")
8
29
  next ctx.json(nil) unless session
9
30
 
10
- ctx.json(parsed_session_response(ctx, session))
31
+ response = parsed_session_response(ctx, session)
32
+ response[:needsRefresh] = session_needs_refresh?(ctx, session[:session]) if defer_refresh && ctx.method == "GET"
33
+ ctx.json(response)
11
34
  rescue APIError
12
35
  raise
13
36
  rescue => error
@@ -17,7 +40,22 @@ module BetterAuth
17
40
  end
18
41
 
19
42
  def self.list_sessions
20
- Endpoint.new(path: "/list-sessions", method: "GET") do |ctx|
43
+ Endpoint.new(
44
+ path: "/list-sessions",
45
+ method: "GET",
46
+ metadata: {
47
+ openapi: {
48
+ operationId: "listSessions",
49
+ description: "List active sessions for the current user",
50
+ responses: {
51
+ "200" => OpenAPI.json_response(
52
+ "Active sessions",
53
+ {type: "array", items: {type: "object", "$ref": "#/components/schemas/Session"}}
54
+ )
55
+ }
56
+ }
57
+ }
58
+ ) do |ctx|
21
59
  session = current_session(ctx)
22
60
  sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
23
61
  active = sessions
@@ -29,8 +67,22 @@ module BetterAuth
29
67
  end
30
68
 
31
69
  def self.update_session
32
- Endpoint.new(path: "/update-session", method: "POST") do |ctx|
33
- session = current_session(ctx, sensitive: true)
70
+ Endpoint.new(
71
+ path: "/update-session",
72
+ method: "POST",
73
+ metadata: {
74
+ openapi: {
75
+ operationId: "updateSession",
76
+ description: "Update the current session",
77
+ responses: {
78
+ "200" => OpenAPI.json_response("Updated session", OpenAPI.session_response_schema_pair)
79
+ }
80
+ }
81
+ }
82
+ ) do |ctx|
83
+ session = current_session(ctx)
84
+ raise APIError.new("BAD_REQUEST", code: "BODY_MUST_BE_AN_OBJECT", message: BASE_ERROR_CODES["BODY_MUST_BE_AN_OBJECT"]) unless ctx.body.is_a?(Hash)
85
+
34
86
  body = Routes.parse_declared_input(ctx, "session", ctx.body, allowed_base: [])
35
87
  raise APIError.new("BAD_REQUEST", message: "No fields to update") if body.empty?
36
88
 
@@ -38,12 +90,32 @@ module BetterAuth
38
90
  updated = ctx.context.internal_adapter.update_session(session[:session]["token"], update)
39
91
  merged = session[:session].merge(updated || update)
40
92
  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]}))
93
+ ctx.json({session: Schema.parse_output(ctx.context.options, "session", merged)})
42
94
  end
43
95
  end
44
96
 
45
97
  def self.revoke_session
46
- Endpoint.new(path: "/revoke-session", method: "POST") do |ctx|
98
+ Endpoint.new(
99
+ path: "/revoke-session",
100
+ method: "POST",
101
+ metadata: {
102
+ openapi: {
103
+ operationId: "revokeSession",
104
+ description: "Revoke a session by token",
105
+ requestBody: OpenAPI.json_request_body(
106
+ OpenAPI.object_schema(
107
+ {
108
+ token: {type: "string", description: "The session token to revoke"}
109
+ },
110
+ required: ["token"]
111
+ )
112
+ ),
113
+ responses: {
114
+ "200" => OpenAPI.json_response("Session revoked", OpenAPI.status_response_schema)
115
+ }
116
+ }
117
+ }
118
+ ) do |ctx|
47
119
  session = current_session(ctx, sensitive: true)
48
120
  body = normalize_hash(ctx.body)
49
121
  token = body["token"].to_s
@@ -58,7 +130,19 @@ module BetterAuth
58
130
  end
59
131
 
60
132
  def self.revoke_sessions
61
- Endpoint.new(path: "/revoke-sessions", method: "POST") do |ctx|
133
+ Endpoint.new(
134
+ path: "/revoke-sessions",
135
+ method: "POST",
136
+ metadata: {
137
+ openapi: {
138
+ operationId: "revokeSessions",
139
+ description: "Revoke all sessions for the current user",
140
+ responses: {
141
+ "200" => OpenAPI.json_response("Sessions revoked", OpenAPI.status_response_schema)
142
+ }
143
+ }
144
+ }
145
+ ) do |ctx|
62
146
  session = current_session(ctx, sensitive: true)
63
147
  ctx.context.internal_adapter.delete_sessions(session[:user]["id"])
64
148
  Cookies.delete_session_cookie(ctx)
@@ -67,7 +151,19 @@ module BetterAuth
67
151
  end
68
152
 
69
153
  def self.revoke_other_sessions
70
- Endpoint.new(path: "/revoke-other-sessions", method: "POST") do |ctx|
154
+ Endpoint.new(
155
+ path: "/revoke-other-sessions",
156
+ method: "POST",
157
+ metadata: {
158
+ openapi: {
159
+ operationId: "revokeOtherSessions",
160
+ description: "Revoke all sessions except the current one",
161
+ responses: {
162
+ "200" => OpenAPI.json_response("Other sessions revoked", OpenAPI.status_response_schema)
163
+ }
164
+ }
165
+ }
166
+ ) do |ctx|
71
167
  session = current_session(ctx, sensitive: true)
72
168
  current_token = session[:session]["token"]
73
169
  sessions = ctx.context.internal_adapter.list_sessions(session[:user]["id"])
@@ -81,11 +177,11 @@ module BetterAuth
81
177
  end
82
178
  end
83
179
 
84
- def self.current_session(ctx, allow_nil: false, sensitive: false)
180
+ def self.current_session(ctx, allow_nil: false, sensitive: false, fresh: false, disable_refresh: false)
85
181
  data = Session.find_current(
86
182
  ctx,
87
183
  disable_cookie_cache: truthy_query?(ctx.query, "disableCookieCache"),
88
- disable_refresh: truthy_query?(ctx.query, "disableRefresh"),
184
+ disable_refresh: disable_refresh || truthy_query?(ctx.query, "disableRefresh"),
89
185
  sensitive: sensitive
90
186
  )
91
187
  return nil if allow_nil && data.nil?
@@ -93,7 +189,7 @@ module BetterAuth
93
189
  raise APIError.new("UNAUTHORIZED") unless data
94
190
 
95
191
  session = stringify_keys(data[:session] || data["session"])
96
- ensure_fresh_session!(ctx, session) if sensitive
192
+ ensure_fresh_session!(ctx, session) if fresh
97
193
 
98
194
  {
99
195
  session: session,
@@ -101,6 +197,15 @@ module BetterAuth
101
197
  }
102
198
  end
103
199
 
200
+ def self.request_only_session(ctx)
201
+ session = current_session(ctx, allow_nil: true)
202
+ if !session && (ctx.request || !ctx.headers.empty?)
203
+ raise APIError.new("UNAUTHORIZED", code: "UNAUTHORIZED", message: "Unauthorized")
204
+ end
205
+
206
+ session
207
+ end
208
+
104
209
  def self.ensure_fresh_session!(ctx, session)
105
210
  fresh_age = ctx.context.session_config[:fresh_age].to_i
106
211
  return if fresh_age.zero?
@@ -111,6 +216,16 @@ module BetterAuth
111
216
  raise APIError.new("FORBIDDEN", code: "SESSION_NOT_FRESH", message: BASE_ERROR_CODES.fetch("SESSION_NOT_FRESH"))
112
217
  end
113
218
 
219
+ def self.session_needs_refresh?(ctx, session)
220
+ return false if truthy_query?(ctx.query, "disableRefresh") || ctx.context.session_config[:disable_session_refresh]
221
+
222
+ update_age = ctx.context.session_config[:update_age].to_i
223
+ return true if update_age.zero?
224
+
225
+ updated_at = normalize_time(session["updatedAt"])
226
+ updated_at && updated_at + update_age <= Time.now
227
+ end
228
+
114
229
  def self.normalize_time(value)
115
230
  return value if value.is_a?(Time)
116
231
  return nil if value.nil? || value.to_s.empty?
@@ -8,17 +8,39 @@ module BetterAuth
8
8
  Endpoint.new(
9
9
  path: "/sign-in/email",
10
10
  method: "POST",
11
+ body_schema: request_body_schema(required_strings: %w[email password]),
11
12
  metadata: {
12
13
  allowed_media_types: [
13
14
  "application/x-www-form-urlencoded",
14
15
  "application/json"
15
- ]
16
+ ],
17
+ openapi: {
18
+ operationId: "signInEmail",
19
+ description: "Sign in with email and password",
20
+ requestBody: OpenAPI.json_request_body(
21
+ OpenAPI.object_schema(
22
+ {
23
+ email: {type: "string", description: "Email of the user"},
24
+ password: {type: "string", description: "Password of the user"},
25
+ callbackURL: {type: ["string", "null"], description: "Callback URL to use as a redirect for email verification"},
26
+ rememberMe: {type: ["boolean", "null"], default: true, description: "If this is false, the session will not be remembered. Default is `true`."}
27
+ },
28
+ required: ["email", "password"]
29
+ )
30
+ ),
31
+ responses: {
32
+ "200" => OpenAPI.json_response(
33
+ "Success - Returns either session details or redirect URL",
34
+ OpenAPI.session_response_schema(description: "Session response when idToken is provided", nullable_url: true)
35
+ )
36
+ }
37
+ }
16
38
  }
17
39
  ) do |ctx|
18
40
  options = ctx.context.options
19
41
  email_config = options.email_and_password
20
42
  if email_config[:enabled] != true
21
- raise APIError.new("BAD_REQUEST", message: "Email and password is not enabled")
43
+ raise APIError.new("BAD_REQUEST", code: "EMAIL_PASSWORD_DISABLED", message: BASE_ERROR_CODES["EMAIL_PASSWORD_DISABLED"])
22
44
  end
23
45
 
24
46
  body = normalize_hash(ctx.body)
@@ -27,6 +49,8 @@ module BetterAuth
27
49
  callback_url = body["callbackURL"] || body["callbackUrl"] || body["callback_url"]
28
50
  remember_me = body.key?("rememberMe") ? body["rememberMe"] : body["remember_me"]
29
51
 
52
+ validate_auth_callback_url!(ctx.context, callback_url, "callbackURL")
53
+
30
54
  unless EMAIL_PATTERN.match?(email)
31
55
  raise APIError.new("BAD_REQUEST", message: BASE_ERROR_CODES["INVALID_EMAIL"])
32
56
  end
@@ -3,7 +3,19 @@
3
3
  module BetterAuth
4
4
  module Routes
5
5
  def self.sign_out
6
- Endpoint.new(path: "/sign-out", method: "POST") do |ctx|
6
+ Endpoint.new(
7
+ path: "/sign-out",
8
+ method: "POST",
9
+ metadata: {
10
+ openapi: {
11
+ operationId: "signOut",
12
+ description: "Sign out the current session",
13
+ responses: {
14
+ "200" => OpenAPI.json_response("Successfully signed out", OpenAPI.success_response_schema)
15
+ }
16
+ }
17
+ }
18
+ ) do |ctx|
7
19
  token_cookie = ctx.context.auth_cookies[:session_token]
8
20
  token = ctx.get_signed_cookie(token_cookie.name, ctx.context.secret)
9
21
  ctx.context.internal_adapter.delete_session(token) if token