better_auth 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: 764767c87a3ba64039bbb591a49528e87b1dcd5db3b5f591dc5e604f1eda354c
4
- data.tar.gz: b8dbf1bbc4248e27f5439d0acc06e77e68e53e9f9d4d82261ee9b4b6030efbb0
3
+ metadata.gz: b62f1d0f1e0fc4c774ab2d4907f9229f4cbcae46599cf8b72c03a4d5984aafcf
4
+ data.tar.gz: a02c36229e3d829a1d3c24d64e9e560fc9f592e9ad4ad915bcd6a962fd0f633a
5
5
  SHA512:
6
- metadata.gz: 4ffea2315dbe5ca3322e58ec2baaf85d968464807135194c3472ea529ce7ceab360e4d3dbbf670dcce42ac3ffd982392478b02a8552c67c4856a19cecb862bb3
7
- data.tar.gz: daebbf60273a3db8562ac985639ec3554435a81633d713602087e1450692b352d128185eeec1058803c1227f652adf3321c11ca4ab8c1e3edd1876c8aeab774c
6
+ metadata.gz: 50d18b3901b1e7292b3b4089e1eb388dadb16836d88a6d8af35ecb8061005dc062712010865b0f131b768538fbc2e64042edfb5b652feaad979f93d03f6ca7fa
7
+ data.tar.gz: ce679e88e9e12dd1d36aab49a20bb6d2a8e55e95de6eeccbdc3b4bd3736924d9496fb5148e874132f6f07efb914035ed6ce0cd200216355390bfe86b016c6ff8
data/CHANGELOG.md CHANGED
@@ -7,7 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.0] - 2026-05-05
11
+
12
+ ### Added
13
+
14
+ - Completed OpenAPI support with upstream v1.6.9 base-route schema parity, `/ok` and `/error` documentation, richer helper-generated schemas, plugin endpoint metadata coverage, and Scalar reference configuration parity.
15
+ - Added shared join query handling for adapter-backed relation loading.
16
+
17
+ ### Changed
18
+
10
19
  - Modernized the MCP plugin to use OAuth Provider-style client, token, metadata, and protected-resource behavior while keeping legacy MCP routes as aliases.
20
+ - Changed OAuth HS256 ID token signing to use non-public key material; existing ID tokens signed only with the public client id will no longer validate.
21
+
22
+ ### Fixed
23
+
24
+ - Fixed OAuth refresh token rotation to reject refresh tokens presented by a different authenticated client.
25
+ - Fixed OAuth client-secret verification to use constant-time comparison for encrypted and custom-hashed storage modes.
26
+ - Hardened router and OAuth protocol behavior around path handling, issuer metadata, and public route coverage.
11
27
 
12
28
  ## [0.4.0] - 2026-04-30
13
29
 
data/README.md CHANGED
@@ -378,6 +378,18 @@ The gem is available as open source under the terms of the [MIT License](https:/
378
378
 
379
379
  ## Security
380
380
 
381
+ ### Trusted origins
382
+
383
+ `trusted_origins` is merged with the resolved application origin and deployment
384
+ configuration rather than acting as an adapter-local CORS switch. Upstream
385
+ Better Auth also incorporates environment-driven origins and dynamic
386
+ configuration, so an explicit empty array should not be assumed to mean "reject
387
+ every browser origin" unless the deployed core configuration documents that full
388
+ merge contract. Configure concrete origins for each environment and keep browser
389
+ CORS headers in the host Rack stack or reverse proxy. See
390
+ [`host-app-responsibilities.md`](../../.docs/features/host-app-responsibilities.md)
391
+ for the boundary between origin validation, CORS, and CSRF ownership.
392
+
381
393
  If you discover a security vulnerability within Better Auth Ruby, please send an e-mail to [security@openparcel.dev](mailto:security@openparcel.dev).
382
394
 
383
395
  All reports will be promptly addressed, and you'll be credited accordingly.
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module Adapters
5
+ module JoinSupport
6
+ private
7
+
8
+ def normalized_join(model, join)
9
+ return {} unless join
10
+
11
+ join.each_with_object({}) do |(join_model, config), result|
12
+ join_model = join_model.to_s
13
+ result[join_model] = normalize_join_config(model.to_s, join_model, config)
14
+ end
15
+ end
16
+
17
+ def normalize_join_config(model, join_model, config)
18
+ if config.is_a?(Hash) && (config.key?(:on) || config.key?("on"))
19
+ on = config[:on] || config["on"]
20
+ from = storage_key(fetch_key(on, :from))
21
+ to = storage_key(fetch_key(on, :to))
22
+ relation = config[:relation] || config["relation"]
23
+ limit = config[:limit] || config["limit"]
24
+ return {from: from, to: to, relation: relation, limit: limit, unique: unique_join_field?(join_model, to)}
25
+ end
26
+
27
+ inferred = inferred_join_config(model, join_model)
28
+ if config.is_a?(Hash)
29
+ relation = config[:relation] || config["relation"]
30
+ limit = config[:limit] || config["limit"]
31
+ inferred = inferred.merge(relation: relation) if relation
32
+ inferred = inferred.merge(limit: limit) if limit
33
+ end
34
+ inferred
35
+ end
36
+
37
+ def reference_model_matches?(attributes, model)
38
+ reference = attributes[:references]
39
+ return false unless reference
40
+
41
+ reference_model = reference[:model] || reference["model"]
42
+ reference_model.to_s == model.to_s || reference_model.to_s == table_for(model)
43
+ end
44
+
45
+ def unique_join_field?(model, field)
46
+ field = storage_key(field)
47
+ field == "id" || schema_for(model).fetch(:fields).dig(field, :unique) == true
48
+ end
49
+
50
+ def collection_join?(model, join)
51
+ normalized_join(model, join).any? do |_join_model, config|
52
+ if config.key?(:relation)
53
+ config[:relation] != "one-to-one" && config[:unique] != true
54
+ elsif config.key?(:collection)
55
+ config[:collection] == true
56
+ else
57
+ false
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -172,13 +172,13 @@ module BetterAuth
172
172
  when "ne"
173
173
  current != comparable
174
174
  when "gt"
175
- !comparable.nil? && current > comparable
175
+ !current.nil? && !comparable.nil? && current > comparable
176
176
  when "gte"
177
- !comparable.nil? && current >= comparable
177
+ !current.nil? && !comparable.nil? && current >= comparable
178
178
  when "lt"
179
- !comparable.nil? && current < comparable
179
+ !current.nil? && !comparable.nil? && current < comparable
180
180
  when "lte"
181
- !comparable.nil? && current <= comparable
181
+ !current.nil? && !comparable.nil? && current <= comparable
182
182
  else
183
183
  current == comparable
184
184
  end
@@ -7,6 +7,8 @@ require "time"
7
7
  module BetterAuth
8
8
  module Adapters
9
9
  class SQL < Base
10
+ include JoinSupport
11
+
10
12
  attr_reader :connection, :dialect
11
13
 
12
14
  def initialize(options, connection:, dialect:)
@@ -191,35 +193,6 @@ module BetterAuth
191
193
  end.join
192
194
  end
193
195
 
194
- def normalized_join(model, join)
195
- return {} unless join
196
-
197
- join.each_with_object({}) do |(join_model, config), result|
198
- join_model = join_model.to_s
199
- result[join_model] = normalize_join_config(model.to_s, join_model, config)
200
- end
201
- end
202
-
203
- def normalize_join_config(model, join_model, config)
204
- if config.is_a?(Hash) && (config.key?(:on) || config.key?("on"))
205
- on = config[:on] || config["on"]
206
- from = storage_key(fetch_key(on, :from))
207
- to = storage_key(fetch_key(on, :to))
208
- relation = config[:relation] || config["relation"]
209
- limit = config[:limit] || config["limit"]
210
- return {from: from, to: to, relation: relation, limit: limit, unique: unique_join_field?(join_model, to)}
211
- end
212
-
213
- inferred = inferred_join_config(model, join_model)
214
- if config.is_a?(Hash)
215
- relation = config[:relation] || config["relation"]
216
- limit = config[:limit] || config["limit"]
217
- inferred = inferred.merge(relation: relation) if relation
218
- inferred = inferred.merge(limit: limit) if limit
219
- end
220
- inferred
221
- end
222
-
223
196
  def inferred_join_config(model, join_model)
224
197
  foreign_keys = schema_for(join_model).fetch(:fields).select do |_field, attributes|
225
198
  reference_model_matches?(attributes, model)
@@ -246,19 +219,6 @@ module BetterAuth
246
219
  end
247
220
  end
248
221
 
249
- def reference_model_matches?(attributes, model)
250
- reference = attributes[:references]
251
- return false unless reference
252
-
253
- reference_model = reference[:model] || reference["model"]
254
- reference_model.to_s == model.to_s || reference_model.to_s == table_for(model)
255
- end
256
-
257
- def unique_join_field?(model, field)
258
- field = storage_key(field)
259
- field == "id" || schema_for(model).fetch(:fields).dig(field, :unique) == true
260
- end
261
-
262
222
  def build_where(model, where, params)
263
223
  Array(where).each_with_index.map do |clause, index|
264
224
  field = storage_key(fetch_key(clause, :field))
@@ -277,13 +237,14 @@ module BetterAuth
277
237
  sql_operator = (operator == "not_in") ? "NOT IN" : "IN"
278
238
  "#{column} #{sql_operator} (#{placeholders})"
279
239
  when "contains", "starts_with", "ends_with"
240
+ escaped = escape_like(value)
280
241
  pattern = case operator
281
- when "starts_with" then "#{value}%"
282
- when "ends_with" then "%#{value}"
283
- else "%#{value}%"
242
+ when "starts_with" then "#{escaped}%"
243
+ when "ends_with" then "%#{escaped}"
244
+ else "%#{escaped}%"
284
245
  end
285
246
  params << pattern
286
- "#{column} LIKE #{placeholder(params.length)}"
247
+ "#{column} LIKE #{placeholder(params.length)} ESCAPE #{escape_literal}"
287
248
  else
288
249
  params << coerce_where_value(value, attributes)
289
250
  "#{column} #{sql_operator(operator)} #{placeholder(params.length)}"
@@ -379,12 +340,6 @@ module BetterAuth
379
340
  end
380
341
  end
381
342
 
382
- def collection_join?(model, join)
383
- normalized_join(model, join).any? do |_join_model, config|
384
- config[:relation] != "one-to-one" && config[:unique] != true
385
- end
386
- end
387
-
388
343
  def aggregate_collection_joins(model, records, join)
389
344
  join_config = normalized_join(model, join)
390
345
  grouped = {}
@@ -450,6 +405,14 @@ module BetterAuth
450
405
  SecureRandom.hex(16)
451
406
  end
452
407
 
408
+ def escape_like(value)
409
+ value.to_s.gsub(/[\\%_]/) { |match| "\\#{match}" }
410
+ end
411
+
412
+ def escape_literal
413
+ (dialect == :postgres) ? "'\\\\'" : "'\\'"
414
+ end
415
+
453
416
  def resolve_default(default)
454
417
  default.respond_to?(:call) ? default.call : default
455
418
  end
@@ -12,9 +12,10 @@ module BetterAuth
12
12
  :metadata,
13
13
  :options,
14
14
  :use,
15
+ :disable_body,
15
16
  :handler
16
17
 
17
- def initialize(path: nil, method: nil, body_schema: nil, query_schema: nil, params_schema: nil, headers_schema: nil, metadata: {}, use: [], &handler)
18
+ def initialize(path: nil, method: nil, body_schema: nil, query_schema: nil, params_schema: nil, headers_schema: nil, metadata: {}, use: [], disable_body: false, &handler)
18
19
  @path = path
19
20
  @methods = Array(method || "*").map { |value| value.to_s.upcase }
20
21
  @body_schema = body_schema
@@ -26,6 +27,7 @@ module BetterAuth
26
27
  apply_open_api_schemas!
27
28
  @options = endpoint_options
28
29
  @use = Array(use)
30
+ @disable_body = !!disable_body
29
31
  @handler = handler || ->(_ctx) {}
30
32
  end
31
33
 
@@ -57,6 +59,7 @@ module BetterAuth
57
59
  query: query_schema,
58
60
  params: params_schema,
59
61
  headers: headers_schema,
62
+ disableBody: disable_body,
60
63
  metadata: metadata
61
64
  }.compact
62
65
  end
@@ -249,19 +252,21 @@ module BetterAuth
249
252
  :body,
250
253
  :params,
251
254
  :headers,
255
+ :raw_body,
252
256
  :context,
253
257
  :request,
254
258
  :status,
255
259
  :returned,
256
260
  :response_headers
257
261
 
258
- def initialize(path:, method:, query:, body:, params:, headers:, context:, request: nil)
262
+ def initialize(path:, method:, query:, body:, params:, headers:, context:, request: nil, raw_body: nil)
259
263
  @path = path
260
264
  @method = method.to_s.upcase
261
265
  @query = query || {}
262
266
  @body = body || {}
263
267
  @params = params || {}
264
268
  @headers = normalize_headers(headers || {})
269
+ @raw_body = raw_body
265
270
  @context = context
266
271
  @request = request
267
272
  @status = 200
@@ -6,31 +6,35 @@ module BetterAuth
6
6
  module_function
7
7
 
8
8
  def legacy_register_endpoint(config)
9
- Endpoint.new(path: "/mcp/register", method: "POST") do |ctx|
9
+ Endpoint.new(path: "/mcp/register", method: "POST", metadata: BetterAuth::Plugins.mcp_openapi("legacyRegisterMcpClient", "Register an OAuth2 application using the legacy MCP path", "OAuth2 application registered successfully", BetterAuth::Plugins.mcp_client_schema)) do |ctx|
10
10
  ctx.json(register_client(ctx, config), status: 201, headers: no_store_headers)
11
11
  end
12
12
  end
13
13
 
14
14
  def legacy_authorize_endpoint(config)
15
- Endpoint.new(path: "/mcp/authorize", method: "GET") do |ctx|
15
+ Endpoint.new(path: "/mcp/authorize", method: "GET", metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpOAuthAuthorize", "Authorize an OAuth2 request using the legacy MCP path", "Authorization response generated successfully", {type: "object", additionalProperties: true})) do |ctx|
16
16
  authorize(ctx, config)
17
17
  end
18
18
  end
19
19
 
20
20
  def legacy_token_endpoint(config)
21
- Endpoint.new(path: "/mcp/token", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
21
+ Endpoint.new(
22
+ path: "/mcp/token",
23
+ method: "POST",
24
+ metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpOAuthToken", "Exchange OAuth2 code for MCP tokens using the legacy path", "OAuth2 tokens issued successfully", BetterAuth::Plugins.mcp_token_response_schema).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
25
+ ) do |ctx|
22
26
  ctx.json(token(ctx, config), headers: no_store_headers)
23
27
  end
24
28
  end
25
29
 
26
30
  def legacy_userinfo_endpoint(config)
27
- Endpoint.new(path: "/mcp/userinfo", method: "GET") do |ctx|
31
+ Endpoint.new(path: "/mcp/userinfo", method: "GET", metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpOAuthUserinfo", "Get MCP OAuth2 user information using the legacy path", "User information retrieved successfully", BetterAuth::Plugins.mcp_userinfo_schema)) do |ctx|
28
32
  ctx.json(userinfo(ctx, config))
29
33
  end
30
34
  end
31
35
 
32
36
  def legacy_jwks_endpoint(config)
33
- Endpoint.new(path: "/mcp/jwks", method: "GET") do |ctx|
37
+ Endpoint.new(path: "/mcp/jwks", method: "GET", metadata: BetterAuth::Plugins.mcp_openapi("legacyMcpJSONWebKeySet", "Get the MCP JSON Web Key Set using the legacy path", "JSON Web Key Set retrieved successfully", BetterAuth::Plugins.mcp_jwks_response_schema)) do |ctx|
34
38
  ctx.json(jwks(ctx, config))
35
39
  end
36
40
  end
@@ -86,7 +86,7 @@ module BetterAuth
86
86
  end
87
87
 
88
88
  def mcp_consent_endpoint(config)
89
- Endpoint.new(path: "/oauth2/consent", method: "POST") do |ctx|
89
+ Endpoint.new(path: "/oauth2/consent", method: "POST", metadata: mcp_openapi("mcpOAuthConsent", "Handle MCP OAuth2 consent", "OAuth2 consent handled successfully", {type: "object", additionalProperties: true})) do |ctx|
90
90
  ctx.json(MCP.consent(ctx, config))
91
91
  end
92
92
  end
@@ -120,13 +120,21 @@ module BetterAuth
120
120
  end
121
121
 
122
122
  def mcp_introspect_endpoint(config)
123
- Endpoint.new(path: "/oauth2/introspect", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
123
+ Endpoint.new(
124
+ path: "/oauth2/introspect",
125
+ method: "POST",
126
+ metadata: mcp_openapi("mcpOAuthIntrospect", "Introspect an MCP OAuth2 token", "OAuth2 token introspection result", mcp_introspection_schema).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
127
+ ) do |ctx|
124
128
  ctx.json(MCP.introspect(ctx, config))
125
129
  end
126
130
  end
127
131
 
128
132
  def mcp_revoke_endpoint(config)
129
- Endpoint.new(path: "/oauth2/revoke", method: "POST", metadata: {allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]}) do |ctx|
133
+ Endpoint.new(
134
+ path: "/oauth2/revoke",
135
+ method: "POST",
136
+ metadata: mcp_openapi("mcpOAuthRevoke", "Revoke an MCP OAuth2 token", "OAuth2 token revoked successfully", OpenAPI.object_schema({revoked: {type: "boolean"}}, required: ["revoked"])).merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
137
+ ) do |ctx|
130
138
  ctx.json(MCP.revoke(ctx, config))
131
139
  end
132
140
  end
@@ -186,5 +194,22 @@ module BetterAuth
186
194
  required: ["keys"]
187
195
  )
188
196
  end
197
+
198
+ def mcp_introspection_schema
199
+ OpenAPI.object_schema(
200
+ {
201
+ active: {type: "boolean"},
202
+ client_id: {type: ["string", "null"]},
203
+ scope: {type: ["string", "null"]},
204
+ sub: {type: ["string", "null"]},
205
+ iss: {type: ["string", "null"]},
206
+ iat: {type: ["number", "null"]},
207
+ exp: {type: ["number", "null"]},
208
+ sid: {type: ["string", "null"]},
209
+ aud: {type: ["string", "array", "null"], items: {type: "string"}}
210
+ },
211
+ required: ["active"]
212
+ )
213
+ end
189
214
  end
190
215
  end
@@ -344,7 +344,7 @@ module BetterAuth
344
344
  raise APIError.new("UNAUTHORIZED", message: "invalid_client")
345
345
  end
346
346
 
347
- def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil, nonce: nil, reference_id: nil, auth_time: nil)
347
+ def store_code(store, code:, client_id:, redirect_uri:, session:, scopes:, code_challenge: nil, code_challenge_method: nil, nonce: nil, reference_id: nil, auth_time: nil, expires_in: 600)
348
348
  store[:codes][code] = {
349
349
  client_id: client_id,
350
350
  redirect_uri: redirect_uri,
@@ -355,7 +355,7 @@ module BetterAuth
355
355
  nonce: nonce,
356
356
  reference_id: reference_id,
357
357
  auth_time: auth_time || session_auth_time(session),
358
- expires_at: Time.now + 600
358
+ expires_at: Time.now + expires_in.to_i
359
359
  }
360
360
  end
361
361
 
@@ -403,7 +403,7 @@ module BetterAuth
403
403
  true
404
404
  end
405
405
 
406
- def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false)
406
+ def issue_tokens(ctx, store, model:, client:, session:, scopes:, include_refresh: false, issuer: nil, jwt_audience: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, grant_type: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, nonce: nil, auth_time: nil, reference_id: nil, filter_id_token_claims_by_scope: false)
407
407
  data = stringify_keys(session || {})
408
408
  user = stringify_keys(data["user"] || data[:user] || {})
409
409
  session_data = stringify_keys(data["session"] || data[:session] || {})
@@ -417,7 +417,7 @@ module BetterAuth
417
417
  scope = scope_string(scopes)
418
418
  expires_at = Time.now + access_token_expires_in.to_i
419
419
  access_token = if jwt_access_token && audience
420
- build_jwt_access_token(ctx, client_data, user, session_data, scope, audience, issuer || issuer(ctx), expires_at, custom_access_token_claims, reference_id: token_reference_id)
420
+ build_jwt_access_token(ctx, client_data, user, session_data, scope, audience, issuer || issuer(ctx), expires_at, custom_access_token_claims, reference_id: token_reference_id, use_jwt_plugin: use_jwt_plugin)
421
421
  else
422
422
  apply_prefix(access_token_value, prefix, :access_token)
423
423
  end
@@ -461,7 +461,9 @@ module BetterAuth
461
461
  "issuer" => issuer || issuer(ctx),
462
462
  "issuedAt" => Time.now
463
463
  }
464
- ctx.context.adapter.create(model: model, data: record)
464
+ created_access = ctx.context.adapter.create(model: model, data: record)
465
+ created = stringify_keys(created_access || {})
466
+ record = record.merge("id" => created["id"]) if created["id"]
465
467
  stored_record = record.merge("user" => user, "session" => session_data, "client" => client_data)
466
468
  store[:tokens][access_token_value] = stored_record
467
469
  store[:tokens][access_token] = stored_record
@@ -476,7 +478,7 @@ module BetterAuth
476
478
  }
477
479
  response[:audience] = audience if audience
478
480
  response[:refresh_token] = refresh_token if refresh_token
479
- response[:id_token] = id_token(user.merge("id" => subject), client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer, session_id: session_data["id"], include_sid: !!client_data["enableEndSession"], nonce: nonce, auth_time: token_auth_time, custom_claims: custom_id_token_claims, scopes: parse_scopes(scope), client: client_data, filter_claims_by_scope: filter_id_token_claims_by_scope) if parse_scopes(scope).include?("openid")
481
+ response[:id_token] = id_token(user.merge("id" => subject), client_data["clientId"], issuer || issuer(ctx), jwt_audience || client_data["clientId"], ctx: ctx, signer: id_token_signer, session_id: session_data["id"], include_sid: !!client_data["enableEndSession"], nonce: nonce, auth_time: token_auth_time, custom_claims: custom_id_token_claims, scopes: parse_scopes(scope), client: client_data, filter_claims_by_scope: filter_id_token_claims_by_scope, expires_in: id_token_expires_in, use_jwt_plugin: use_jwt_plugin) if parse_scopes(scope).include?("openid")
480
482
  if custom_token_response_fields.respond_to?(:call)
481
483
  extra = custom_token_response_fields.call({grant_type: grant_type, user: user.empty? ? nil : user, scopes: parse_scopes(scope), metadata: stringify_keys(client_data["metadata"] || {})})
482
484
  response.merge!(stringify_keys(extra).reject { |key, _value| standard_token_response_field?(key) }.transform_keys(&:to_sym)) if extra.is_a?(Hash)
@@ -484,7 +486,7 @@ module BetterAuth
484
486
  response
485
487
  end
486
488
 
487
- def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false)
489
+ def refresh_tokens(ctx, store, model:, client:, refresh_token:, scopes: nil, issuer: nil, access_token_expires_in: 3600, refresh_token_expires_in: 2_592_000, id_token_expires_in: 3600, id_token_signer: nil, prefix: {}, audience: nil, custom_token_response_fields: nil, custom_access_token_claims: nil, custom_id_token_claims: nil, jwt_access_token: false, use_jwt_plugin: false, pairwise_secret: nil, filter_id_token_claims_by_scope: false)
488
490
  refresh_token_value = strip_prefix(refresh_token, prefix, :refresh_token)
489
491
  data = refresh_token_value ? store[:refresh_tokens][refresh_token_value] : nil
490
492
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") unless data
@@ -494,6 +496,11 @@ module BetterAuth
494
496
  end
495
497
  raise APIError.new("BAD_REQUEST", message: "invalid_grant") if data["expiresAt"] && data["expiresAt"] <= Time.now
496
498
 
499
+ client_data = stringify_keys(client)
500
+ unless data["clientId"].to_s == client_data["clientId"].to_s
501
+ raise APIError.new("BAD_REQUEST", message: "invalid_grant")
502
+ end
503
+
497
504
  requested = scopes ? parse_scopes(scopes) : data["scopes"]
498
505
  unless requested.all? { |scope| data["scopes"].include?(scope) }
499
506
  raise APIError.new("BAD_REQUEST", message: "invalid_scope")
@@ -520,7 +527,9 @@ module BetterAuth
520
527
  custom_access_token_claims: custom_access_token_claims,
521
528
  custom_id_token_claims: custom_id_token_claims,
522
529
  jwt_access_token: jwt_access_token,
530
+ use_jwt_plugin: use_jwt_plugin,
523
531
  pairwise_secret: pairwise_secret,
532
+ id_token_expires_in: id_token_expires_in,
524
533
  auth_time: data["authTime"],
525
534
  reference_id: data["referenceId"],
526
535
  filter_id_token_claims_by_scope: filter_id_token_claims_by_scope
@@ -537,13 +546,13 @@ module BetterAuth
537
546
  data
538
547
  end
539
548
 
540
- def build_jwt_access_token(ctx, client, user, session, scope, audience, issuer_value, expires_at, custom_claims, reference_id: nil)
549
+ def build_jwt_access_token(ctx, client, user, session, scope, audience, issuer_value, expires_at, custom_claims, reference_id: nil, use_jwt_plugin: false)
541
550
  scopes = parse_scopes(scope)
542
551
  extra = if custom_claims.respond_to?(:call)
543
552
  custom_claims.call({user: user.empty? ? nil : user, scopes: scopes, resource: audience, reference_id: reference_id, metadata: stringify_keys(client["metadata"] || {})})
544
553
  end
545
554
  payload = (extra.is_a?(Hash) ? stringify_keys(extra) : {}).merge(
546
- "sub" => user["id"],
555
+ "sub" => user["id"] || client["clientId"],
547
556
  "aud" => audience,
548
557
  "azp" => client["clientId"],
549
558
  "scope" => scope,
@@ -555,10 +564,15 @@ module BetterAuth
555
564
  "iat" => Time.now.to_i,
556
565
  "exp" => expires_at.to_i
557
566
  ).compact
567
+ if use_jwt_plugin
568
+ signed = sign_oauth_jwt(ctx, payload, issuer: issuer_value, audience: audience)
569
+ return signed if signed
570
+ end
571
+
558
572
  ::JWT.encode(payload, ctx.context.secret, "HS256")
559
573
  end
560
574
 
561
- def userinfo(store, authorization, additional_claim: nil, prefix: {}, jwt_secret: nil)
575
+ def userinfo(store, authorization, additional_claim: nil, prefix: {}, jwt_secret: nil, ctx: nil, issuer: nil)
562
576
  if authorization.to_s.strip.empty?
563
577
  raise APIError.new(
564
578
  "UNAUTHORIZED",
@@ -568,7 +582,7 @@ module BetterAuth
568
582
  end
569
583
  token = authorization.to_s.delete_prefix("Bearer ").strip
570
584
  record = token_record(store, token, prefix: prefix)
571
- return jwt_userinfo(token, jwt_secret, additional_claim: additional_claim) unless record
585
+ return jwt_userinfo(token, jwt_secret, additional_claim: additional_claim, ctx: ctx, issuer: issuer) unless record
572
586
  user = stringify_keys(record["user"])
573
587
  scopes = parse_scopes(record["scopes"])
574
588
  raise userinfo_openid_scope_error unless scopes.include?("openid")
@@ -593,8 +607,12 @@ module BetterAuth
593
607
  response
594
608
  end
595
609
 
596
- def jwt_userinfo(token, jwt_secret, additional_claim: nil)
597
- payload = ::JWT.decode(token, jwt_secret.to_s, true, algorithm: "HS256").first
610
+ def jwt_userinfo(token, jwt_secret, additional_claim: nil, ctx: nil, issuer: nil)
611
+ payload = if ctx
612
+ verify_oauth_jwt(ctx, token, issuer: issuer || issuer(ctx), hs256_secret: jwt_secret)
613
+ else
614
+ ::JWT.decode(token, jwt_secret.to_s, true, algorithm: "HS256").first
615
+ end
598
616
  scopes = parse_scopes(payload["scope"])
599
617
  raise userinfo_openid_scope_error unless scopes.include?("openid")
600
618
 
@@ -700,7 +718,7 @@ module BetterAuth
700
718
  end
701
719
  end
702
720
 
703
- def id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil, session_id: nil, include_sid: false, nonce: nil, auth_time: nil, custom_claims: nil, scopes: [], client: {}, filter_claims_by_scope: false)
721
+ def id_token(user, client_id, issuer_value, audience, ctx: nil, signer: nil, session_id: nil, include_sid: false, nonce: nil, auth_time: nil, custom_claims: nil, scopes: [], client: {}, filter_claims_by_scope: false, expires_in: 3600, use_jwt_plugin: false)
704
722
  requested_scopes = parse_scopes(scopes)
