better_auth-api-key 0.1.0 → 0.2.1

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: 5a2a00e41f4d86078b25bb86e9b515412d48b732f1817a16a33de6916d4ac287
4
- data.tar.gz: 0cea4d53a5bee49499501804ea8a80db5a62555f4c5049b24bb1a7469f7eddcf
3
+ metadata.gz: 2ac9db4e9d87c466c1bf8ecb7be2978d8ffc17fa168b5a626e82b29ddf36c4c2
4
+ data.tar.gz: 90f8b579bdf6fd84d7d0d2b0360f479c50800bc5ac724b0fdde6c721a8290e3a
5
5
  SHA512:
6
- metadata.gz: ae64ad016758516945f7a76860196c381358183df9918f06d2c4588d746bbdb57ccf3c6143ecaf8a14074d33eadea558f321ad0295b6012393bc3a306b442b20
7
- data.tar.gz: 65b54fd1360d696467f09888e16c0c76b9681aa6ea9b59137b4c16f132f34993d5ebf30b8a63e99f711ab0da136f9eb1e1ab43de474ff023d6b846058b049296
6
+ metadata.gz: 970bf80895c95174c91e269560260f6b90281dcc5f3a1be4cd708bcdb53a12dd5d567874a3b046b86867b069000009238982bbd22b980edfa990ed7c68a04b6b
7
+ data.tar.gz: c52b6d7e9d2b98e77754e9105681bd1c1f8e619b1a0819ab93798a7eacc62cd10a40e11a32debe1a119578de868e76f3982dd366f745d4eb7abb60eab97a4332
data/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 - 2026-04-30
4
+
5
+ - Fixed API key metadata normalization so symbol and string metadata keys preserve nested metadata payloads.
6
+ - Added upstream parity coverage for API key behavior and error-code response details.
7
+
8
+ ## 0.2.0 - 2026-04-29
9
+
10
+ - Aligned API key behavior with upstream Better Auth v1.6.9, including key verification, permission checks, metadata updates, expiration, rate limiting, prefix handling, and route response shapes.
11
+ - Expanded package documentation and executable coverage for upstream API key edge cases.
12
+
3
13
  ## 0.1.0
4
14
 
5
15
  - Extract API key support into the `better_auth-api-key` package.
data/README.md CHANGED
@@ -25,3 +25,181 @@ auth = BetterAuth.auth(
25
25
  ## Notes
26
26
 
27
27
  This package matches upstream's separate `@better-auth/api-key` package boundary. The Ruby plugin keeps the public `BetterAuth::Plugins.api_key` entrypoint, while core `better_auth` only provides a compatibility shim.
28
+
29
+ ## Upstream parity
30
+
31
+ The Ruby package implements the upstream server contract for `@better-auth/api-key`: the same API key routes, response shapes, error messages, metadata/permissions decoding, organization-owned keys, multiple configurations, rate limits, usage limits, secondary storage, fallback-to-database behavior, and API-key-backed sessions.
32
+
33
+ Frontend applications should use the upstream JavaScript client plugin against the Ruby server:
34
+
35
+ ```ts
36
+ import { createAuthClient } from "better-auth/client";
37
+ import { apiKeyClient } from "@better-auth/api-key/client";
38
+
39
+ export const authClient = createAuthClient({
40
+ baseURL: "https://auth.example.com",
41
+ plugins: [apiKeyClient()]
42
+ });
43
+ ```
44
+
45
+ Ruby does not expose a separate `apiKeyClient()` equivalent; the public Ruby surface is the server plugin and route contract.
46
+
47
+ ## Configuration
48
+
49
+ ```ruby
50
+ auth = BetterAuth.auth(
51
+ secret: ENV.fetch("BETTER_AUTH_SECRET"),
52
+ secondary_storage: redis_storage,
53
+ plugins: [
54
+ BetterAuth::Plugins.api_key(
55
+ default_key_length: 64,
56
+ default_prefix: "ba_",
57
+ enable_metadata: true,
58
+ enable_session_for_api_keys: true,
59
+ disable_key_hashing: false,
60
+ rate_limit: {
61
+ enabled: true,
62
+ time_window: 86_400_000,
63
+ max_requests: 10
64
+ },
65
+ key_expiration: {
66
+ default_expires_in: nil,
67
+ disable_custom_expires_time: false,
68
+ min_expires_in: 1,
69
+ max_expires_in: 365
70
+ },
71
+ starting_characters_config: {
72
+ should_store: true,
73
+ characters_length: 6
74
+ },
75
+ storage: "secondary-storage",
76
+ fallback_to_database: true,
77
+ custom_storage: nil,
78
+ permissions: {
79
+ default_permissions: {files: ["read"]}
80
+ }
81
+ )
82
+ ]
83
+ )
84
+ ```
85
+
86
+ Multiple configurations are supported with required unique `config_id` values:
87
+
88
+ ```ruby
89
+ BetterAuth::Plugins.api_key([
90
+ {config_id: "user-keys", references: "user", default_prefix: "usr_"},
91
+ {config_id: "org-keys", references: "organization", default_prefix: "org_"}
92
+ ])
93
+ ```
94
+
95
+ Organization-owned keys require `BetterAuth::Plugins.organization` and use organization permissions for `apiKey` actions: `create`, `read`, `update`, and `delete`.
96
+
97
+ Secondary-storage mode uses upstream storage keys such as `api-key:<hash>`, `api-key:by-id:<id>`, and `api-key:by-ref:<referenceId>`. When `fallback_to_database: true` is enabled, the reference list is treated as a cache and invalidated on writes/deletes so concurrent writers cannot lose IDs; listing falls back to the database source of truth.
98
+
99
+ ## Storage layout
100
+
101
+ The Ruby gem writes only to the upstream layout; legacy prefixes are read for
102
+ backward compatibility but never produced by new writes:
103
+
104
+ | Purpose | Upstream key (read + write) | Ruby legacy key (read only) |
105
+ |----------------------------------|------------------------------|------------------------------|
106
+ | Lookup by hashed key | `api-key:<hash>` | `api-key:key:<hash>` |
107
+ | Lookup by id | `api-key:by-id:<id>` | `api-key:id:<id>` |
108
+ | Reference -> [id] list | `api-key:by-ref:<refId>` | `api-key:user:<userId>` |
109
+
110
+ When upgrading from older Ruby releases the new server transparently keeps
111
+ serving cached entries from the legacy keys while populating the upstream layout
112
+ on the next mutation. Once a key is rewritten, the legacy entry is also deleted
113
+ on `delete-api-key` to keep the layout converging on a single source of truth.
114
+
115
+ ## Plugin metadata
116
+
117
+ The plugin object exposes the package version (mirroring upstream
118
+ `@better-auth/api-key` 1.6.0+):
119
+
120
+ ```ruby
121
+ auth.options.plugins.find { |plugin| plugin.id == "api-key" }.version
122
+ # => BetterAuth::APIKey::VERSION
123
+ ```
124
+
125
+ ## Hashing
126
+
127
+ The upstream `defaultKeyHasher` equivalent is available as:
128
+
129
+ ```ruby
130
+ BetterAuth::Plugins.default_api_key_hasher("secret-key")
131
+ BetterAuth::APIKey.default_key_hasher("secret-key")
132
+ ```
133
+
134
+ Both return the SHA-256 base64url digest used for stored API keys when `disable_key_hashing` is false.
135
+
136
+ ## Ruby option naming policy
137
+
138
+ Public option keys use idiomatic Ruby `snake_case` while the wire JSON keeps
139
+ upstream's `camelCase`. The mapping is fixed and intentionally lossless:
140
+
141
+ | Ruby option (snake_case) | Wire field (camelCase) |
142
+ |---------------------------------------|-------------------------------|
143
+ | `config_id` | `configId` |
144
+ | `default_key_length` | `defaultKeyLength` |
145
+ | `default_prefix` | `defaultPrefix` |
146
+ | `enable_metadata` | `enableMetadata` |
147
+ | `disable_key_hashing` | `disableKeyHashing` |
148
+ | `require_name` | `requireName` |
149
+ | `enable_session_for_api_keys` | `enableSessionForAPIKeys` |
150
+ | `fallback_to_database` | `fallbackToDatabase` |
151
+ | `custom_storage` | `customStorage` |
152
+ | `defer_updates` | `deferUpdates` |
153
+ | `references` | `references` |
154
+ | `key_expiration.default_expires_in` | `keyExpiration.defaultExpiresIn` |
155
+ | `key_expiration.disable_custom_expires_time` | `keyExpiration.disableCustomExpiresTime` |
156
+ | `key_expiration.max_expires_in` | `keyExpiration.maxExpiresIn` |
157
+ | `key_expiration.min_expires_in` | `keyExpiration.minExpiresIn` |
158
+ | `starting_characters_config.should_store` | `startingCharactersConfig.shouldStore` |
159
+ | `starting_characters_config.characters_length` | `startingCharactersConfig.charactersLength` |
160
+ | `rate_limit.enabled` | `rateLimit.enabled` |
161
+ | `rate_limit.time_window` | `rateLimit.timeWindow` |
162
+ | `rate_limit.max_requests` | `rateLimit.maxRequests` |
163
+
164
+ Endpoint requests/responses always use the upstream `camelCase` field names, so
165
+ TypeScript clients targeting `@better-auth/api-key/client` interoperate without
166
+ configuration changes.
167
+
168
+ ## Organization-owned API keys
169
+
170
+ Setting `references: "organization"` on a configuration delegates ownership to
171
+ `BetterAuth::Plugins::Organization`, which must be installed alongside this
172
+ plugin. The organization plugin's access-control bundle must define the
173
+ `apiKey` resource with `create`, `read`, `update`, and `delete` actions:
174
+
175
+ ```ruby
176
+ ac = BetterAuth::Plugins.create_access_control(
177
+ organization: ["update", "delete"],
178
+ member: ["create", "update", "delete"],
179
+ invitation: ["create", "cancel"],
180
+ team: ["create", "update", "delete"],
181
+ ac: ["create", "read", "update", "delete"],
182
+ apiKey: ["create", "read", "update", "delete"]
183
+ )
184
+ ```
185
+
186
+ The configured `creator_role` (default `"owner"`) is treated as having
187
+ implicit permission for every `apiKey` action, mirroring upstream's "owner
188
+ bypasses per-action permission check" behavior. All other roles must be granted
189
+ the appropriate `apiKey:*` permission to perform the corresponding action.
190
+
191
+ ## Intentional Ruby-vs-upstream adaptations
192
+
193
+ The following decisions are explicit and locked behind tests:
194
+
195
+ - **OpenAPI metadata blocks** embedded in upstream endpoint definitions are not
196
+ ported. OpenAPI generation is not part of `better_auth-api-key`'s scope.
197
+ - **Browser-only `@better-auth/api-key/client`** helpers are not implemented in
198
+ Ruby. Apps should call `/api-key/create`, `/api-key/verify`, `/api-key/get`,
199
+ `/api-key/list`, `/api-key/update`, `/api-key/delete`, and
200
+ `/api-key/delete-all-expired-api-keys` directly via JSON.
201
+ - **`apikey` table name** mirrors the upstream package (no `_` separator).
202
+ - **Legacy secondary-storage prefixes** (`api-key:key:*`, `api-key:id:*`,
203
+ `api-key:user:*`) are still honored on read so existing deployments do not
204
+ lose data when upgrading. New writes always use the upstream layout
205
+ documented above.
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module APIKey
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
@@ -6,5 +6,10 @@ require_relative "plugins/api_key"
6
6
 
7
7
  module BetterAuth
8
8
  module APIKey
9
+ module_function
10
+
11
+ def default_key_hasher(key)
12
+ Plugins.default_api_key_hasher(key)
13
+ end
9
14
  end
10
15
  end
@@ -47,10 +47,15 @@ module BetterAuth
47
47
 
48
48
  module_function
49
49
 
50
+ def default_api_key_hasher(key)
51
+ Crypto.sha256(key.to_s, encoding: :base64url)
52
+ end
53
+
50
54
  def api_key(configurations = {}, options = nil)
51
55
  config = api_key_config(configurations, options)
52
56
  Plugin.new(
53
57
  id: "api-key",
58
+ version: BetterAuth::APIKey::VERSION,
54
59
  hooks: {
55
60
  before: [
56
61
  {
@@ -153,7 +158,6 @@ module BetterAuth
153
158
  prefix: {type: "string", required: false},
154
159
  key: {type: "string", required: true, index: true},
155
160
  referenceId: {type: "string", required: true, index: true},
156
- userId: {type: "string", required: false, index: true, references: {model: "user", field: "id", on_delete: "cascade"}},
157
161
  refillInterval: {type: "number", required: false},
158
162
  refillAmount: {type: "number", required: false},
159
163
  lastRefillAt: {type: "date", required: false},
@@ -177,17 +181,12 @@ module BetterAuth
177
181
 
178
182
  def api_key_create_endpoint(config)
179
183
  Endpoint.new(path: "/api-key/create", method: "POST") do |ctx|
180
- body = normalize_hash(ctx.body)
184
+ body = api_key_normalize_body(ctx.body)
181
185
  resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
182
186
  session = Routes.current_session(ctx, allow_nil: true)
183
- if session && body[:user_id]
184
- raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["SERVER_ONLY_PROPERTY"])
185
- end
186
-
187
187
  reference_id = api_key_create_reference_id!(ctx, body, session, resolved_config)
188
- user_id = (resolved_config[:references].to_s == "organization") ? (session&.dig(:user, "id") || body[:user_id]) : reference_id
189
188
 
190
- api_key_validate_create_update!(body, resolved_config, create: true, client: !!session)
189
+ api_key_validate_create_update!(body, resolved_config, create: true, client: !ctx.headers.empty?)
191
190
  key_prefix = body.key?(:prefix) ? body[:prefix] : resolved_config[:default_prefix]
192
191
  key = api_key_generate_key(resolved_config, key_prefix)
193
192
  now = Time.now
@@ -198,17 +197,16 @@ module BetterAuth
198
197
  start: resolved_config[:starting_characters_config][:should_store] ? key[0, resolved_config[:starting_characters_config][:characters_length].to_i] : nil,
199
198
  prefix: key_prefix,
200
199
  key: hashed,
201
- userId: user_id,
202
200
  referenceId: reference_id,
203
201
  enabled: true,
204
202
  rateLimitEnabled: body.key?(:rate_limit_enabled) ? body[:rate_limit_enabled] : resolved_config[:rate_limit][:enabled],
205
203
  rateLimitTimeWindow: body[:rate_limit_time_window] || resolved_config[:rate_limit][:time_window],
206
204
  rateLimitMax: body[:rate_limit_max] || resolved_config[:rate_limit][:max_requests],
207
205
  requestCount: 0,
208
- remaining: body.key?(:remaining) ? body[:remaining] : (body[:refill_amount] || nil),
206
+ remaining: body.key?(:remaining) ? body[:remaining] : nil,
209
207
  refillAmount: body[:refill_amount],
210
208
  refillInterval: body[:refill_interval],
211
- lastRefillAt: now,
209
+ lastRefillAt: nil,
212
210
  expiresAt: api_key_expires_at(body, resolved_config),
213
211
  createdAt: now,
214
212
  updatedAt: now,
@@ -224,16 +222,20 @@ module BetterAuth
224
222
  Endpoint.new(path: "/api-key/verify", method: "POST") do |ctx|
225
223
  body = normalize_hash(ctx.body)
226
224
  resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
227
- key = body[:key] || api_key_get_from_headers(ctx, config)
225
+ key = body[:key]
228
226
  raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY") if key.to_s.empty?
229
227
 
230
- record = api_key_validate!(ctx, key, resolved_config, permissions: body[:permissions])
231
- record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
232
- api_key_delete_expired(ctx.context, record_config)
233
- ctx.json({valid: true, error: nil, key: api_key_public(record, include_key_field: false)})
228
+ if resolved_config[:custom_api_key_validator].respond_to?(:call) && !resolved_config[:custom_api_key_validator].call({ctx: ctx, key: key})
229
+ ctx.json({valid: false, error: {message: API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "KEY_NOT_FOUND"}, key: nil})
230
+ else
231
+ record = api_key_validate!(ctx, key, resolved_config, permissions: body[:permissions])
232
+ record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
233
+ api_key_schedule_cleanup(ctx, record_config)
234
+ ctx.json({valid: true, error: nil, key: api_key_public(record, include_key_field: false)})
235
+ end
234
236
  rescue APIError => error
235
237
  ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
236
- ctx.json({valid: false, error: {message: error.message, code: api_key_error_code(error)}, key: nil})
238
+ ctx.json({valid: false, error: api_key_error_payload(error), key: nil})
237
239
  rescue => error
238
240
  ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
239
241
  ctx.json({valid: false, error: {message: API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY"}, key: nil})
@@ -260,7 +262,7 @@ module BetterAuth
260
262
 
261
263
  def api_key_update_endpoint(config)
262
264
  Endpoint.new(path: "/api-key/update", method: "POST") do |ctx|
263
- body = normalize_hash(ctx.body)
265
+ body = api_key_normalize_body(ctx.body)
264
266
  resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
265
267
  session = Routes.current_session(ctx, allow_nil: true)
266
268
  user_id = session&.dig(:user, "id") || body[:user_id]
@@ -277,7 +279,7 @@ module BetterAuth
277
279
  record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
278
280
  api_key_authorize_reference!(ctx, record_config, user_id, api_key_record_reference_id(record), "update")
279
281
 
280
- api_key_validate_create_update!(body, record_config, create: false, client: !!session)
282
+ api_key_validate_create_update!(body, record_config, create: false, client: api_key_auth_required?(ctx))
281
283
  update = api_key_update_payload(body, record_config)
282
284
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["NO_VALUES_TO_UPDATE"]) if update.empty?
283
285
 
@@ -291,6 +293,8 @@ module BetterAuth
291
293
  def api_key_delete_endpoint(config)
292
294
  Endpoint.new(path: "/api-key/delete", method: "POST") do |ctx|
293
295
  session = Routes.current_session(ctx)
296
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["USER_BANNED"]) if session[:user]["banned"] == true
297
+
294
298
  body = normalize_hash(ctx.body)
295
299
  resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
296
300
  key_id = body[:key_id]
@@ -310,6 +314,7 @@ module BetterAuth
310
314
  Endpoint.new(path: "/api-key/list", method: "GET") do |ctx|
311
315
  session = Routes.current_session(ctx)
312
316
  query = normalize_hash(ctx.query)
317
+ api_key_validate_list_query!(query)
313
318
  configs = query[:config_id] ? [api_key_resolve_config(ctx.context, config, query[:config_id])] : config.fetch(:configurations, [config])
314
319
  reference_id = query[:organization_id] || session[:user]["id"]
315
320
  expected_reference = query[:organization_id] ? "organization" : "user"
@@ -338,8 +343,11 @@ module BetterAuth
338
343
 
339
344
  def api_key_delete_expired_endpoint(config)
340
345
  Endpoint.new(path: "/api-key/delete-all-expired-api-keys", method: "POST") do |ctx|
341
- api_key_delete_expired(ctx.context, config)
342
- ctx.json({success: true})
346
+ api_key_delete_expired(ctx.context, config, bypass_last_check: true)
347
+ ctx.json({success: true, error: nil})
348
+ rescue => error
349
+ ctx.context.logger.error("[API KEY PLUGIN] Failed to delete expired API keys: #{error.message}") if ctx.context.logger.respond_to?(:error)
350
+ ctx.json({success: false, error: error})
343
351
  end
344
352
  end
345
353
 
@@ -371,7 +379,13 @@ module BetterAuth
371
379
  def api_key_create_reference_id!(ctx, body, session, config)
372
380
  if config[:references].to_s == "organization"
373
381
  organization_id = body[:organization_id]
374
- raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["ORGANIZATION_ID_REQUIRED"]) if organization_id.to_s.empty?
382
+ if organization_id.to_s.empty?
383
+ raise APIError.new(
384
+ "BAD_REQUEST",
385
+ message: API_KEY_ERROR_CODES["ORGANIZATION_ID_REQUIRED"],
386
+ code: "ORGANIZATION_ID_REQUIRED"
387
+ )
388
+ end
375
389
 
376
390
  user_id = session&.dig(:user, "id") || body[:user_id]
377
391
  raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) if user_id.to_s.empty?
@@ -420,15 +434,33 @@ module BetterAuth
420
434
 
421
435
  def api_key_check_org_permission!(ctx, user_id, organization_id, action)
422
436
  org_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "organization" }
423
- raise APIError.new("INTERNAL_SERVER_ERROR", message: API_KEY_ERROR_CODES["ORGANIZATION_PLUGIN_REQUIRED"]) unless org_plugin
437
+ unless org_plugin
438
+ raise APIError.new(
439
+ "INTERNAL_SERVER_ERROR",
440
+ message: API_KEY_ERROR_CODES["ORGANIZATION_PLUGIN_REQUIRED"],
441
+ code: "ORGANIZATION_PLUGIN_REQUIRED"
442
+ )
443
+ end
424
444
 
425
445
  member = ctx.context.adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}])
426
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["USER_NOT_MEMBER_OF_ORGANIZATION"]) unless member
446
+ unless member
447
+ raise APIError.new(
448
+ "FORBIDDEN",
449
+ message: API_KEY_ERROR_CODES["USER_NOT_MEMBER_OF_ORGANIZATION"],
450
+ code: "USER_NOT_MEMBER_OF_ORGANIZATION"
451
+ )
452
+ end
453
+
454
+ return member if member["role"].to_s == (org_plugin.options[:creator_role] || "owner").to_s
427
455
 
428
456
  permissions = {"apiKey" => [action]}
429
457
  return member if BetterAuth::Plugins.organization_permission?(ctx, org_plugin.options, member["role"], permissions, organization_id)
430
458
 
431
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INSUFFICIENT_API_KEY_PERMISSIONS"])
459
+ raise APIError.new(
460
+ "FORBIDDEN",
461
+ message: API_KEY_ERROR_CODES["INSUFFICIENT_API_KEY_PERMISSIONS"],
462
+ code: "INSUFFICIENT_API_KEY_PERMISSIONS"
463
+ )
432
464
  end
433
465
 
434
466
  def api_key_sort_records(records, sort_by, direction)
@@ -443,10 +475,31 @@ module BetterAuth
443
475
  end
444
476
  end
445
477
 
478
+ def api_key_validate_list_query!(query)
479
+ %i[limit offset].each do |key|
480
+ next unless query.key?(key)
481
+
482
+ value = query[key]
483
+ raise APIError.new("BAD_REQUEST", message: "Invalid #{key}") unless value.to_s.match?(/\A\d+\z/)
484
+ end
485
+
486
+ direction = query[:sort_direction]
487
+ return if direction.nil? || %w[asc desc].include?(direction.to_s.downcase)
488
+
489
+ raise APIError.new("BAD_REQUEST", message: "Invalid sortDirection")
490
+ end
491
+
446
492
  def api_key_error_code(error)
447
493
  API_KEY_ERROR_CODES.key(error.message) || error.code.to_s
448
494
  end
449
495
 
496
+ def api_key_error_payload(error)
497
+ payload = error.to_h
498
+ return payload if payload.is_a?(Hash) && payload.key?(:details)
499
+
500
+ {message: error.message, code: api_key_error_code(error)}
501
+ end
502
+
450
503
  def api_key_session_header_config(ctx, config)
451
504
  config.fetch(:configurations, [config]).find do |entry|
452
505
  entry[:enable_session_for_api_keys] && api_key_get_from_headers(ctx, entry)
@@ -466,12 +519,23 @@ module BetterAuth
466
519
  end
467
520
 
468
521
  record = api_key_validate!(ctx, key, config)
522
+ api_key_schedule_cleanup(ctx, config)
469
523
  if config[:references].to_s != "user"
470
- raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"])
524
+ raise APIError.new(
525
+ "UNAUTHORIZED",
526
+ message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"],
527
+ code: "INVALID_REFERENCE_ID_FROM_API_KEY"
528
+ )
471
529
  end
472
530
  reference_id = api_key_record_reference_id(record)
473
531
  user = ctx.context.internal_adapter.find_user_by_id(reference_id)
474
- raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"]) unless user
532
+ unless user
533
+ raise APIError.new(
534
+ "UNAUTHORIZED",
535
+ message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"],
536
+ code: "INVALID_REFERENCE_ID_FROM_API_KEY"
537
+ )
538
+ end
475
539
 
476
540
  session = {
477
541
  user: user,
@@ -480,7 +544,7 @@ module BetterAuth
480
544
  "token" => key,
481
545
  "userId" => reference_id,
482
546
  "userAgent" => ctx.headers["user-agent"],
483
- "ipAddress" => ctx.headers["x-forwarded-for"],
547
+ "ipAddress" => RequestIP.client_ip(ctx.request || ctx.headers, ctx.context.options),
484
548
  "createdAt" => Time.now,
485
549
  "updatedAt" => Time.now,
486
550
  "expiresAt" => record["expiresAt"] || (Time.now + ctx.context.options.session[:expires_in].to_i)
@@ -493,16 +557,16 @@ module BetterAuth
493
557
  def api_key_validate!(ctx, key, config, permissions: nil)
494
558
  hashed = api_key_hash(key, config)
495
559
  record = api_key_find_by_hash(ctx, hashed, config)
496
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
497
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless api_key_config_id_matches?(api_key_record_config_id(record), config[:config_id])
498
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
560
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
561
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless api_key_config_id_matches?(api_key_record_config_id(record), config[:config_id])
562
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
499
563
  if record["expiresAt"] && record["expiresAt"] <= Time.now
500
- api_key_delete_record(ctx, record, config)
501
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["KEY_EXPIRED"])
564
+ api_key_schedule_record_delete(ctx, record, config)
565
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_EXPIRED"])
502
566
  end
503
567
  if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].nil?
504
- api_key_delete_record(ctx, record, config)
505
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
568
+ api_key_schedule_record_delete(ctx, record, config)
569
+ raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
506
570
  end
507
571
 
508
572
  api_key_check_permissions!(record, permissions)
@@ -515,38 +579,57 @@ module BetterAuth
515
579
  now = Time.now
516
580
  update = {lastRequest: now, updatedAt: now}
517
581
 
518
- if api_key_rate_limited?(record, config, now)
519
- raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"])
582
+ if (try_again_in = api_key_rate_limit_try_again_in(record, config, now))
583
+ raise APIError.new(
584
+ "UNAUTHORIZED",
585
+ message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
586
+ code: "RATE_LIMITED",
587
+ body: {
588
+ message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
589
+ code: "RATE_LIMITED",
590
+ details: {tryAgainIn: try_again_in}
591
+ }
592
+ )
520
593
  end
521
- update[:requestCount] = api_key_next_request_count(record, now)
594
+ update[:requestCount] = api_key_next_request_count(record, now) if api_key_rate_limit_counts_requests?(record, config)
522
595
 
523
596
  remaining = record["remaining"]
524
597
  if !remaining.nil?
525
598
  if remaining.to_i <= 0 && record["refillAmount"] && record["refillInterval"]
526
- last_refill = api_key_normalize_time(record["lastRefillAt"] || record["lastRequest"] || record["createdAt"])
527
- if !last_refill || ((now - last_refill) * 1000) >= record["refillInterval"].to_i
599
+ last_refill = api_key_normalize_time(record["lastRefillAt"] || record["createdAt"])
600
+ if !last_refill || ((now - last_refill) * 1000) > record["refillInterval"].to_i
528
601
  remaining = record["refillAmount"].to_i
529
602
  update[:lastRefillAt] = now
530
603
  end
531
604
  end
532
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
605
+ raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
533
606
 
534
607
  update[:remaining] = remaining.to_i - 1
535
608
  end
536
609
  update
537
610
  end
538
611
 
539
- def api_key_rate_limited?(record, config, now)
540
- return false if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
612
+ def api_key_rate_limit_try_again_in(record, config, now)
613
+ return nil if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
541
614
 
542
615
  window = record["rateLimitTimeWindow"]
543
616
  max = record["rateLimitMax"]
544
- return false if window.nil? || max.nil?
617
+ return nil if window.nil? || max.nil?
545
618
 
546
619
  last = api_key_normalize_time(record["lastRequest"])
547
- return false unless last && ((now - last) * 1000) <= window.to_i
620
+ return nil unless last
621
+
622
+ elapsed = (now - last) * 1000
623
+ return nil if elapsed > window.to_i
624
+ return nil unless record["requestCount"].to_i >= max.to_i
548
625
 
549
- record["requestCount"].to_i >= max.to_i
626
+ (window.to_i - elapsed).ceil
627
+ end
628
+
629
+ def api_key_rate_limit_counts_requests?(record, config)
630
+ return false if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
631
+
632
+ !record["rateLimitTimeWindow"].nil? && !record["rateLimitMax"].nil?
550
633
  end
551
634
 
552
635
  def api_key_next_request_count(record, now)
@@ -578,23 +661,25 @@ module BetterAuth
578
661
  minimum = create ? 0 : 1
579
662
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_REMAINING"]) if body[:remaining].to_i < minimum
580
663
  end
581
- if body.key?(:metadata)
664
+ if body[:metadata] && (create || config[:enable_metadata])
582
665
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["METADATA_DISABLED"]) unless config[:enable_metadata]
583
666
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_METADATA_TYPE"]) unless body[:metadata].nil? || body[:metadata].is_a?(Hash)
584
667
  end
585
- server_only_keys = %i[refill_amount refill_interval rate_limit_max rate_limit_time_window rate_limit_enabled remaining]
586
- server_only_keys << :permissions unless create
587
- if client && server_only_keys.any? { |key| body.key?(key) }
668
+ server_only_keys = %i[refill_amount refill_interval rate_limit_max rate_limit_time_window rate_limit_enabled remaining permissions]
669
+ if client && server_only_keys.any? { |key| (create && key == :remaining) ? !body[:remaining].nil? : body.key?(key) }
588
670
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["SERVER_ONLY_PROPERTY"])
589
671
  end
590
- if body[:refill_amount] && !body[:refill_interval]
591
- raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_INTERVAL_AND_AMOUNT_REQUIRED"])
592
- end
593
- if body[:refill_interval] && !body[:refill_amount]
672
+ amount_present = body.key?(:refill_amount)
673
+ interval_present = body.key?(:refill_interval)
674
+ if amount_present && !interval_present
594
675
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_AMOUNT_AND_INTERVAL_REQUIRED"])
595
676
  end
677
+ if interval_present && !amount_present
678
+ raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_INTERVAL_AND_AMOUNT_REQUIRED"])
679
+ end
596
680
  if body.key?(:expires_in)
597
681
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["KEY_DISABLED_EXPIRATION"]) if config[:key_expiration][:disable_custom_expires_time]
682
+ return if body[:expires_in].nil?
598
683
 
599
684
  days = body[:expires_in].to_f / 86_400
600
685
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["EXPIRES_IN_IS_TOO_SMALL"]) if days < config[:key_expiration][:min_expires_in].to_f
@@ -602,7 +687,7 @@ module BetterAuth
602
687
  end
603
688
  end
604
689
 
605
- def api_key_update_payload(body, _config)
690
+ def api_key_update_payload(body, config)
606
691
  update = {}
607
692
  update[:name] = body[:name] if body.key?(:name)
608
693
  update[:enabled] = body[:enabled] unless body[:enabled].nil?
@@ -613,7 +698,7 @@ module BetterAuth
613
698
  update[:rateLimitTimeWindow] = body[:rate_limit_time_window] if body.key?(:rate_limit_time_window)
614
699
  update[:rateLimitMax] = body[:rate_limit_max] if body.key?(:rate_limit_max)
615
700
  update[:expiresAt] = body[:expires_in].nil? ? nil : Time.now + body[:expires_in].to_i if body.key?(:expires_in)
616
- update[:metadata] = api_key_encode_json(body[:metadata]) if body.key?(:metadata)
701
+ update[:metadata] = api_key_encode_json(body[:metadata]) if body.key?(:metadata) && config[:enable_metadata]
617
702
  update[:permissions] = api_key_encode_json(body[:permissions]) if body.key?(:permissions)
618
703
  update
619
704
  end
@@ -622,16 +707,26 @@ module BetterAuth
622
707
  generator = config[:custom_key_generator]
623
708
  return generator.call({length: config[:default_key_length], prefix: prefix}) if generator.respond_to?(:call)
624
709
 
625
- "#{prefix}#{Array.new(config[:default_key_length].to_i) { ("A".."Z").to_a[SecureRandom.random_number(26)] }.join}"
710
+ alphabet = [*("a".."z"), *("A".."Z")]
711
+ "#{prefix}#{Array.new(config[:default_key_length].to_i) { alphabet[SecureRandom.random_number(alphabet.length)] }.join}"
626
712
  end
627
713
 
628
714
  def api_key_hash(key, config)
629
- config[:disable_key_hashing] ? key.to_s : Crypto.sha256(key.to_s, encoding: :base64url)
715
+ config[:disable_key_hashing] ? key.to_s : default_api_key_hasher(key)
716
+ end
717
+
718
+ def api_key_normalize_body(raw)
719
+ body = normalize_hash(raw)
720
+ return body unless raw.is_a?(Hash)
721
+
722
+ metadata_key = raw.key?(:metadata) ? :metadata : ("metadata" if raw.key?("metadata"))
723
+ body[:metadata] = raw[metadata_key] if metadata_key
724
+ body
630
725
  end
631
726
 
632
727
  def api_key_expires_at(body, config)
633
728
  if body.key?(:expires_in)
634
- Time.now + body[:expires_in].to_i
729
+ Time.now + body[:expires_in].to_i unless body[:expires_in].nil?
635
730
  elsif config[:key_expiration][:default_expires_in]
636
731
  Time.now + config[:key_expiration][:default_expires_in].to_i
637
732
  end
@@ -653,7 +748,9 @@ module BetterAuth
653
748
  return record if record
654
749
  return nil unless config[:fallback_to_database]
655
750
  end
656
- ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
751
+ record = ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
752
+ api_key_storage_set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
753
+ record
657
754
  end
658
755
 
659
756
  def api_key_find_by_id(ctx, id, config)
@@ -662,7 +759,9 @@ module BetterAuth
662
759
  return record if record
663
760
  return nil unless config[:fallback_to_database]
664
761
  end
665
- ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
762
+ record = ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
763
+ api_key_storage_set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
764
+ record
666
765
  end
667
766
 
668
767
  def api_key_list_for_user(ctx, user_id, config)
@@ -682,7 +781,9 @@ module BetterAuth
682
781
  end
683
782
  records = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "referenceId", value: reference_id}])
684
783
  legacy = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "userId", value: reference_id}])
685
- (records + legacy).uniq { |record| record["id"] }
784
+ combined = (records + legacy).uniq { |record| record["id"] }
785
+ api_key_storage_populate_reference(ctx, reference_id, combined, config) if config[:storage] == "secondary-storage" && config[:fallback_to_database]
786
+ combined
686
787
  end
687
788
 
688
789
  def api_key_update_record(ctx, record, update, config, defer: false)
@@ -710,8 +811,34 @@ module BetterAuth
710
811
  api_key_storage_delete(ctx, record, config) if config[:storage] == "secondary-storage"
711
812
  end
712
813
 
713
- def api_key_delete_expired(context, config)
814
+ def api_key_schedule_record_delete(ctx, record, config)
815
+ task = -> { api_key_delete_record(ctx, record, config) }
816
+ if config[:defer_updates] && api_key_background_tasks?(ctx)
817
+ ctx.context.run_in_background(task)
818
+ else
819
+ task.call
820
+ end
821
+ end
822
+
823
+ def api_key_schedule_cleanup(ctx, config)
824
+ task = -> { api_key_delete_expired(ctx.context, config) }
825
+ if config[:defer_updates] && api_key_background_tasks?(ctx)
826
+ ctx.context.run_in_background(task)
827
+ else
828
+ task.call
829
+ end
830
+ end
831
+
832
+ @api_key_last_expired_check = nil
833
+
834
+ def api_key_delete_expired(context, config, bypass_last_check: false)
714
835
  return unless config[:storage] == "database" || config[:fallback_to_database]
836
+ unless bypass_last_check
837
+ now = Time.now
838
+ return if @api_key_last_expired_check && ((now - @api_key_last_expired_check) * 1000) < 10_000
839
+
840
+ @api_key_last_expired_check = now
841
+ end
715
842
 
716
843
  expired = context.adapter.find_many(model: API_KEY_TABLE_NAME).select do |record|
717
844
  record["expiresAt"] && record["expiresAt"] < Time.now
@@ -734,35 +861,98 @@ module BetterAuth
734
861
 
735
862
  def api_key_storage_set(ctx, record, config)
736
863
  storage = api_key_storage(config, ctx.context)
737
- return unless storage
864
+ unless storage
865
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Secondary storage is required when storage mode is 'secondary-storage'")
866
+ end
738
867
 
739
868
  serialized = JSON.generate(api_key_storage_record(record))
740
869
  expires_at = api_key_normalize_time(record["expiresAt"])
741
870
  ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
742
871
  reference_id = api_key_record_reference_id(record)
743
- storage.set("api-key:#{record["key"]}", serialized, ttl)
744
- storage.set("api-key:by-id:#{record["id"]}", serialized, ttl)
745
872
  user_key = "api-key:by-ref:#{reference_id}"
746
- ids = JSON.parse(storage.get(user_key).to_s)
747
- ids << record["id"] unless ids.include?(record["id"])
748
- storage.set(user_key, JSON.generate(ids))
749
- rescue JSON::ParserError
750
- storage.set("api-key:by-ref:#{api_key_record_reference_id(record)}", JSON.generate([record["id"]]))
873
+
874
+ api_key_storage_batch(storage) do
875
+ operations = [
876
+ -> { storage.set("api-key:#{record["key"]}", serialized, ttl) },
877
+ -> { storage.set("api-key:by-id:#{record["id"]}", serialized, ttl) }
878
+ ]
879
+ operations << if config[:fallback_to_database]
880
+ # In fallback mode the ref list is a cache invalidated on writes
881
+ # to avoid races with concurrent writers of the same reference.
882
+ -> { storage.delete(user_key) }
883
+ else
884
+ -> { api_key_ref_list_add(storage, user_key, record["id"]) }
885
+ end
886
+ operations.each(&:call)
887
+ end
751
888
  end
752
889
 
753
890
  def api_key_storage_delete(ctx, record, config)
754
891
  storage = api_key_storage(config, ctx.context)
755
892
  return unless storage
756
893
 
757
- storage.delete("api-key:#{record["key"]}")
758
- storage.delete("api-key:by-id:#{record["id"]}")
759
- storage.delete("api-key:key:#{record["key"]}")
760
- storage.delete("api-key:id:#{record["id"]}")
761
- user_key = "api-key:by-ref:#{api_key_record_reference_id(record)}"
762
- ids = JSON.parse(storage.get(user_key).to_s).reject { |id| id == record["id"] }
894
+ reference_id = api_key_record_reference_id(record)
895
+ user_key = "api-key:by-ref:#{reference_id}"
896
+
897
+ api_key_storage_batch(storage) do
898
+ operations = [
899
+ -> { storage.delete("api-key:#{record["key"]}") },
900
+ -> { storage.delete("api-key:by-id:#{record["id"]}") },
901
+ # Ruby-only legacy storage layout cleanup; upstream never wrote here.
902
+ -> { storage.delete("api-key:key:#{record["key"]}") },
903
+ -> { storage.delete("api-key:id:#{record["id"]}") }
904
+ ]
905
+ operations << if config[:fallback_to_database]
906
+ -> { storage.delete(user_key) }
907
+ else
908
+ -> { api_key_ref_list_remove(storage, user_key, record["id"]) }
909
+ end
910
+ operations.each(&:call)
911
+ end
912
+ end
913
+
914
+ def api_key_ref_list_add(storage, user_key, id)
915
+ ids = api_key_safe_parse_id_list(storage.get(user_key))
916
+ ids << id unless ids.include?(id)
917
+ storage.set(user_key, JSON.generate(ids))
918
+ end
919
+
920
+ def api_key_ref_list_remove(storage, user_key, id)
921
+ ids = api_key_safe_parse_id_list(storage.get(user_key)).reject { |existing| existing == id }
763
922
  ids.empty? ? storage.delete(user_key) : storage.set(user_key, JSON.generate(ids))
923
+ end
924
+
925
+ def api_key_safe_parse_id_list(raw)
926
+ return [] if raw.nil?
927
+
928
+ parsed = JSON.parse(raw.to_s)
929
+ parsed.is_a?(Array) ? parsed : []
764
930
  rescue JSON::ParserError
765
- nil
931
+ []
932
+ end
933
+
934
+ def api_key_storage_batch(storage, &block)
935
+ if storage.respond_to?(:batch)
936
+ storage.batch(&block)
937
+ else
938
+ block.call
939
+ end
940
+ end
941
+
942
+ def api_key_storage_populate_reference(ctx, reference_id, records, config)
943
+ storage = api_key_storage(config, ctx.context)
944
+ return unless storage
945
+
946
+ ids = []
947
+ records.each do |record|
948
+ serialized = JSON.generate(api_key_storage_record(record))
949
+ expires_at = api_key_normalize_time(record["expiresAt"])
950
+ ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
951
+ storage.set("api-key:#{record["key"]}", serialized, ttl)
952
+ storage.set("api-key:by-id:#{record["id"]}", serialized, ttl)
953
+ ids << record["id"]
954
+ end
955
+ ids.empty? ? storage.delete("api-key:by-ref:#{reference_id}") : storage.set("api-key:by-ref:#{reference_id}", JSON.generate(ids))
766
956
  end
767
957
 
768
958
  def api_key_storage_record(record)
@@ -806,6 +996,10 @@ module BetterAuth
806
996
  ctx.context.options.advanced.dig(:background_tasks, :handler).respond_to?(:call)
807
997
  end
808
998
 
999
+ def api_key_auth_required?(ctx)
1000
+ !!(ctx.request || (ctx.headers && !ctx.headers.empty?))
1001
+ end
1002
+
809
1003
  def api_key_get_from_headers(ctx, config)
810
1004
  getter = config[:custom_api_key_getter]
811
1005
  return getter.call(ctx) if getter.respond_to?(:call)
@@ -821,11 +1015,9 @@ module BetterAuth
821
1015
  return if required.nil? || required == {}
822
1016
 
823
1017
  actual = api_key_decode_json(record["permissions"]) || {}
824
- required.each do |resource, actions|
825
- allowed = Array(actual[resource.to_s] || actual[resource.to_sym])
826
- unless Array(actions).all? { |action| allowed.include?(action) || allowed.include?(action.to_s) }
827
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"])
828
- end
1018
+ result = Role.new(actual).authorize(required)
1019
+ unless result[:success]
1020
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"], code: "KEY_NOT_FOUND")
829
1021
  end
830
1022
  end
831
1023
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: better_auth-api-key
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala