better_auth-sinatra 0.6.2 → 0.7.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a219e70b396e525478a19501b6500283665608b0ef8b7ff8c30beb9c3b896d7d
4
- data.tar.gz: 357688a02a14e92e2993ff766bf908d5b79c947763cbf56b7ebd71484478a4b3
3
+ metadata.gz: 14d141007853cbd143bb23e5ac0557790a28bfb65e386b340e545d9cd0cb55c0
4
+ data.tar.gz: 6b21abf9d5f9b8e8633c83f9255e7861818e310bf0c614b1368e4530460eeaaa
5
5
  SHA512:
6
- metadata.gz: fe646f5da3b7178241ca725bded50719f97df0d1f671f1705d75a358b1494b8089529b66167cf69cc24afc272a3f9eeaa0283bd128ac597046a08bca20fef3de
7
- data.tar.gz: ab7235cc0f4acfc588c4e433107888897e49a0c6c811bfa1409d3921d2e0cc101e9fa855527a92d8cd94a102b5db2371d1ab06997bdb2c48152873fd5ad67177
6
+ metadata.gz: a1766d030b0eb07b9e0ad84896b8b0d8efe4d3f2f01d9eb46a95e5fdb9a2f2e629904d924108a970d4fb70308e68e5cd6480832789e2edc5bd4a5d1a5167899f
7
+ data.tar.gz: 48323869049ff91d30ad0f0b465e89c8802d88b5f3d92df37f1d08c9f401e9fcfb5ffdc38fcf2d9a828296739a913dba3171e71bd00667a852111b1a98a9707d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ ## 0.7.0 - 2026-05-05
6
+
7
+ - Fixed auth dispatch when Rack splits mounted paths across `SCRIPT_NAME` and `PATH_INFO`.
8
+ - Rejected `better_auth at: "/"` to avoid capturing every Sinatra route.
9
+ - Stopped swallowing real migration bookkeeping query errors while preserving empty-state behavior for missing schema tables.
10
+ - Split simple single-line multi-statement SQL migration files.
11
+ - Passed versioned `secrets` through Sinatra configuration to core auth.
12
+ - Warned when `better_auth` is configured more than once on the same Sinatra app class.
13
+ - Returned JSON-shaped 401 responses from `require_authentication` when JSON is preferred.
14
+ - Removed duplicate Rake task wiring and clarified `better_auth:routes` output.
15
+ - Documented mount path, Rack nesting, SQL migration, and helper auth caveats.
16
+
3
17
  ## 0.1.1 - 2026-04-29
4
18
 
5
19
  - Fixed mounted base-path propagation when creating the Sinatra auth instance.
data/README.md CHANGED
@@ -39,9 +39,26 @@ class App < Sinatra::Base
39
39
  end
40
40
  ```
41
41
 
42
- The extension mounts the core Rack app at `/api/auth` by default. The core app
43
- still owns routes such as `/ok`, `/sign-up/email`, `/sign-in/email`, and plugin
44
- endpoints.
42
+ The extension mounts the core Rack app at `/api/auth` by default. The mount path
43
+ cannot be `/`, because that would capture every Sinatra route before the app can
44
+ handle it. The core app still owns routes such as `/ok`, `/sign-up/email`,
45
+ `/sign-in/email`, and plugin endpoints.
46
+
47
+ `better_auth at:` sets the path prefix that Better Auth uses as its core
48
+ `base_path`. The adapter supports two common Rack mount patterns:
49
+
50
+ - Natural Sinatra nesting: mount the Sinatra app under a parent `Rack::URLMap`,
51
+ for example at `/api`, and configure `better_auth at: "/auth"`. Auth routes
52
+ are available at `/api/auth/*`.
53
+ - Shared auth mount: mount the Sinatra app itself at the same path as auth, for
54
+ example `/api/auth`, and configure `better_auth at: "/api/auth"`. The adapter
55
+ reconstructs the logical path from Rack `SCRIPT_NAME` and `PATH_INFO`.
56
+
57
+ When using reverse proxies, `Rack::URLMap`, or another parent app, make sure the
58
+ `PATH_INFO` visible to Sinatra still aligns with the configured auth prefix.
59
+ `SCRIPT_NAME` handling depends on the Rack server and mount stack, so verify
60
+ redirect URLs and cookie paths in integration tests when mounting below a
61
+ sub-path.
45
62
 
46
63
  ## Helpers
47
64
 
@@ -51,6 +68,14 @@ endpoints.
51
68
  - `require_authentication`
52
69
 
53
70
  `require_authentication` halts with `401` when no Better Auth user is present.
71
+ Requests that prefer JSON receive the same JSON error shape used by the core
72
+ router.
73
+
74
+ Sinatra helpers resolve sessions through the core `get-session` API path, so
75
+ Better Auth plugin hooks that affect session lookup, such as the bearer plugin,
76
+ run for `current_user` and `require_authentication`. Helper session lookup may
77
+ emit Better Auth `Set-Cookie` headers when stale cookies need to be cleared or
78
+ session cookies need to be refreshed.
54
79
 
55
80
  ## Rake Tasks
56
81
 
@@ -78,6 +103,11 @@ Sinatra does not include a Rails-style database layer or migration command.
78
103
  This adapter uses Better Auth core SQL adapters for migrations. Set
79
104
  `BETTER_AUTH_DIALECT=postgres`, `mysql`, or `sqlite` when generating SQL.
80
105
 
106
+ Generated SQL should keep one statement per line ending with `;`. The migration
107
+ runner handles simple single-line multi-statement files, but hand-edited SQL
108
+ with semicolons inside string literals can confuse the splitter. DDL rollback
109
+ behavior depends on the database, so back up production data before migrating.
110
+
81
111
  ActiveRecord-backed Sinatra migrations are not supported yet. Apps that already
82
112
  use `sinatra-activerecord` can still configure Better Auth manually, but the v1
83
113
  Rake tasks do not emit ActiveRecord migrations.
@@ -8,6 +8,7 @@ module BetterAuth
8
8
  base_url
9
9
  base_path
10
10
  secret
11
+ secrets
11
12
  database
12
13
  plugins
13
14
  trusted_origins
@@ -11,6 +11,13 @@ module BetterAuth
11
11
  module ClassMethods
12
12
  def better_auth(at: BetterAuth::Configuration::DEFAULT_BASE_PATH, auth: nil, **overrides)
13
13
  mount_path = normalize_better_auth_mount_path(at)
14
+ if mount_path == "/"
15
+ raise ArgumentError,
16
+ "better_auth mount path cannot be '/' (it would capture every request). " \
17
+ "Use a prefix such as #{BetterAuth::Configuration::DEFAULT_BASE_PATH.inspect}."
18
+ end
19
+ warn "[better_auth-sinatra] better_auth is already configured for this app; the new configuration will be appended." if respond_to?(:better_auth_auth)
20
+
14
21
  config = BetterAuth::Sinatra.configuration.copy
15
22
  yield config if block_given?
16
23
  config.base_path = mount_path
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+
3
5
  module BetterAuth
4
6
  module Sinatra
5
7
  module Helpers
@@ -20,11 +22,29 @@ module BetterAuth
20
22
  def require_authentication
21
23
  return true if authenticated?
22
24
 
25
+ if prefers_json_response?
26
+ error = BetterAuth::APIError.new("UNAUTHORIZED")
27
+ halt 401, {"content-type" => "application/json"}, JSON.generate(error.to_h)
28
+ end
29
+
23
30
  halt 401, ""
24
31
  end
25
32
 
26
33
  private
27
34
 
35
+ def prefers_json_response?
36
+ accept = request.env["HTTP_ACCEPT"].to_s
37
+ return false if accept.empty? || accept == "*/*"
38
+
39
+ preferred = request.preferred_type(["application/json", "text/html"]) if request.respond_to?(:preferred_type)
40
+ return preferred.to_s == "application/json" if preferred
41
+
42
+ accept.split(",").any? do |entry|
43
+ media_type = entry.split(";", 2).first.to_s.strip
44
+ media_type == "application/json" || media_type.end_with?("+json")
45
+ end
46
+ end
47
+
28
48
  def better_auth_session_data
29
49
  return request.env["better_auth.session"] if request.env.key?("better_auth.session")
30
50
 
@@ -33,18 +53,35 @@ module BetterAuth
33
53
 
34
54
  def resolve_better_auth_session
35
55
  auth = better_auth_auth
36
- auth.context.prepare_for_request!(request) if auth.context.respond_to?(:prepare_for_request!)
37
- context = BetterAuth::Endpoint::Context.new(
38
- path: request.path_info,
39
- method: request.request_method,
40
- query: request.GET,
41
- body: {},
42
- params: params,
43
- headers: {"cookie" => request.env["HTTP_COOKIE"]},
44
- context: auth.context,
45
- request: request
56
+ result = auth.api.get_session(
57
+ headers: better_auth_request_headers,
58
+ return_headers: true
46
59
  )
47
- BetterAuth::Session.find_current(context, disable_refresh: true)
60
+ apply_better_auth_response_headers(result[:headers] || result["headers"] || {})
61
+ result[:response] || result["response"]
62
+ end
63
+
64
+ def better_auth_request_headers
65
+ request.env.each_with_object({}) do |(key, value), headers|
66
+ case key
67
+ when "CONTENT_TYPE"
68
+ headers["content-type"] = value if value
69
+ when "CONTENT_LENGTH"
70
+ headers["content-length"] = value if value
71
+ else
72
+ next unless key.start_with?("HTTP_")
73
+
74
+ headers[key.delete_prefix("HTTP_").downcase.tr("_", "-")] = value
75
+ end
76
+ end
77
+ end
78
+
79
+ def apply_better_auth_response_headers(headers)
80
+ set_cookie = headers["set-cookie"] || headers["Set-Cookie"] || headers[:set_cookie]
81
+ return if set_cookie.to_s.empty?
82
+
83
+ existing = response.headers["set-cookie"].to_s
84
+ response.headers["set-cookie"] = [existing, set_cookie.to_s].reject(&:empty?).join("\n")
48
85
  end
49
86
 
50
87
  def better_auth_auth
@@ -6,6 +6,13 @@ module BetterAuth
6
6
  module Sinatra
7
7
  module Migration
8
8
  DEFAULT_MIGRATIONS_PATH = "db/better_auth/migrate"
9
+ MISSING_MIGRATIONS_TABLE_MESSAGES = [
10
+ /no such table/i,
11
+ /relation .* does not exist/i,
12
+ /table .* doesn't exist/i,
13
+ /undefined table/i,
14
+ /invalid object name/i
15
+ ].freeze
9
16
 
10
17
  class UnsupportedAdapterError < StandardError; end
11
18
 
@@ -83,7 +90,11 @@ module BetterAuth
83
90
  def applied_migrations(connection, dialect)
84
91
  rows = execute_sql(connection, "SELECT #{quote("version", dialect)} FROM #{quote("better_auth_schema_migrations", dialect)};")
85
92
  Array(rows).map { |row| row["version"] || row[:version] }
86
- rescue
93
+ rescue UnsupportedAdapterError
94
+ raise
95
+ rescue => error
96
+ raise error unless missing_schema_migrations_table?(error)
97
+
87
98
  []
88
99
  end
89
100
 
@@ -109,7 +120,112 @@ module BetterAuth
109
120
  end
110
121
 
111
122
  def statements(sql)
112
- sql.split(/;\s*$/).map(&:strip).reject(&:empty?)
123
+ normalized = sql.to_s.gsub("\r\n", "\n").strip
124
+ return [] if normalized.empty?
125
+
126
+ split_sql_statements(normalized)
127
+ end
128
+
129
+ def split_sql_statements(sql)
130
+ output = []
131
+ buffer = +""
132
+ index = 0
133
+ quote = nil
134
+ line_comment = false
135
+ block_comment = false
136
+ dollar_tag = nil
137
+
138
+ while index < sql.length
139
+ state = {
140
+ quote: quote,
141
+ line_comment: line_comment,
142
+ block_comment: block_comment,
143
+ dollar_tag: dollar_tag
144
+ }
145
+ index, quote, line_comment, block_comment, dollar_tag = scan_sql_character(sql, buffer, index, state, output)
146
+ end
147
+
148
+ tail = buffer.strip
149
+ output << tail unless tail.empty?
150
+ output
151
+ end
152
+
153
+ def scan_sql_character(sql, buffer, index, state, output)
154
+ char = sql[index]
155
+ next_char = sql[index + 1]
156
+ quote = state[:quote]
157
+ line_comment = state[:line_comment]
158
+ block_comment = state[:block_comment]
159
+ dollar_tag = state[:dollar_tag]
160
+
161
+ return scan_line_comment(buffer, index, char) + [quote, false, block_comment, dollar_tag] if line_comment && char == "\n"
162
+ return scan_line_comment(buffer, index, char) + [quote, true, block_comment, dollar_tag] if line_comment
163
+ return scan_block_comment(buffer, index, char, next_char, quote, line_comment, dollar_tag) if block_comment
164
+ return scan_dollar_quote(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag) if dollar_tag
165
+ return scan_quoted_string(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag) if quote
166
+ return [index + 2, quote, true, block_comment, dollar_tag].tap { buffer << char << next_char } if char == "-" && next_char == "-"
167
+ return [index + 2, quote, line_comment, true, dollar_tag].tap { buffer << char << next_char } if char == "/" && next_char == "*"
168
+
169
+ tag = dollar_quote_tag_at(sql, index)
170
+ return [index + tag.length, quote, line_comment, block_comment, tag].tap { buffer << tag } if tag
171
+ return [index + 1, char, line_comment, block_comment, dollar_tag].tap { buffer << char } if char == "'" || char == "\""
172
+
173
+ if char == ";"
174
+ statement = buffer.strip
175
+ output << statement unless statement.empty?
176
+ buffer.clear
177
+ return [index + 1, quote, line_comment, block_comment, dollar_tag]
178
+ end
179
+
180
+ buffer << char
181
+ [index + 1, quote, line_comment, block_comment, dollar_tag]
182
+ end
183
+
184
+ def scan_line_comment(buffer, index, char)
185
+ buffer << char
186
+ [index + 1]
187
+ end
188
+
189
+ def scan_block_comment(buffer, index, char, next_char, quote, line_comment, dollar_tag)
190
+ buffer << char
191
+ if char == "*" && next_char == "/"
192
+ buffer << next_char
193
+ [index + 2, quote, line_comment, false, dollar_tag]
194
+ else
195
+ [index + 1, quote, line_comment, true, dollar_tag]
196
+ end
197
+ end
198
+
199
+ def scan_dollar_quote(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag)
200
+ if sql[index, dollar_tag.length] == dollar_tag
201
+ buffer << dollar_tag
202
+ [index + dollar_tag.length, quote, line_comment, block_comment, nil]
203
+ else
204
+ buffer << char
205
+ [index + 1, quote, line_comment, block_comment, dollar_tag]
206
+ end
207
+ end
208
+
209
+ def scan_quoted_string(sql, buffer, index, char, quote, line_comment, block_comment, dollar_tag)
210
+ buffer << char
211
+ if char == quote && sql[index + 1] == quote
212
+ buffer << sql[index + 1]
213
+ [index + 2, quote, line_comment, block_comment, dollar_tag]
214
+ elsif char == quote
215
+ [index + 1, nil, line_comment, block_comment, dollar_tag]
216
+ else
217
+ [index + 1, quote, line_comment, block_comment, dollar_tag]
218
+ end
219
+ end
220
+
221
+ def dollar_quote_tag_at(sql, index)
222
+ match = sql[index..]&.match(/\A\$[A-Za-z_][A-Za-z0-9_]*\$|\A\$\$/)
223
+ match&.[](0)
224
+ end
225
+
226
+ def missing_schema_migrations_table?(error)
227
+ message = error.message.to_s
228
+ MISSING_MIGRATIONS_TABLE_MESSAGES.any? { |pattern| message.match?(pattern) }
113
229
  end
114
230
 
115
231
  def quote(identifier, dialect)
@@ -10,9 +10,12 @@ module BetterAuth
10
10
  end
11
11
 
12
12
  def call(env)
13
- return auth.call(env) if mounted_path?(env["PATH_INFO"])
13
+ return @app.call(env) unless mount_matches?(env)
14
14
 
15
- @app.call(env)
15
+ rewritten_path = mounted_path_info(env)
16
+ next_env = env.merge("PATH_INFO" => rewritten_path)
17
+ next_env["SCRIPT_NAME"] = "" if shared_mount_rewrite?(env, rewritten_path)
18
+ auth.call(next_env)
16
19
  end
17
20
 
18
21
  private
@@ -23,11 +26,39 @@ module BetterAuth
23
26
  @auth
24
27
  end
25
28
 
26
- def mounted_path?(path)
27
- normalized = normalize_path(path)
28
- return true if @mount_path == "/"
29
+ def mount_matches?(env)
30
+ return false if @mount_path == "/"
29
31
 
30
- normalized == @mount_path || normalized.start_with?("#{@mount_path}/")
32
+ path_info = normalize_path(env["PATH_INFO"])
33
+ return true if path_info == @mount_path || path_info.start_with?("#{@mount_path}/")
34
+
35
+ full = full_request_path(env)
36
+ full == @mount_path || full.start_with?("#{@mount_path}/")
37
+ end
38
+
39
+ def full_request_path(env)
40
+ script = env.fetch("SCRIPT_NAME", "").to_s
41
+ path = env.fetch("PATH_INFO", "").to_s
42
+ normalize_path("#{script}#{path}")
43
+ end
44
+
45
+ def mounted_path_info(env)
46
+ path_info = normalize_path(env["PATH_INFO"])
47
+ return path_info if path_info == @mount_path || path_info.start_with?("#{@mount_path}/")
48
+
49
+ script_name = normalize_path(env["SCRIPT_NAME"])
50
+ prefix = (script_name == "/") ? @mount_path : script_name
51
+ return path_info if path_info == prefix || path_info.start_with?("#{prefix}/")
52
+
53
+ normalize_path("#{prefix}/#{path_info.delete_prefix("/")}")
54
+ end
55
+
56
+ def shared_mount_rewrite?(env, rewritten_path)
57
+ script_name = normalize_path(env["SCRIPT_NAME"])
58
+ original_path = normalize_path(env["PATH_INFO"])
59
+ script_name != "/" &&
60
+ !original_path.start_with?("#{@mount_path}/") &&
61
+ rewritten_path.start_with?("#{@mount_path}/")
31
62
  end
32
63
 
33
64
  def normalize_path(path)
@@ -30,11 +30,6 @@ namespace :better_auth do
30
30
  end
31
31
  end
32
32
 
33
- desc "Create the Better Auth SQL migration"
34
- task "generate:migration" do
35
- Rake::Task["better_auth:generate:migration"].invoke
36
- end
37
-
38
33
  desc "Run pending Better Auth SQL migrations"
39
34
  task :migrate do
40
35
  BetterAuth::Sinatra.load_app_config
@@ -44,6 +39,8 @@ namespace :better_auth do
44
39
  desc "Print Better Auth Sinatra mount information"
45
40
  task :routes do
46
41
  BetterAuth::Sinatra.load_app_config
47
- puts "#{BetterAuth::Sinatra.configuration.base_path}/* -> BetterAuth.auth"
42
+ mount_path = BetterAuth::Sinatra.configuration.base_path
43
+ puts "#{mount_path}/* -> BetterAuth.auth"
44
+ puts "Core routes are handled by Better Auth; use the OpenAPI plugin or HTTP API docs for endpoint details."
48
45
  end
49
46
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module Sinatra
5
- VERSION = "0.6.2"
5
+ VERSION = "0.7.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-sinatra
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.2
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala