better_auth-api-key 0.1.0 → 0.2.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: 5a2a00e41f4d86078b25bb86e9b515412d48b732f1817a16a33de6916d4ac287
4
- data.tar.gz: 0cea4d53a5bee49499501804ea8a80db5a62555f4c5049b24bb1a7469f7eddcf
3
+ metadata.gz: df93f92692f0e4ce8a929b7c13ead11567450cb00052eae52b3d3505c82e0930
4
+ data.tar.gz: df7adda82f9f74ac01760f30ecd8013693a18f91c2fb799d961a9acc168cf78b
5
5
  SHA512:
6
- metadata.gz: ae64ad016758516945f7a76860196c381358183df9918f06d2c4588d746bbdb57ccf3c6143ecaf8a14074d33eadea558f321ad0295b6012393bc3a306b442b20
7
- data.tar.gz: 65b54fd1360d696467f09888e16c0c76b9681aa6ea9b59137b4c16f132f34993d5ebf30b8a63e99f711ab0da136f9eb1e1ab43de474ff023d6b846058b049296
6
+ metadata.gz: 0c592f30fac4c14c801eeedb43884c66b6ffac714e407e10e41ede39eab3c382341dd1143dc246ade4ebee9dc63ff9b7f13302da1291b4b4180434739df3d314
7
+ data.tar.gz: '087f99ab7f1e18be8afa5b3c48d64b579580b5468be4d3c387918c9a19f899087b1c695ae766144f05965050ba9cb9430ad02d64d30e53f6fae41270ed41e6b1'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-04-29
4
+
5
+ - 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.
6
+ - Expanded package documentation and executable coverage for upstream API key edge cases.
7
+
3
8
  ## 0.1.0
4
9
 
5
10
  - 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.0"
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},
@@ -180,14 +184,9 @@ module BetterAuth
180
184
  body = normalize_hash(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})
@@ -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
 
@@ -420,15 +428,33 @@ module BetterAuth
420
428
 
421
429
  def api_key_check_org_permission!(ctx, user_id, organization_id, action)
422
430
  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
431
+ unless org_plugin
432
+ raise APIError.new(
433
+ "INTERNAL_SERVER_ERROR",
434
+ message: API_KEY_ERROR_CODES["ORGANIZATION_PLUGIN_REQUIRED"],
435
+ code: "ORGANIZATION_PLUGIN_REQUIRED"
436
+ )
437
+ end
424
438
 
425
439
  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
440
+ unless member
441
+ raise APIError.new(
442
+ "FORBIDDEN",
443
+ message: API_KEY_ERROR_CODES["USER_NOT_MEMBER_OF_ORGANIZATION"],
444
+ code: "USER_NOT_MEMBER_OF_ORGANIZATION"
445
+ )
446
+ end
447
+
448
+ return member if member["role"].to_s == (org_plugin.options[:creator_role] || "owner").to_s
427
449
 
428
450
  permissions = {"apiKey" => [action]}
429
451
  return member if BetterAuth::Plugins.organization_permission?(ctx, org_plugin.options, member["role"], permissions, organization_id)
430
452
 
431
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INSUFFICIENT_API_KEY_PERMISSIONS"])
453
+ raise APIError.new(
454
+ "FORBIDDEN",
455
+ message: API_KEY_ERROR_CODES["INSUFFICIENT_API_KEY_PERMISSIONS"],
456
+ code: "INSUFFICIENT_API_KEY_PERMISSIONS"
457
+ )
432
458
  end
433
459
 
434
460
  def api_key_sort_records(records, sort_by, direction)
@@ -443,10 +469,31 @@ module BetterAuth
443
469
  end
444
470
  end
445
471
 
472
+ def api_key_validate_list_query!(query)
473
+ %i[limit offset].each do |key|
474
+ next unless query.key?(key)
475
+
476
+ value = query[key]
477
+ raise APIError.new("BAD_REQUEST", message: "Invalid #{key}") unless value.to_s.match?(/\A\d+\z/)
478
+ end
479
+
480
+ direction = query[:sort_direction]
481
+ return if direction.nil? || %w[asc desc].include?(direction.to_s.downcase)
482
+
483
+ raise APIError.new("BAD_REQUEST", message: "Invalid sortDirection")
484
+ end
485
+
446
486
  def api_key_error_code(error)
447
487
  API_KEY_ERROR_CODES.key(error.message) || error.code.to_s
448
488
  end
449
489
 
490
+ def api_key_error_payload(error)
491
+ payload = error.to_h
492
+ return payload if payload.is_a?(Hash) && payload.key?(:details)
493
+
494
+ {message: error.message, code: api_key_error_code(error)}
495
+ end
496
+
450
497
  def api_key_session_header_config(ctx, config)
451
498
  config.fetch(:configurations, [config]).find do |entry|
452
499
  entry[:enable_session_for_api_keys] && api_key_get_from_headers(ctx, entry)
@@ -466,6 +513,7 @@ module BetterAuth
466
513
  end
467
514
 
468
515
  record = api_key_validate!(ctx, key, config)
516
+ api_key_schedule_cleanup(ctx, config)
469
517
  if config[:references].to_s != "user"
470
518
  raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"])
471
519
  end
@@ -480,7 +528,7 @@ module BetterAuth
480
528
  "token" => key,
481
529
  "userId" => reference_id,
482
530
  "userAgent" => ctx.headers["user-agent"],
483
- "ipAddress" => ctx.headers["x-forwarded-for"],
531
+ "ipAddress" => RequestIP.client_ip(ctx.request || ctx.headers, ctx.context.options),
484
532
  "createdAt" => Time.now,
485
533
  "updatedAt" => Time.now,
486
534
  "expiresAt" => record["expiresAt"] || (Time.now + ctx.context.options.session[:expires_in].to_i)
@@ -493,16 +541,16 @@ module BetterAuth
493
541
  def api_key_validate!(ctx, key, config, permissions: nil)
494
542
  hashed = api_key_hash(key, config)
495
543
  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
544
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
545
+ 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])
546
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
499
547
  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"])
548
+ api_key_schedule_record_delete(ctx, record, config)
549
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_EXPIRED"])
502
550
  end
503
551
  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"])
552
+ api_key_schedule_record_delete(ctx, record, config)
553
+ raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
506
554
  end
507
555
 
508
556
  api_key_check_permissions!(record, permissions)
@@ -515,38 +563,57 @@ module BetterAuth
515
563
  now = Time.now
516
564
  update = {lastRequest: now, updatedAt: now}
517
565
 
518
- if api_key_rate_limited?(record, config, now)
519
- raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"])
566
+ if (try_again_in = api_key_rate_limit_try_again_in(record, config, now))
567
+ raise APIError.new(
568
+ "UNAUTHORIZED",
569
+ message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
570
+ code: "RATE_LIMITED",
571
+ body: {
572
+ message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
573
+ code: "RATE_LIMITED",
574
+ details: {tryAgainIn: try_again_in}
575
+ }
576
+ )
520
577
  end
521
- update[:requestCount] = api_key_next_request_count(record, now)
578
+ update[:requestCount] = api_key_next_request_count(record, now) if api_key_rate_limit_counts_requests?(record, config)
522
579
 
523
580
  remaining = record["remaining"]
524
581
  if !remaining.nil?
525
582
  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
583
+ last_refill = api_key_normalize_time(record["lastRefillAt"] || record["createdAt"])
584
+ if !last_refill || ((now - last_refill) * 1000) > record["refillInterval"].to_i
528
585
  remaining = record["refillAmount"].to_i
529
586
  update[:lastRefillAt] = now
530
587
  end
531
588
  end
532
- raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
589
+ raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
533
590
 
534
591
  update[:remaining] = remaining.to_i - 1
535
592
  end
536
593
  update
537
594
  end
538
595
 
539
- def api_key_rate_limited?(record, config, now)
540
- return false if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
596
+ def api_key_rate_limit_try_again_in(record, config, now)
597
+ return nil if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
541
598
 
542
599
  window = record["rateLimitTimeWindow"]
543
600
  max = record["rateLimitMax"]
544
- return false if window.nil? || max.nil?
601
+ return nil if window.nil? || max.nil?
545
602
 
546
603
  last = api_key_normalize_time(record["lastRequest"])
547
- return false unless last && ((now - last) * 1000) <= window.to_i
604
+ return nil unless last
605
+
606
+ elapsed = (now - last) * 1000
607
+ return nil if elapsed > window.to_i
608
+ return nil unless record["requestCount"].to_i >= max.to_i
548
609
 
549
- record["requestCount"].to_i >= max.to_i
610
+ (window.to_i - elapsed).ceil
611
+ end
612
+
613
+ def api_key_rate_limit_counts_requests?(record, config)
614
+ return false if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
615
+
616
+ !record["rateLimitTimeWindow"].nil? && !record["rateLimitMax"].nil?
550
617
  end
551
618
 
552
619
  def api_key_next_request_count(record, now)
@@ -578,23 +645,25 @@ module BetterAuth
578
645
  minimum = create ? 0 : 1
579
646
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_REMAINING"]) if body[:remaining].to_i < minimum
580
647
  end
581
- if body.key?(:metadata)
648
+ if body[:metadata] && (create || config[:enable_metadata])
582
649
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["METADATA_DISABLED"]) unless config[:enable_metadata]
583
650
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_METADATA_TYPE"]) unless body[:metadata].nil? || body[:metadata].is_a?(Hash)
584
651
  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) }
652
+ server_only_keys = %i[refill_amount refill_interval rate_limit_max rate_limit_time_window rate_limit_enabled remaining permissions]
653
+ if client && server_only_keys.any? { |key| (create && key == :remaining) ? !body[:remaining].nil? : body.key?(key) }
588
654
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["SERVER_ONLY_PROPERTY"])
589
655
  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]
656
+ amount_present = body.key?(:refill_amount)
657
+ interval_present = body.key?(:refill_interval)
658
+ if amount_present && !interval_present
594
659
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_AMOUNT_AND_INTERVAL_REQUIRED"])
595
660
  end
661
+ if interval_present && !amount_present
662
+ raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_INTERVAL_AND_AMOUNT_REQUIRED"])
663
+ end
596
664
  if body.key?(:expires_in)
597
665
  raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["KEY_DISABLED_EXPIRATION"]) if config[:key_expiration][:disable_custom_expires_time]
666
+ return if body[:expires_in].nil?
598
667
 
599
668
  days = body[:expires_in].to_f / 86_400
600
669
  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 +671,7 @@ module BetterAuth
602
671
  end
603
672
  end
604
673
 
605
- def api_key_update_payload(body, _config)
674
+ def api_key_update_payload(body, config)
606
675
  update = {}
607
676
  update[:name] = body[:name] if body.key?(:name)
608
677
  update[:enabled] = body[:enabled] unless body[:enabled].nil?
@@ -613,7 +682,7 @@ module BetterAuth
613
682
  update[:rateLimitTimeWindow] = body[:rate_limit_time_window] if body.key?(:rate_limit_time_window)
614
683
  update[:rateLimitMax] = body[:rate_limit_max] if body.key?(:rate_limit_max)
615
684
  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)
685
+ update[:metadata] = api_key_encode_json(body[:metadata]) if body.key?(:metadata) && config[:enable_metadata]
617
686
  update[:permissions] = api_key_encode_json(body[:permissions]) if body.key?(:permissions)
618
687
  update
619
688
  end
@@ -622,16 +691,17 @@ module BetterAuth
622
691
  generator = config[:custom_key_generator]
623
692
  return generator.call({length: config[:default_key_length], prefix: prefix}) if generator.respond_to?(:call)
624
693
 
625
- "#{prefix}#{Array.new(config[:default_key_length].to_i) { ("A".."Z").to_a[SecureRandom.random_number(26)] }.join}"
694
+ alphabet = [*("a".."z"), *("A".."Z")]
695
+ "#{prefix}#{Array.new(config[:default_key_length].to_i) { alphabet[SecureRandom.random_number(alphabet.length)] }.join}"
626
696
  end
627
697
 
628
698
  def api_key_hash(key, config)
629
- config[:disable_key_hashing] ? key.to_s : Crypto.sha256(key.to_s, encoding: :base64url)
699
+ config[:disable_key_hashing] ? key.to_s : default_api_key_hasher(key)
630
700
  end
631
701
 
632
702
  def api_key_expires_at(body, config)
633
703
  if body.key?(:expires_in)
634
- Time.now + body[:expires_in].to_i
704
+ Time.now + body[:expires_in].to_i unless body[:expires_in].nil?
635
705
  elsif config[:key_expiration][:default_expires_in]
636
706
  Time.now + config[:key_expiration][:default_expires_in].to_i
637
707
  end
@@ -653,7 +723,9 @@ module BetterAuth
653
723
  return record if record
654
724
  return nil unless config[:fallback_to_database]
655
725
  end
656
- ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
726
+ record = ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
727
+ api_key_storage_set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
728
+ record
657
729
  end
658
730
 
659
731
  def api_key_find_by_id(ctx, id, config)
@@ -662,7 +734,9 @@ module BetterAuth
662
734
  return record if record
663
735
  return nil unless config[:fallback_to_database]
664
736
  end
665
- ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
737
+ record = ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
738
+ api_key_storage_set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
739
+ record
666
740
  end
667
741
 
668
742
  def api_key_list_for_user(ctx, user_id, config)
@@ -682,7 +756,9 @@ module BetterAuth
682
756
  end
683
757
  records = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "referenceId", value: reference_id}])
684
758
  legacy = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "userId", value: reference_id}])
685
- (records + legacy).uniq { |record| record["id"] }
759
+ combined = (records + legacy).uniq { |record| record["id"] }
760
+ api_key_storage_populate_reference(ctx, reference_id, combined, config) if config[:storage] == "secondary-storage" && config[:fallback_to_database]
761
+ combined
686
762
  end
687
763
 
688
764
  def api_key_update_record(ctx, record, update, config, defer: false)
@@ -710,8 +786,34 @@ module BetterAuth
710
786
  api_key_storage_delete(ctx, record, config) if config[:storage] == "secondary-storage"
711
787
  end
712
788
 
713
- def api_key_delete_expired(context, config)
789
+ def api_key_schedule_record_delete(ctx, record, config)
790
+ task = -> { api_key_delete_record(ctx, record, config) }
791
+ if config[:defer_updates] && api_key_background_tasks?(ctx)
792
+ ctx.context.run_in_background(task)
793
+ else
794
+ task.call
795
+ end
796
+ end
797
+
798
+ def api_key_schedule_cleanup(ctx, config)
799
+ task = -> { api_key_delete_expired(ctx.context, config) }
800
+ if config[:defer_updates] && api_key_background_tasks?(ctx)
801
+ ctx.context.run_in_background(task)
802
+ else
803
+ task.call
804
+ end
805
+ end
806
+
807
+ @api_key_last_expired_check = nil
808
+
809
+ def api_key_delete_expired(context, config, bypass_last_check: false)
714
810
  return unless config[:storage] == "database" || config[:fallback_to_database]
811
+ unless bypass_last_check
812
+ now = Time.now
813
+ return if @api_key_last_expired_check && ((now - @api_key_last_expired_check) * 1000) < 10_000
814
+
815
+ @api_key_last_expired_check = now
816
+ end
715
817
 
716
818
  expired = context.adapter.find_many(model: API_KEY_TABLE_NAME).select do |record|
717
819
  record["expiresAt"] && record["expiresAt"] < Time.now
@@ -734,35 +836,98 @@ module BetterAuth
734
836
 
735
837
  def api_key_storage_set(ctx, record, config)
736
838
  storage = api_key_storage(config, ctx.context)
737
- return unless storage
839
+ unless storage
840
+ raise APIError.new("INTERNAL_SERVER_ERROR", message: "Secondary storage is required when storage mode is 'secondary-storage'")
841
+ end
738
842
 
739
843
  serialized = JSON.generate(api_key_storage_record(record))
740
844
  expires_at = api_key_normalize_time(record["expiresAt"])
741
845
  ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
742
846
  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
847
  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"]]))
848
+
849
+ api_key_storage_batch(storage) do
850
+ operations = [
851
+ -> { storage.set("api-key:#{record["key"]}", serialized, ttl) },
852
+ -> { storage.set("api-key:by-id:#{record["id"]}", serialized, ttl) }
853
+ ]
854
+ operations << if config[:fallback_to_database]
855
+ # In fallback mode the ref list is a cache invalidated on writes
856
+ # to avoid races with concurrent writers of the same reference.
857
+ -> { storage.delete(user_key) }
858
+ else
859
+ -> { api_key_ref_list_add(storage, user_key, record["id"]) }
860
+ end
861
+ operations.each(&:call)
862
+ end
751
863
  end
752
864
 
753
865
  def api_key_storage_delete(ctx, record, config)
754
866
  storage = api_key_storage(config, ctx.context)
755
867
  return unless storage
756
868
 
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"] }
869
+ reference_id = api_key_record_reference_id(record)
870
+ user_key = "api-key:by-ref:#{reference_id}"
871
+
872
+ api_key_storage_batch(storage) do
873
+ operations = [
874
+ -> { storage.delete("api-key:#{record["key"]}") },
875
+ -> { storage.delete("api-key:by-id:#{record["id"]}") },
876
+ # Ruby-only legacy storage layout cleanup; upstream never wrote here.
877
+ -> { storage.delete("api-key:key:#{record["key"]}") },
878
+ -> { storage.delete("api-key:id:#{record["id"]}") }
879
+ ]
880
+ operations << if config[:fallback_to_database]
881
+ -> { storage.delete(user_key) }
882
+ else
883
+ -> { api_key_ref_list_remove(storage, user_key, record["id"]) }
884
+ end
885
+ operations.each(&:call)
886
+ end
887
+ end
888
+
889
+ def api_key_ref_list_add(storage, user_key, id)
890
+ ids = api_key_safe_parse_id_list(storage.get(user_key))
891
+ ids << id unless ids.include?(id)
892
+ storage.set(user_key, JSON.generate(ids))
893
+ end
894
+
895
+ def api_key_ref_list_remove(storage, user_key, id)
896
+ ids = api_key_safe_parse_id_list(storage.get(user_key)).reject { |existing| existing == id }
763
897
  ids.empty? ? storage.delete(user_key) : storage.set(user_key, JSON.generate(ids))
898
+ end
899
+
900
+ def api_key_safe_parse_id_list(raw)
901
+ return [] if raw.nil?
902
+
903
+ parsed = JSON.parse(raw.to_s)
904
+ parsed.is_a?(Array) ? parsed : []
764
905
  rescue JSON::ParserError
765
- nil
906
+ []
907
+ end
908
+
909
+ def api_key_storage_batch(storage, &block)
910
+ if storage.respond_to?(:batch)
911
+ storage.batch(&block)
912
+ else
913
+ block.call
914
+ end
915
+ end
916
+
917
+ def api_key_storage_populate_reference(ctx, reference_id, records, config)
918
+ storage = api_key_storage(config, ctx.context)
919
+ return unless storage
920
+
921
+ ids = []
922
+ records.each do |record|
923
+ serialized = JSON.generate(api_key_storage_record(record))
924
+ expires_at = api_key_normalize_time(record["expiresAt"])
925
+ ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
926
+ storage.set("api-key:#{record["key"]}", serialized, ttl)
927
+ storage.set("api-key:by-id:#{record["id"]}", serialized, ttl)
928
+ ids << record["id"]
929
+ end
930
+ ids.empty? ? storage.delete("api-key:by-ref:#{reference_id}") : storage.set("api-key:by-ref:#{reference_id}", JSON.generate(ids))
766
931
  end
767
932
 
768
933
  def api_key_storage_record(record)
@@ -806,6 +971,10 @@ module BetterAuth
806
971
  ctx.context.options.advanced.dig(:background_tasks, :handler).respond_to?(:call)
807
972
  end
808
973
 
974
+ def api_key_auth_required?(ctx)
975
+ !!(ctx.request || (ctx.headers && !ctx.headers.empty?))
976
+ end
977
+
809
978
  def api_key_get_from_headers(ctx, config)
810
979
  getter = config[:custom_api_key_getter]
811
980
  return getter.call(ctx) if getter.respond_to?(:call)
@@ -821,11 +990,9 @@ module BetterAuth
821
990
  return if required.nil? || required == {}
822
991
 
823
992
  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
993
+ result = Role.new(actual).authorize(required)
994
+ unless result[:success]
995
+ raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"], code: "KEY_NOT_FOUND")
829
996
  end
830
997
  end
831
998
 
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala