better_auth-api-key 0.10.0 → 0.11.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: '0644878a04c3b9f3489304b324a9636aed5411eefc8b8f51ec89f15a5b978cf1'
4
- data.tar.gz: fc6aa4568b7831ec89a1fbab0abd5191b0d27bf5f5afc84615c038748dce9bd0
3
+ metadata.gz: e054d9e429e2c199d594467dcc924a9ef44c22c0be63f96e240f47308e4444ef
4
+ data.tar.gz: d34741924ce2951654646c1127f0d3caa09d16cd765f58647220ff6c71221e57
5
5
  SHA512:
6
- metadata.gz: 2790364cbe835dd0c368aa9a035a57653370ab5d7f06c13fe1967ef04442a1cf3554a61056c920d80d0834ba9fd1c4963206ba46cb9b5f2bea77d46bcef59f42
7
- data.tar.gz: 170f42d1487f231cb0302a63cd26e90d72f0560f0c0cc9ad5cbb05a7863d842c70517699432afa800c9070a6d1cbbf44e924dfca361972568a0dddae8759cfd8
6
+ metadata.gz: b8220f1cc0645105e0666c7d8184d43c72bb4e050094f4aa6cfb411b61f0622e2b0c2c4c21d50b7c586b82a80d96b7855c36b6e93b0dbbb94db2d87fd0456f2d
7
+ data.tar.gz: 5c1d00a8dc01502bc6709d0419195548eec8ad816846f9be05f58a3fa8c477305df53ba3722344f268b223b19b7e4ededd4bd138d1c1eca36a060c42872f6e02
data/CHANGELOG.md CHANGED
@@ -2,6 +2,36 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Hardened API-key verification and counter updates where authoritative adapter
6
+ operations are available, with regression coverage for stale-write cases.
7
+ - Added a checked upstream test inventory for this plugin's pinned reference
8
+ files.
9
+
10
+ ## [0.11.0](https://github.com/salasebas/better-auth-rb/compare/better_auth-api-key-v0.10.0...better_auth-api-key/v0.11.0) (2026-07-23)
11
+
12
+
13
+ ### ⚠ BREAKING CHANGES
14
+
15
+ * **release:** OpenAuth alias gems, the openauth executable, and the better_auth_rails alias gem and require path are removed.
16
+
17
+ ### Features
18
+
19
+ * **schema:** align migration plugin schema parity ([c0261dc](https://github.com/salasebas/better-auth-rb/commit/c0261dc1e5fd649557cd8a57b92c0609620c3767))
20
+
21
+
22
+ ### Bug Fixes
23
+
24
+ * **api-key:** load access plugin before permission checks ([985e56c](https://github.com/salasebas/better-auth-rb/commit/985e56cdc29298aac995ff7d0f04a7f1bf13a7cd))
25
+ * **api-key:** make verification counters authoritative ([6550ae7](https://github.com/salasebas/better-auth-rb/commit/6550ae753f104f771e313360f56e6fdcff82d005))
26
+ * **ci:** restore package .ruby-version files for setup-ruby ([c18d220](https://github.com/salasebas/better-auth-rb/commit/c18d220065f3cb7cd2504bef7aa407e2c0c027eb))
27
+ * load external plugin gems without stub recursion ([4926bae](https://github.com/salasebas/better-auth-rb/commit/4926bae7520c17438de25e99cb2155839c177493))
28
+ * **saml:** fail closed without response parser ([#35](https://github.com/salasebas/better-auth-rb/issues/35)) ([0f9a7a4](https://github.com/salasebas/better-auth-rb/commit/0f9a7a4fb841153951b5f00ce42f01eee41b3112))
29
+
30
+
31
+ ### Miscellaneous Chores
32
+
33
+ * **release:** retire aliases and refresh parity tooling ([e099464](https://github.com/salasebas/better-auth-rb/commit/e0994643694267508a3c4d9be020bb1fd0e2e5a3))
34
+
5
35
  ## 0.10.0 - 2026-05-21
6
36
 
7
37
  - Improved adapter coverage and Redis-backed storage behavior for API key flows.
data/LICENSE.md ADDED
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+ Copyright (c) 2024 - present
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ this software and associated documentation files (the "Software"), to deal in
6
+ the Software without restriction, including without limitation the rights to
7
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ the Software, and to permit persons to whom the Software is furnished to do so,
9
+ subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
19
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
20
+ DEALINGS IN THE SOFTWARE.
data/README.md CHANGED
@@ -64,10 +64,10 @@ purge secondary-only keys.
64
64
  The scheduled expired-key cleanup throttle is per Ruby process. It is not
65
65
  coordinated across web workers, hosts, or background job runners.
66
66
 
67
- When `defer_updates` is combined with `advanced.background_tasks.handler`, usage
68
- updates such as request counts, remaining limits, refill state, and scheduled
69
- cleanup can be reordered under concurrency. Use database transactions or
70
- deployment-level coordination if strict counters are required.
67
+ `defer_updates` can defer explicit API-key updates and cleanup when a background
68
+ task handler is configured. Verification counter claims remain synchronous;
69
+ database-backed claims use guarded atomic operations, while pure secondary-only
70
+ claims retain the best-effort limitation described below.
71
71
 
72
72
  ## Configuration
73
73
 
@@ -119,7 +119,21 @@ BetterAuth::Plugins.api_key([
119
119
 
120
120
  Organization-owned keys require `BetterAuth::Plugins.organization` and use organization permissions for `apiKey` actions: `create`, `read`, `update`, and `delete`.
121
121
 
122
- 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.
122
+ Secondary-storage mode uses upstream storage keys such as `api-key:<hash>`, `api-key:by-id:<id>`, and `api-key:by-ref:<referenceId>`. With `fallback_to_database: true`, the database is authoritative: verification re-reads the row after a cache hit, and a missing authoritative row invalidates the cache and can never authorize. A cache entry may remain stale, or be briefly re-created from a row, during the unavoidable cross-store window after a database update and before its cache write; that does not weaken verification because authorization always requires the authoritative row. Generic custom storage gets an in-process lock for reference-list updates only; RedisStorage uses atomic cross-process JSON-list scripts.
123
+
124
+ Verification rate-limit failures return HTTP `429` with error code
125
+ `RATE_LIMITED` and `details.tryAgainIn` (authentication failures remain `401`).
126
+ An exhausted non-refillable key remains an inert authoritative row and returns
127
+ `USAGE_EXCEEDED`; Ruby deliberately avoids eager deletion during verification
128
+ because deletion cannot be made cross-process atomic with the winning counter
129
+ update. Cleanup routes/jobs can remove such rows separately.
130
+ Database-backed keys, including `secondary-storage` with
131
+ `fallback_to_database: true`, consume remaining and rate-limit counters with
132
+ guarded atomic updates; concurrent requests cannot drive `remaining` below zero
133
+ or exceed the configured window. Pure secondary-storage deployments do not have
134
+ a guarded JSON-record counter primitive and are therefore best-effort under
135
+ cross-process concurrency. Use `fallback_to_database` when strict enforcement
136
+ is required.
123
137
 
124
138
  ## Storage layout
125
139
 
@@ -10,6 +10,8 @@ module BetterAuth
10
10
  HASH_STORAGE_PREFIX = "api-key:"
11
11
  ID_STORAGE_PREFIX = "api-key:by-id:"
12
12
  REFERENCE_STORAGE_PREFIX = "api-key:by-ref:"
13
+ REFERENCE_LOCKS_GUARD = Mutex.new
14
+ REFERENCE_LOCK_STRIPE_COUNT = 256
13
15
 
14
16
  module_function
15
17
 
@@ -197,14 +199,40 @@ module BetterAuth
197
199
  end
198
200
 
199
201
  def ref_list_add(storage_instance, reference_key, id)
200
- ids = safe_parse_id_list(storage_instance.get(reference_key))
201
- ids << id unless ids.include?(id)
202
- storage_instance.set(reference_key, JSON.generate(ids))
202
+ if storage_instance.respond_to?(:json_list_add)
203
+ storage_instance.json_list_add(reference_key, id)
204
+ return
205
+ end
206
+
207
+ with_reference_lock(reference_key) do
208
+ ids = safe_parse_id_list(storage_instance.get(reference_key))
209
+ ids << id unless ids.include?(id)
210
+ storage_instance.set(reference_key, JSON.generate(ids))
211
+ end
203
212
  end
204
213
 
205
214
  def ref_list_remove(storage_instance, reference_key, id)
206
- ids = safe_parse_id_list(storage_instance.get(reference_key)).reject { |existing| existing == id }
207
- ids.empty? ? storage_instance.delete(reference_key) : storage_instance.set(reference_key, JSON.generate(ids))
215
+ if storage_instance.respond_to?(:json_list_remove)
216
+ storage_instance.json_list_remove(reference_key, id)
217
+ return
218
+ end
219
+
220
+ with_reference_lock(reference_key) do
221
+ ids = safe_parse_id_list(storage_instance.get(reference_key)).reject { |existing| existing == id }
222
+ ids.empty? ? storage_instance.delete(reference_key) : storage_instance.set(reference_key, JSON.generate(ids))
223
+ end
224
+ end
225
+
226
+ # Custom secondary-storage implementations expose only get/set/delete,
227
+ # so serialize reference-list read/modify/write operations within this
228
+ # Ruby process. This lock is deliberately not treated as distributed
229
+ # safety; RedisStorage uses its atomic JSON-list scripts above.
230
+ def with_reference_lock(reference_key)
231
+ lock = REFERENCE_LOCKS_GUARD.synchronize do
232
+ @reference_lock_stripes ||= Array.new(REFERENCE_LOCK_STRIPE_COUNT) { Mutex.new }
233
+ @reference_lock_stripes[reference_key.hash % REFERENCE_LOCK_STRIPE_COUNT]
234
+ end
235
+ lock.synchronize { yield }
208
236
  end
209
237
 
210
238
  def safe_parse_id_list(raw)
@@ -5,6 +5,36 @@ module BetterAuth
5
5
  module RateLimit
6
6
  module_function
7
7
 
8
+ # Pure decision function used by the verifier before applying a guarded
9
+ # counter mutation. Keeping the decision separate from the write avoids
10
+ # persisting a stale snapshot when another verifier wins the race.
11
+ def evaluate(record, config, now = Time.now)
12
+ return {type: :skip, last_request: now} if config[:rate_limit][:enabled] == false
13
+ return {type: :skip, last_request: now} if record["rateLimitEnabled"] == false
14
+
15
+ window = record["rateLimitTimeWindow"]
16
+ max = record["rateLimitMax"]
17
+ return {type: :skip, last_request: nil} if window.nil? || max.nil?
18
+
19
+ last = Utils.normalize_time(record["lastRequest"])
20
+ return {type: :start, now: now} unless last
21
+
22
+ elapsed_ms = (now - last) * 1000
23
+ if elapsed_ms > window.to_i
24
+ return {type: :reset, now: now, window_start: now - (window.to_i / 1000.0)}
25
+ end
26
+
27
+ if record["requestCount"].to_i >= max.to_i
28
+ return {
29
+ type: :deny,
30
+ try_again_in: [(window.to_i - elapsed_ms).ceil, 0].max,
31
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"]
32
+ }
33
+ end
34
+
35
+ {type: :increment, now: now, max: max.to_i, window_start: now - (window.to_i / 1000.0)}
36
+ end
37
+
8
38
  def try_again_in(record, config, now)
9
39
  return nil if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
10
40
 
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module APIKey
5
5
  module Routes
6
6
  module CreateAPIKey
7
- UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/create-api-key.ts"
7
+ UPSTREAM_SOURCE = "reference/upstream-src/1.6.9/repository/packages/api-key/src/routes/create-api-key.ts"
8
8
 
9
9
  module_function
10
10
 
@@ -12,7 +12,10 @@ module BetterAuth
12
12
  BetterAuth::Endpoint.new(path: "/api-key/create", method: "POST", metadata: Routes.openapi_for(:create_api_key)) do |ctx|
13
13
  body = BetterAuth::Plugins.api_key_normalize_body(ctx.body)
14
14
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
- session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
15
+ # API-key ownership and authorization are sensitive operations. A
16
+ # cookie-cache snapshot may outlive a revoked session, so force an
17
+ # authoritative session read whenever stateful storage is active.
18
+ session = BetterAuth::Routes.current_session(ctx, allow_nil: true, sensitive: true)
16
19
  if !session && BetterAuth::Plugins.api_key_auth_required?(ctx)
17
20
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
18
21
  end
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module APIKey
5
5
  module Routes
6
6
  module DeleteAllExpiredAPIKeys
7
- UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/delete-all-expired-api-keys.ts"
7
+ UPSTREAM_SOURCE = "reference/upstream-src/1.6.9/repository/packages/api-key/src/routes/delete-all-expired-api-keys.ts"
8
8
 
9
9
  module_function
10
10
 
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module APIKey
5
5
  module Routes
6
6
  module DeleteAPIKey
7
- UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/delete-api-key.ts"
7
+ UPSTREAM_SOURCE = "reference/upstream-src/1.6.9/repository/packages/api-key/src/routes/delete-api-key.ts"
8
8
 
9
9
  module_function
10
10
 
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module APIKey
5
5
  module Routes
6
6
  module GetAPIKey
7
- UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/get-api-key.ts"
7
+ UPSTREAM_SOURCE = "reference/upstream-src/1.6.9/repository/packages/api-key/src/routes/get-api-key.ts"
8
8
 
9
9
  module_function
10
10
 
@@ -98,6 +98,14 @@ module BetterAuth
98
98
  end
99
99
 
100
100
  def verify_api_key_openapi
101
+ response_schema = BetterAuth::OpenAPI.object_schema(
102
+ {
103
+ valid: {type: "boolean"},
104
+ error: {type: ["object", "null"], additionalProperties: true},
105
+ key: api_key_record_schema(include_secret: false).merge(type: ["object", "null"])
106
+ },
107
+ required: ["valid", "error", "key"]
108
+ )
101
109
  {
102
110
  openapi: {
103
111
  description: "Verify and rate-limit an API key",
@@ -114,15 +122,10 @@ module BetterAuth
114
122
  responses: {
115
123
  "200" => BetterAuth::OpenAPI.json_response(
116
124
  "API key verification result",
117
- BetterAuth::OpenAPI.object_schema(
118
- {
119
- valid: {type: "boolean"},
120
- error: {type: ["object", "null"], additionalProperties: true},
121
- key: api_key_record_schema(include_secret: false).merge(type: ["object", "null"])
122
- },
123
- required: ["valid", "error", "key"]
124
- )
125
- )
125
+ response_schema
126
+ ),
127
+ "401" => BetterAuth::OpenAPI.json_response("API key authentication failed", response_schema),
128
+ "429" => BetterAuth::OpenAPI.json_response("API key usage or rate limit exceeded", response_schema)
126
129
  }
127
130
  }
128
131
  }
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module APIKey
5
5
  module Routes
6
6
  module ListAPIKeys
7
- UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/list-api-keys.ts"
7
+ UPSTREAM_SOURCE = "reference/upstream-src/1.6.9/repository/packages/api-key/src/routes/list-api-keys.ts"
8
8
 
9
9
  module_function
10
10
 
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module APIKey
5
5
  module Routes
6
6
  module UpdateAPIKey
7
- UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/update-api-key.ts"
7
+ UPSTREAM_SOURCE = "reference/upstream-src/1.6.9/repository/packages/api-key/src/routes/update-api-key.ts"
8
8
 
9
9
  module_function
10
10
 
@@ -12,7 +12,8 @@ module BetterAuth
12
12
  BetterAuth::Endpoint.new(path: "/api-key/update", method: "POST", metadata: Routes.openapi_for(:update_api_key)) do |ctx|
13
13
  body = BetterAuth::Plugins.api_key_normalize_body(ctx.body)
14
14
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
- session = BetterAuth::Routes.current_session(ctx, allow_nil: true)
15
+ # Updates must not authorize from a stale cookie-cache session.
16
+ session = BetterAuth::Routes.current_session(ctx, allow_nil: true, sensitive: true)
16
17
  if !session && BetterAuth::Plugins.api_key_auth_required?(ctx)
17
18
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
18
19
  end
@@ -4,7 +4,7 @@ module BetterAuth
4
4
  module APIKey
5
5
  module Routes
6
6
  module VerifyAPIKey
7
- UPSTREAM_SOURCE = "upstream/packages/api-key/src/routes/verify-api-key.ts"
7
+ UPSTREAM_SOURCE = "reference/upstream-src/1.6.9/repository/packages/api-key/src/routes/verify-api-key.ts"
8
8
 
9
9
  module_function
10
10
 
@@ -20,7 +20,7 @@ module BetterAuth
20
20
  key = body[:key]
21
21
  if key.to_s.empty?
22
22
  raise BetterAuth::APIError.new(
23
- "FORBIDDEN",
23
+ "UNAUTHORIZED",
24
24
  message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"],
25
25
  code: "INVALID_API_KEY"
26
26
  )
@@ -30,7 +30,11 @@ module BetterAuth
30
30
  validation_config ||= resolved_config
31
31
  validator = validation_config[:custom_api_key_validator]
32
32
  if validator.respond_to?(:call) && !validator.call({ctx: ctx, key: key})
33
- ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "KEY_NOT_FOUND"}, key: nil})
33
+ raise BetterAuth::APIError.new(
34
+ "UNAUTHORIZED",
35
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"],
36
+ code: "KEY_NOT_FOUND"
37
+ )
34
38
  else
35
39
  record = BetterAuth::Plugins.api_key_validate!(ctx, key, validation_config, permissions: body[:permissions])
36
40
  record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
@@ -39,7 +43,13 @@ module BetterAuth
39
43
  end
40
44
  rescue BetterAuth::APIError => error
41
45
  ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
42
- ctx.json({valid: false, error: BetterAuth::Plugins.api_key_error_payload(error), key: nil})
46
+ payload = BetterAuth::Plugins.api_key_error_payload(error)
47
+ payload[:code] = error.code if error.code == "KEY_NOT_FOUND"
48
+ ctx.json(
49
+ {valid: false, error: payload, key: nil},
50
+ status: error.status_code,
51
+ headers: error.headers
52
+ )
43
53
  rescue => error
44
54
  ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
45
55
  ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY"}, key: nil})
@@ -5,13 +5,6 @@ module BetterAuth
5
5
  module Validation
6
6
  module_function
7
7
 
8
- USAGE_LOCK_STRIPE_COUNT = 256
9
-
10
- def usage_lock_for(key)
11
- @usage_lock_stripes ||= Array.new(USAGE_LOCK_STRIPE_COUNT) { Mutex.new }
12
- @usage_lock_stripes[key.hash % USAGE_LOCK_STRIPE_COUNT]
13
- end
14
-
15
8
  def validate_create_update!(body, config, create:, client:)
16
9
  name = body[:name]
17
10
  if create && config[:require_name] && name.to_s.empty?
@@ -31,6 +24,9 @@ module BetterAuth
31
24
  minimum = create ? 0 : 1
32
25
  raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_REMAINING"]) if body[:remaining].to_i < minimum
33
26
  end
27
+ if body.key?(:refill_amount) && !body[:refill_amount].nil? && body[:refill_amount].to_i < 1
28
+ raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_REMAINING"])
29
+ end
34
30
  if body[:metadata] && (create || config[:enable_metadata])
35
31
  raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["METADATA_DISABLED"]) unless config[:enable_metadata]
36
32
  raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_METADATA_TYPE"]) unless body[:metadata].nil? || body[:metadata].is_a?(Hash)
@@ -75,73 +71,259 @@ module BetterAuth
75
71
 
76
72
  def validate_api_key!(ctx, key, config, permissions: nil)
77
73
  hashed = BetterAuth::APIKey::Keys.hash(key, config)
78
- usage_lock_for(hashed).synchronize do
79
- record = BetterAuth::APIKey::Adapter.find_by_hash(ctx, hashed, config)
80
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
81
- unless BetterAuth::APIKey::Routes.config_id_matches?(BetterAuth::APIKey::Types.record_config_id(record), config[:config_id])
82
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"])
83
- end
84
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
85
- if record["expiresAt"] && record["expiresAt"] <= Time.now
86
- BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
87
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_EXPIRED"])
88
- end
89
- if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].nil?
90
- BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
91
- raise BetterAuth::APIError.new("TOO_MANY_REQUESTS", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
74
+ record = BetterAuth::APIKey::Adapter.find_by_hash(ctx, hashed, config)
75
+
76
+ # In fallback mode the database is authoritative. A cache hit must be
77
+ # re-read so revocation, expiry, permission, and counter updates cannot
78
+ # be hidden by a stale secondary snapshot.
79
+ if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
80
+ authoritative = ctx.context.adapter.find_one(
81
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
82
+ where: [{field: "key", value: hashed}]
83
+ )
84
+ unless authoritative
85
+ BetterAuth::APIKey::Adapter.delete(ctx, record, config)
86
+ record = nil
92
87
  end
88
+ record = authoritative if authoritative
89
+ end
90
+
91
+ raise invalid_api_key_error unless record
92
+ # Adapters are expected to return row snapshots. The in-memory test
93
+ # adapter exposes its backing hash directly, so copy the top-level row
94
+ # before evaluating guards to avoid one request mutating another's
95
+ # decision snapshot.
96
+ record = record.dup
97
+ unless BetterAuth::APIKey::Routes.config_id_matches?(BetterAuth::APIKey::Types.record_config_id(record), config[:config_id])
98
+ raise invalid_api_key_error
99
+ end
100
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
101
+ if record["expiresAt"] && BetterAuth::APIKey::Utils.normalize_time(record["expiresAt"]) <= Time.now
102
+ BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
103
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_EXPIRED"])
104
+ end
105
+ # Keep an exhausted row inert until an explicit cleanup pass. Eager
106
+ # deletion here can race a concurrent winner between its guarded quota
107
+ # decrement and final row refresh, causing a valid request to observe a
108
+ # missing row. The database remains authoritative and the next request
109
+ # is still rejected with USAGE_EXCEEDED.
110
+ if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].to_i <= 0
111
+ raise usage_exceeded_error
112
+ end
113
+
114
+ check_permissions!(record, permissions)
115
+ updated = if config[:storage] == "database" || config[:fallback_to_database]
116
+ claim_usage_in_database(ctx, record, config)
117
+ else
118
+ warn_best_effort_secondary(ctx, config)
119
+ claim_usage_in_secondary(ctx, record, config, hashed)
120
+ end
93
121
 
94
- check_permissions!(record, permissions)
95
- update = usage_update(record, config)
96
- updated = BetterAuth::APIKey::Adapter.update_record(ctx, record, update, config, defer: false)
97
- unless updated
122
+ BetterAuth::APIKey::Adapter.migrate_legacy_metadata(ctx, updated, config)
123
+ end
124
+
125
+ def claim_usage_in_database(ctx, record, config)
126
+ row = record
127
+ row = consume_remaining_database(ctx, row) unless row["remaining"].nil?
128
+ row = consume_rate_limit_database(ctx, row, config)
129
+
130
+ final_row = ctx.context.adapter.update(
131
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
132
+ where: [{field: "id", value: row["id"]}],
133
+ update: {updatedAt: Time.now}
134
+ )
135
+ unless final_row
136
+ if ctx.context.adapter.find_one(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: row["id"]}])
98
137
  raise BetterAuth::APIError.new(
99
138
  "INTERNAL_SERVER_ERROR",
100
139
  message: BetterAuth::Plugins::API_KEY_ERROR_CODES["FAILED_TO_UPDATE_API_KEY"],
101
140
  code: "FAILED_TO_UPDATE_API_KEY"
102
141
  )
103
142
  end
104
- BetterAuth::APIKey::Adapter.migrate_legacy_metadata(ctx, updated || record.merge(update.transform_keys { |key_name| BetterAuth::Schema.storage_key(key_name) }), config)
143
+ BetterAuth::APIKey::Adapter.delete(ctx, record, config)
144
+ raise invalid_api_key_error
145
+ end
146
+
147
+ if config[:storage] == "secondary-storage"
148
+ BetterAuth::APIKey::Adapter.set(ctx, final_row, config)
149
+ # A delete can commit after the counter update but before this cache
150
+ # publication. Re-check after publishing so that interleaving cannot
151
+ # leave a non-expiring ghost entry behind.
152
+ authoritative = ctx.context.adapter.find_one(
153
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
154
+ where: [{field: "id", value: final_row["id"]}]
155
+ )
156
+ unless authoritative
157
+ BetterAuth::APIKey::Adapter.delete(ctx, final_row, config)
158
+ raise invalid_api_key_error
159
+ end
105
160
  end
161
+ final_row
106
162
  end
107
163
 
108
- def usage_update(record, config)
164
+ def consume_remaining_database(ctx, record)
109
165
  now = Time.now
110
- update = {lastRequest: now, updatedAt: now}
166
+ refill_interval = record["refillInterval"]
167
+ refill_amount = record["refillAmount"]
168
+ if refill_interval && refill_amount.to_i.positive?
169
+ last_refill = BetterAuth::APIKey::Utils.normalize_time(record["lastRefillAt"] || record["createdAt"])
170
+ if last_refill && ((now - last_refill) * 1000) > refill_interval.to_i
171
+ refilled = ctx.context.adapter.increment_one(
172
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
173
+ where: [
174
+ {field: "id", value: record["id"]},
175
+ {field: "lastRefillAt", value: record["lastRefillAt"]}
176
+ ],
177
+ increment: {},
178
+ set: {remaining: refill_amount.to_i - 1, lastRefillAt: now},
179
+ allow_server_managed: true
180
+ )
181
+ return refilled if refilled
182
+ end
183
+ end
184
+
185
+ decremented = ctx.context.adapter.increment_one(
186
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
187
+ where: [{field: "id", value: record["id"]}, {field: "remaining", operator: "gt", value: 0}],
188
+ increment: {remaining: -1},
189
+ allow_server_managed: true
190
+ )
191
+ return decremented if decremented
192
+
193
+ raise usage_exceeded_error
194
+ end
111
195
 
112
- if (try_again_in = BetterAuth::APIKey::RateLimit.try_again_in(record, config, now))
196
+ def consume_rate_limit_database(ctx, record, config)
197
+ now = Time.now
198
+ decision = BetterAuth::APIKey::RateLimit.evaluate(record, config, now)
199
+ if decision[:type] == :deny
113
200
  raise BetterAuth::APIError.new(
114
- "UNAUTHORIZED",
115
- message: BetterAuth::Plugins::API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
201
+ "TOO_MANY_REQUESTS",
202
+ message: decision[:message],
116
203
  code: "RATE_LIMITED",
117
204
  body: {
118
- message: BetterAuth::Plugins::API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
205
+ message: decision[:message],
119
206
  code: "RATE_LIMITED",
120
- details: {tryAgainIn: try_again_in}
207
+ details: {tryAgainIn: decision[:try_again_in]}
121
208
  }
122
209
  )
123
210
  end
124
- update[:requestCount] = BetterAuth::APIKey::RateLimit.next_request_count(record, now) if BetterAuth::APIKey::RateLimit.counts_requests?(record, config)
211
+
212
+ case decision[:type]
213
+ when :skip
214
+ return record unless decision[:last_request]
215
+
216
+ updated = ctx.context.adapter.update(
217
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
218
+ where: [{field: "id", value: record["id"]}],
219
+ update: {lastRequest: decision[:last_request]}
220
+ )
221
+ return updated || record
222
+ when :start, :reset
223
+ guard = if decision[:type] == :reset
224
+ {field: "lastRequest", operator: "lte", value: decision[:window_start]}
225
+ else
226
+ {field: "lastRequest", value: nil}
227
+ end
228
+ started = ctx.context.adapter.increment_one(
229
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
230
+ where: [{field: "id", value: record["id"]}, guard],
231
+ increment: {},
232
+ set: {requestCount: 1, lastRequest: decision[:now]},
233
+ allow_server_managed: true
234
+ )
235
+ return started if started
236
+ when :increment
237
+ incremented = ctx.context.adapter.increment_one(
238
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
239
+ where: [
240
+ {field: "id", value: record["id"]},
241
+ {field: "lastRequest", operator: "gt", value: decision[:window_start]},
242
+ {field: "requestCount", operator: "lt", value: decision[:max]}
243
+ ],
244
+ increment: {requestCount: 1},
245
+ set: {lastRequest: decision[:now]},
246
+ allow_server_managed: true
247
+ )
248
+ return incremented if incremented
249
+ end
250
+
251
+ fresh = ctx.context.adapter.find_one(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}])
252
+ raise invalid_api_key_error unless fresh
253
+
254
+ # One concurrent writer may have opened/reset the window. Re-evaluate
255
+ # against the fresh row; this recursion is bounded by the finite race.
256
+ consume_rate_limit_database(ctx, fresh, config)
257
+ end
258
+
259
+ def claim_usage_in_secondary(ctx, record, config, hashed)
260
+ now = Time.now
261
+ fresh = BetterAuth::APIKey::Adapter.find_by_hash(ctx, hashed, config)
262
+ raise invalid_api_key_error unless fresh
263
+
264
+ update = usage_update(fresh, config, now)
265
+ merged = fresh.merge(update.transform_keys { |key_name| BetterAuth::Schema.storage_key(key_name) })
266
+ BetterAuth::APIKey::Adapter.set(ctx, merged, config)
267
+ merged
268
+ end
269
+
270
+ def usage_update(record, config, now = Time.now)
271
+ update = {lastRequest: now, updatedAt: now}
272
+ decision = BetterAuth::APIKey::RateLimit.evaluate(record, config, now)
273
+ if decision[:type] == :deny
274
+ raise BetterAuth::APIError.new(
275
+ "TOO_MANY_REQUESTS",
276
+ message: decision[:message],
277
+ code: "RATE_LIMITED",
278
+ body: {message: decision[:message], code: "RATE_LIMITED", details: {tryAgainIn: decision[:try_again_in]}}
279
+ )
280
+ end
281
+ case decision[:type]
282
+ when :start, :reset
283
+ update[:requestCount] = 1
284
+ when :increment
285
+ update[:requestCount] = record["requestCount"].to_i + 1
286
+ when :skip
287
+ update[:lastRequest] = decision[:last_request] if decision[:last_request]
288
+ end
125
289
 
126
290
  remaining = record["remaining"]
127
291
  if !remaining.nil?
128
- if remaining.to_i <= 0 && record["refillAmount"] && record["refillInterval"]
292
+ if remaining.to_i <= 0 && record["refillAmount"].to_i.positive? && record["refillInterval"]
129
293
  last_refill = BetterAuth::APIKey::Utils.normalize_time(record["lastRefillAt"] || record["createdAt"])
130
294
  if !last_refill || ((now - last_refill) * 1000) > record["refillInterval"].to_i
131
295
  remaining = record["refillAmount"].to_i
132
296
  update[:lastRefillAt] = now
133
297
  end
134
298
  end
135
- raise BetterAuth::APIError.new("TOO_MANY_REQUESTS", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
299
+ raise usage_exceeded_error if remaining.to_i <= 0
136
300
 
137
301
  update[:remaining] = remaining.to_i - 1
138
302
  end
139
303
  update
140
304
  end
141
305
 
306
+ def invalid_api_key_error
307
+ BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"])
308
+ end
309
+
310
+ def usage_exceeded_error
311
+ BetterAuth::APIError.new("TOO_MANY_REQUESTS", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
312
+ end
313
+
314
+ def warn_best_effort_secondary(ctx, config)
315
+ return if config[:storage] != "secondary-storage" || config[:fallback_to_database]
316
+ return if ctx.context.respond_to?(:runtime_fetch) && ctx.context.runtime_fetch(:api_key_secondary_warning, false)
317
+
318
+ logger = ctx.context.logger if ctx.context.respond_to?(:logger)
319
+ logger.warn("[API KEY PLUGIN] Secondary-storage-only API-key counters are best-effort; use fallback_to_database for atomic enforcement.") if logger.respond_to?(:warn)
320
+ ctx.context.runtime_store(:api_key_secondary_warning, true) if ctx.context.respond_to?(:runtime_store)
321
+ end
322
+
142
323
  def check_permissions!(record, required)
143
324
  return if required.nil? || required == {}
144
325
 
326
+ BetterAuth::Plugins.load_plugin!(:access)
145
327
  actual = BetterAuth::APIKey::Utils.decode_json(record["permissions"]) || {}
146
328
  result = BetterAuth::Plugins::Role.new(actual).authorize(required)
147
329
  unless result[:success]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module APIKey
5
- VERSION = "0.10.0"
5
+ VERSION = "0.11.0"
6
6
  end
7
7
  end
@@ -43,6 +43,8 @@ module BetterAuth
43
43
  BetterAuth::APIKey::PluginFactory.build(configurations, options)
44
44
  end
45
45
 
46
+ API_KEY_PLUGIN_IMPLEMENTATION = true
47
+
46
48
  def api_key_config(configurations, options = nil)
47
49
  BetterAuth::APIKey::Configuration.normalize(configurations, options)
48
50
  end
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.10.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -202,6 +202,7 @@ extensions: []
202
202
  extra_rdoc_files: []
203
203
  files:
204
204
  - CHANGELOG.md
205
+ - LICENSE.md
205
206
  - README.md
206
207
  - lib/better_auth/api_key.rb
207
208
  - lib/better_auth/api_key/adapter.rb