705
723
  payload = {
706
724
  sub: user["id"],
@@ -726,13 +744,54 @@ module BetterAuth
726
744
  end
727
745
  return signer.call(ctx, payload) if signer.respond_to?(:call)
728
746
 
747
+ if use_jwt_plugin && ctx
748
+ signed = sign_oauth_jwt(ctx, payload, issuer: issuer_value, audience: audience)
749
+ return signed if signed
750
+ end
751
+
729
752
  Crypto.sign_jwt(
730
753
  payload,
731
- client_id.to_s.empty? ? "better-auth" : client_id.to_s,
732
- expires_in: 3600
754
+ id_token_hs256_key(ctx, client_id, stringify_keys(client)["clientSecret"] || stringify_keys(client)["client_secret"]),
755
+ expires_in: expires_in
733
756
  )
734
757
  end
735
758
 
759
+ def id_token_hs256_key(ctx, client_id, client_secret = nil)
760
+ return client_secret.to_s unless client_secret.to_s.empty?
761
+
762
+ label = client_id.to_s.empty? ? "better-auth" : client_id.to_s
763
+ OpenSSL::HMAC.hexdigest("SHA256", ctx.context.secret.to_s, "oidc.id_token.#{label}")
764
+ end
765
+
766
+ def jwt_plugin_options(ctx)
767
+ plugin = ctx.context.options.plugins.find { |entry| entry.id == "jwt" }
768
+ plugin&.options
769
+ end
770
+
771
+ def oauth_jwt_config(ctx, issuer:, audience:)
772
+ options = jwt_plugin_options(ctx)
773
+ return nil unless options
774
+
775
+ BetterAuth::Plugins.deep_merge(options, jwt: {issuer: issuer, audience: audience})
776
+ end
777
+
778
+ def sign_oauth_jwt(ctx, payload, issuer:, audience:)
779
+ config = oauth_jwt_config(ctx, issuer: issuer, audience: audience)
780
+ return nil unless config
781
+
782
+ BetterAuth::Plugins.sign_jwt_payload(ctx, stringify_keys(payload), config)
783
+ end
784
+
785
+ def verify_oauth_jwt(ctx, token, issuer:, hs256_secret:)
786
+ payload = ::JWT.decode(token.to_s, nil, false).first
787
+ audience = payload["aud"]
788
+ config = oauth_jwt_config(ctx, issuer: issuer, audience: audience.is_a?(Array) ? audience.first : audience)
789
+ verified = BetterAuth::Plugins.verify_jwt_token(ctx, token, config) if config
790
+ return verified if verified
791
+
792
+ ::JWT.decode(token, hs256_secret.to_s, true, algorithm: "HS256").first
793
+ end
794
+
736
795
  def standard_token_response_field?(key)
737
796
  %w[access_token token_type expires_in scope refresh_token id_token audience].include?(key.to_s)
738
797
  end
@@ -801,11 +860,11 @@ module BetterAuth
801
860
  def verify_client_secret(ctx, stored_secret, provided_secret, mode)
802
861
  mode = normalize_secret_storage_mode(mode)
803
862
  return Crypto.constant_time_compare(Crypto.sha256(provided_secret, encoding: :base64url), stored_secret.to_s) if mode == "hashed"
804
- return Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_secret) == provided_secret.to_s if mode == "encrypted"
863
+ return Crypto.constant_time_compare(Crypto.symmetric_decrypt(key: ctx.context.secret_config, data: stored_secret).to_s, provided_secret.to_s) if mode == "encrypted"
805
864
 
806
865
  if mode.is_a?(Hash)
807
- return mode[:hash].call(provided_secret).to_s == stored_secret.to_s if mode[:hash].respond_to?(:call)
808
- return mode[:decrypt].call(stored_secret).to_s == provided_secret.to_s if mode[:decrypt].respond_to?(:call)
866
+ return Crypto.constant_time_compare(mode[:hash].call(provided_secret).to_s, stored_secret.to_s) if mode[:hash].respond_to?(:call)
867
+ return Crypto.constant_time_compare(mode[:decrypt].call(stored_secret).to_s, provided_secret.to_s) if mode[:decrypt].respond_to?(:call)
809
868
  end
810
869
 
811
870
  Crypto.constant_time_compare(stored_secret.to_s, provided_secret.to_s)
@@ -437,7 +437,16 @@ module BetterAuth
437
437
  Endpoint.new(
438
438
  path: "/oauth2/endsession",
439
439
  method: ["GET", "POST"],
440
- metadata: oidc_openapi("oauth2EndSession", "RP-Initiated Logout endpoint", "Logout request handled").merge(allowed_media_types: ["application/x-www-form-urlencoded", "application/json"])
440
+ metadata: {
441
+ openapi: {
442
+ operationId: "oauth2EndSession",
443
+ description: "RP-Initiated Logout endpoint",
444
+ responses: {
445
+ "302" => {description: "Redirects after clearing the session cookie"}
446
+ }
447
+ },
448
+ allowed_media_types: ["application/x-www-form-urlencoded", "application/json"]
449
+ }
441
450
  ) do |ctx|
442
451
  input_source = (ctx.method == "GET") ? ctx.query : ctx.body
443
452
  input = OAuthProtocol.stringify_keys(input_source)
@@ -90,6 +90,31 @@ module BetterAuth
90
90
  )
91
91
  end
92
92
 
93
+ def ref_schema(name, type: "object")
94
+ {type: type, "$ref": "#/components/schemas/#{name}"}
95
+ end
96
+
97
+ def array_schema(items)
98
+ {type: "array", items: items}
99
+ end
100
+
101
+ def nullable(type)
102
+ types = Array(type)
103
+ (types.include?("null") ? types : types + ["null"])
104
+ end
105
+
106
+ def query_parameter(name, required: false, schema: {type: "string"}, description: nil)
107
+ parameter = {name: name, in: "query", required: required, schema: schema}
108
+ parameter[:description] = description if description
109
+ parameter
110
+ end
111
+
112
+ def path_parameter(name, schema: {type: "string"}, description: nil)
113
+ parameter = {name: name, in: "path", required: true, schema: schema}
114
+ parameter[:description] = description if description
115
+ parameter
116
+ end
117
+
93
118
  def empty_request_body
94
119
  {
95
120
  content: {
@@ -243,10 +268,12 @@ module BetterAuth
243
268
 
244
269
  def open_api_paths(endpoints, options)
245
270
  disabled_paths = Array(options.disabled_paths).map(&:to_s)
246
- endpoints.each_with_object({}) do |(_key, endpoint, tag), paths|
271
+ endpoints.each_with_object({}) do |(key, endpoint, tag), paths|
247
272
  next unless endpoint.path
248
- next if endpoint.metadata[:hide] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata[:server_only]
273
+ next if endpoint.metadata[:exclude_from_openapi] || endpoint.metadata[:SERVER_ONLY] || endpoint.metadata[:server_only]
274
+ next if endpoint.metadata[:hide] && !open_api_documented_hidden_endpoint?(key, endpoint, tag)
249
275
  next if disabled_paths.include?(endpoint.path)
276
+ next if key == :set_password
250
277
 
251
278
  path = open_api_path(endpoint.path)
252
279
  paths[path] ||= {}
@@ -260,6 +287,10 @@ module BetterAuth
260
287
  path.split("/").map { |part| part.start_with?(":") ? "{#{part.delete_prefix(":")}}" : part }.join("/")
261
288
  end
262
289
 
290
+ def open_api_documented_hidden_endpoint?(key, endpoint, tag)
291
+ tag == "Default" && [:ok, :error].include?(key) && endpoint.metadata[:openapi]
292
+ end
293
+
263
294
  def open_api_operation(endpoint, method, tag)
264
295
  metadata = endpoint.metadata[:openapi] || {}
265
296
  operation = {
@@ -314,6 +345,8 @@ module BetterAuth
314
345
 
315
346
  def open_api_html(schema, config)
316
347
  nonce = config[:nonce] ? " nonce=\"#{config[:nonce]}\"" : ""
348
+ nonce_attr = config[:nonce] ? "nonce=\"#{config[:nonce]}\"" : ""
349
+ encoded_logo = open_api_encode_uri_component(open_api_logo)
317
350
  <<~HTML
318
351
  <!doctype html>
319
352
  <html>
@@ -323,12 +356,45 @@ module BetterAuth
323
356
  <meta name="viewport" content="width=device-width, initial-scale=1" />
324
357
  </head>
325
358
  <body>
326
- <script id="api-reference" type="application/json">#{JSON.generate(schema)}</script>
327
- <script#{nonce}>window.scalarTheme = "#{config[:theme]}";</script>
359
+ <script
360
+ id="api-reference"
361
+ type="application/json">
362
+ #{JSON.generate(schema)}
363
+ </script>
364
+ <script #{nonce_attr}>
365
+ var configuration = {
366
+ favicon: "data:image/svg+xml;utf8,#{encoded_logo}",
367
+ theme: "#{config[:theme] || "default"}",
368
+ metaData: {
369
+ title: "Better Auth API",
370
+ description: "API Reference for your Better Auth Instance",
371
+ }
372
+ }
373
+
374
+ document.getElementById('api-reference').dataset.configuration =
375
+ JSON.stringify(configuration)
376
+ </script>
328
377
  <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"#{nonce}></script>
329
378
  </body>
330
379
  </html>
331
380
  HTML
332
381
  end
382
+
383
+ def open_api_logo
384
+ <<~SVG
385
+ <svg width="75" height="75" viewBox="0 0 75 75" fill="none" xmlns="http://www.w3.org/2000/svg">
386
+ <rect width="75" height="75" rx="14" fill="#050505"/>
387
+ <path d="M19 50V25h15.5c5.5 0 9 2.6 9 6.6 0 2.7-1.6 4.7-4.1 5.7 3.2.9 5.2 3 5.2 6.1 0 4.2-3.7 6.6-9.6 6.6H19Zm7.1-14.8h7.2c2.1 0 3.2-.9 3.2-2.4s-1.1-2.4-3.2-2.4h-7.2v4.8Zm0 9.5h7.9c2.2 0 3.5-.9 3.5-2.5s-1.3-2.6-3.5-2.6h-7.9v5.1Z" fill="white"/>
388
+ <path d="M47 50V25h7.1v25H47Z" fill="white"/>
389
+ </svg>
390
+ SVG
391
+ end
392
+
393
+ def open_api_encode_uri_component(value)
394
+ value.to_s.bytes.map do |byte|
395
+ char = byte.chr
396
+ char.match?(/[A-Za-z0-9\-_.!~*'()]/) ? char : "%%%02X" % byte
397
+ end.join
398
+ end
333
399
  end
334
400
  end
@@ -62,8 +62,9 @@ module BetterAuth
62
62
  return run_on_response_chain(method_not_allowed(allowed_methods)) unless endpoint.matches_method?(request.request_method)
63
63
  return run_on_response_chain(unsupported_media_type) unless allowed_media_type?(request, endpoint)
64
64
 
65
- body = parse_body(request)
66
- endpoint_context = build_endpoint_context(request, route_path, query, body, params)
65
+ raw_body = read_raw_body(request)
66
+ body = parse_body(request, raw_body: raw_body, disable_body: endpoint.disable_body)
67
+ endpoint_context = build_endpoint_context(request, route_path, query, body, params, raw_body)
67
68
  return run_on_response_chain(forbidden) if server_only?(endpoint)
68
69
 
69
70
  response = @origin_check.call(endpoint_context)
@@ -80,7 +81,7 @@ module BetterAuth
80
81
  response = rate_limiter.call(request, context, route_path)
81
82
  return run_on_response_chain(response) if response
82
83
 
83
- endpoint_context = rebuild_endpoint_context(endpoint_context, request, route_path, params)
84
+ endpoint_context = rebuild_endpoint_context(endpoint_context, request, route_path, params, endpoint)
84
85
  result = API.new(context, endpoints).execute(endpoint, endpoint_context)
85
86
  response = result.response.is_a?(APIError) ? error_response(result.response, headers: result.headers) : result.to_rack_response
86
87
  run_on_response_chain(response)
@@ -161,12 +162,19 @@ module BetterAuth
161
162
  path.empty? ? "/" : path
162
163
  end
163
164
 
164
- def parse_body(request)
165
- return {} unless request.body
165
+ def read_raw_body(request)
166
+ return "" unless request.body
166
167
 
167
168
  request.body.rewind
168
169
  raw = request.body.read.to_s
169
170
  request.body.rewind
171
+ raw
172
+ end
173
+
174
+ def parse_body(request, raw_body:, disable_body: false)
175
+ return {} if disable_body
176
+
177
+ raw = raw_body.to_s
170
178
  return {} if raw.empty?
171
179
 
172
180
  if json_media_type?(request.media_type)
@@ -203,7 +211,7 @@ module BetterAuth
203
211
  request.GET
204
212
  end
205
213
 
206
- def build_endpoint_context(request, path, query, body, params)
214
+ def build_endpoint_context(request, path, query, body, params, raw_body)
207
215
  Endpoint::Context.new(
208
216
  path: path,
209
217
  method: request.request_method,
@@ -212,12 +220,14 @@ module BetterAuth
212
220
  params: params,
213
221
  headers: headers_from(request.env),
214
222
  context: context,
215
- request: request
223
+ request: request,
224
+ raw_body: raw_body
216
225
  )
217
226
  end
218
227
 
219
- def rebuild_endpoint_context(previous_context, request, route_path, params)
220
- fresh_context = build_endpoint_context(request, route_path, parse_query(request), parse_body(request), params)
228
+ def rebuild_endpoint_context(previous_context, request, route_path, params, endpoint)
229
+ raw_body = read_raw_body(request)
230
+ fresh_context = build_endpoint_context(request, route_path, parse_query(request), parse_body(request, raw_body: raw_body, disable_body: endpoint.disable_body), params, raw_body)
221
231
  fresh_context.headers = merge_hashes(previous_context.headers, fresh_context.headers)
222
232
  fresh_context.query = merge_hashes(previous_context.query, fresh_context.query)
223
233
  fresh_context.body = merge_hashes(previous_context.body, fresh_context.body)
@@ -229,7 +229,29 @@ module BetterAuth
229
229
  }
230
230
  ],
231
231
  responses: {
232
- "200" => OpenAPI.json_response("Provider user info", {type: "object"})
232
+ "200" => OpenAPI.json_response(
233
+ "Success",
234
+ OpenAPI.object_schema(
235
+ {
236
+ user: OpenAPI.object_schema(
237
+ {
238
+ id: {type: "string"},
239
+ name: {type: "string"},
240
+ email: {type: "string"},
241
+ image: {type: "string"},
242
+ emailVerified: {type: "boolean"}
243
+ },
244
+ required: ["id", "emailVerified"]
245
+ ),
246
+ data: {
247
+ type: "object",
248
+ properties: {},
249
+ additionalProperties: true
250
+ }
251
+ },
252
+ required: ["user", "data"]
253
+ ).merge(additionalProperties: false)
254
+ )
233
255
  }
234
256
  }
235
257
  }
@@ -8,7 +8,26 @@ module BetterAuth
8
8
  Endpoint.new(
9
9
  path: "/error",
10
10
  method: "GET",
11
- metadata: {hide: true}
11
+ metadata: {
12
+ hide: true,
13
+ openapi: {
14
+ description: "Displays an error page",
15
+ responses: {
16
+ "200" => OpenAPI.json_response(
17
+ "Success",
18
+ OpenAPI.object_schema(
19
+ {
20
+ html: {
21
+ type: "string",
22
+ description: "The HTML content of the error page"
23
+ }
24
+ },
25
+ required: ["html"]
26
+ )
27
+ )
28
+ }
29
+ }
30
+ }
12
31
  ) do |ctx|
13
32
  query = ctx.query || {}
14
33
  raw_code = query["error"] || query[:error] || "UNKNOWN"
@@ -6,7 +6,26 @@ module BetterAuth
6
6
  Endpoint.new(
7
7
  path: "/ok",
8
8
  method: "GET",
9
- metadata: {hide: true}
9
+ metadata: {
10
+ hide: true,
11
+ openapi: {
12
+ description: "Check if the API is working",
13
+ responses: {
14
+ "200" => OpenAPI.json_response(
15
+ "API is working",
16
+ OpenAPI.object_schema(
17
+ {
18
+ ok: {
19
+ type: "boolean",
20
+ description: "Indicates if the API is working"
21
+ }
22
+ },
23
+ required: ["ok"]
24
+ )
25
+ )
26
+ }
27
+ }
28
+ }
10
29
  ) do |ctx|
11
30
  ctx.json({ok: true})
12
31
  end
@@ -11,7 +11,7 @@ module BetterAuth
11
11
  operationId: "getSession",
12
12
  description: "Get the current session",
13
13
  responses: {
14
- "200" => OpenAPI.json_response("Current session or null", OpenAPI.session_response_schema_pair.merge(nullable: true))
14
+ "200" => OpenAPI.json_response("Current session or null", OpenAPI.session_response_schema_pair.merge(type: ["object", "null"]))
15
15
  }
16
16
  }
17
17
  }
@@ -101,6 +101,7 @@ module BetterAuth
101
101
  path: "/set-password",
102
102
  method: "POST",
103
103
  metadata: {
104
+ exclude_from_openapi: true,
104
105
  openapi: {
105
106
  operationId: "setPassword",
106
107
  description: "Set a password for the current user",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module BetterAuth
4
- VERSION = "0.6.2"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/better_auth.rb CHANGED
@@ -21,6 +21,7 @@ require_relative "better_auth/configuration"
21
21
  require_relative "better_auth/schema"
22
22
  require_relative "better_auth/schema/sql"
23
23
  require_relative "better_auth/adapters/base"
24
+ require_relative "better_auth/adapters/join_support"
24
25
  require_relative "better_auth/adapters/memory"
25
26
  require_relative "better_auth/adapters/sql"
26
27
  require_relative "better_auth/adapters/postgres"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth
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
@@ -259,6 +259,7 @@ files:
259
259
  - lib/better_auth.rb
260
260
  - lib/better_auth/adapters/base.rb
261
261
  - lib/better_auth/adapters/internal_adapter.rb
262
+ - lib/better_auth/adapters/join_support.rb
262
263
  - lib/better_auth/adapters/memory.rb
263
264
  - lib/better_auth/adapters/mongodb.rb
264
265
  - lib/better_auth/adapters/mssql.rb