better_auth-api-key 0.8.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: 8dd667853c3c3130a0d0bcd0ebd00a5f15425de87979f2cdc00b2a2f5d9001fe
4
- data.tar.gz: f7222b9e0c3a61aabe67ec70ddefbc428f2d39e2181b53586e275ef3b9a63867
3
+ metadata.gz: e054d9e429e2c199d594467dcc924a9ef44c22c0be63f96e240f47308e4444ef
4
+ data.tar.gz: d34741924ce2951654646c1127f0d3caa09d16cd765f58647220ff6c71221e57
5
5
  SHA512:
6
- metadata.gz: a36b2eaee2a0128a50276eff4ccfbfa7604ea06364a041e7f039d6d10f7a3b17216ccb36fff7470e2918148a437ac0fce3801677ed3ca71dc843883c7d3e364d
7
- data.tar.gz: 3ecc422678b620d01b9c6848d60609246c3194e479d629d60ec21d92f8f7be330eade4a96e6971104da7bb62422e334e31d8069816a847df46ded0da005bc89e
6
+ metadata.gz: b8220f1cc0645105e0666c7d8184d43c72bb4e050094f4aa6cfb411b61f0622e2b0c2c4c21d50b7c586b82a80d96b7855c36b6e93b0dbbb94db2d87fd0456f2d
7
+ data.tar.gz: 5c1d00a8dc01502bc6709d0419195548eec8ad816846f9be05f58a3fa8c477305df53ba3722344f268b223b19b7e4ededd4bd138d1c1eca36a060c42872f6e02
data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
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
+
35
+ ## 0.10.0 - 2026-05-21
36
+
37
+ - Improved adapter coverage and Redis-backed storage behavior for API key flows.
38
+ - Tightened API key listing behavior so responses stay consistent across supported adapters.
39
+
5
40
  ## 0.7.0 - 2026-05-05
6
41
 
7
42
  - Changed API-key-backed sessions to expose `tokenFingerprint` instead of storing the raw API key in `session["token"]`.
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
 
@@ -84,8 +86,10 @@ module BetterAuth
84
86
  updated = nil
85
87
  if config[:storage] == "database" || config[:fallback_to_database]
86
88
  updated = ctx.context.adapter.update(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: update)
89
+ return nil unless updated
90
+ else
91
+ updated = record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
87
92
  end
88
- updated ||= record.merge(update.transform_keys { |key| BetterAuth::Schema.storage_key(key) })
89
93
  set(ctx, updated, config) if config[:storage] == "secondary-storage"
90
94
  updated
91
95
  end
@@ -195,14 +199,40 @@ module BetterAuth
195
199
  end
196
200
 
197
201
  def ref_list_add(storage_instance, reference_key, id)
198
- ids = safe_parse_id_list(storage_instance.get(reference_key))
199
- ids << id unless ids.include?(id)
200
- 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
201
212
  end
202
213
 
203
214
  def ref_list_remove(storage_instance, reference_key, id)
204
- ids = safe_parse_id_list(storage_instance.get(reference_key)).reject { |existing| existing == id }
205
- 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 }
206
236
  end
207
237
 
208
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,15 +4,21 @@ 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
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/create", method: "POST") do |ctx|
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)
19
+ if !session && BetterAuth::Plugins.api_key_auth_required?(ctx)
20
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
21
+ end
16
22
  reference_id = BetterAuth::Plugins.api_key_create_reference_id!(ctx, body, session, resolved_config)
17
23
 
18
24
  BetterAuth::Plugins.api_key_validate_create_update!(body, resolved_config, create: true, client: !ctx.headers.empty?)
@@ -4,13 +4,13 @@ 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
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/delete-all-expired-api-keys", method: "POST") do |ctx|
13
- BetterAuth::Plugins.api_key_delete_expired(ctx.context, config, bypass_last_check: true)
12
+ BetterAuth::Endpoint.new(path: "/api-key/delete-all-expired-api-keys", method: "POST", metadata: Routes.openapi_for(:delete_all_expired_api_keys)) do |ctx|
13
+ BetterAuth::APIKey::Routes.delete_expired(ctx.context, config, bypass_last_check: true, raise_on_error: true)
14
14
  ctx.json({success: true, error: nil})
15
15
  rescue => error
16
16
  ctx.context.logger.error("[API KEY PLUGIN] Failed to delete expired API keys: #{error.message}") if ctx.context.logger.respond_to?(:error)
@@ -4,12 +4,12 @@ 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
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/delete", method: "POST") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/delete", method: "POST", metadata: Routes.openapi_for(:delete_api_key)) do |ctx|
13
13
  session = BetterAuth::Routes.current_session(ctx)
14
14
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USER_BANNED"]) if session[:user]["banned"] == true
15
15
 
@@ -4,12 +4,12 @@ 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
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/get", method: "GET") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/get", method: "GET", metadata: Routes.openapi_for(:get_api_key)) do |ctx|
13
13
  session = BetterAuth::Routes.current_session(ctx)
14
14
  query = BetterAuth::Plugins.normalize_hash(ctx.query)
15
15
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, query[:config_id])
@@ -42,7 +42,7 @@ module BetterAuth
42
42
 
43
43
  @last_expired_check = nil
44
44
 
45
- def delete_expired(context, config, bypass_last_check: false)
45
+ def delete_expired(context, config, bypass_last_check: false, raise_on_error: false)
46
46
  return unless config[:storage] == "database" || config[:fallback_to_database]
47
47
  unless bypass_last_check
48
48
  now = Time.now
@@ -59,6 +59,9 @@ module BetterAuth
59
59
  {field: "expiresAt", value: nil, operator: "ne"}
60
60
  ]
61
61
  )
62
+ rescue => error
63
+ context.logger.error("[API KEY PLUGIN] Failed to delete expired API keys: #{error.message}") if context.respond_to?(:logger) && context.logger.respond_to?(:error)
64
+ raise if raise_on_error
62
65
  end
63
66
 
64
67
  def schedule_cleanup(ctx, config)
@@ -69,6 +72,230 @@ module BetterAuth
69
72
  task.call
70
73
  end
71
74
  end
75
+
76
+ def openapi_for(route)
77
+ {
78
+ create_api_key: create_api_key_openapi,
79
+ verify_api_key: verify_api_key_openapi,
80
+ get_api_key: get_api_key_openapi,
81
+ update_api_key: update_api_key_openapi,
82
+ delete_api_key: delete_api_key_openapi,
83
+ list_api_keys: list_api_keys_openapi,
84
+ delete_all_expired_api_keys: delete_all_expired_api_keys_openapi
85
+ }.fetch(route)
86
+ end
87
+
88
+ def create_api_key_openapi
89
+ {
90
+ openapi: {
91
+ description: "Create a new API key for a user",
92
+ requestBody: BetterAuth::OpenAPI.json_request_body(api_key_create_body_schema, required: true),
93
+ responses: {
94
+ "200" => BetterAuth::OpenAPI.json_response("API key created successfully", api_key_record_schema(include_secret: true))
95
+ }
96
+ }
97
+ }
98
+ end
99
+
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
+ )
109
+ {
110
+ openapi: {
111
+ description: "Verify and rate-limit an API key",
112
+ requestBody: BetterAuth::OpenAPI.json_request_body(
113
+ BetterAuth::OpenAPI.object_schema(
114
+ {
115
+ key: {type: "string", description: "The API key to verify"},
116
+ configId: {type: "string", description: "Configuration ID to use for the lookup"},
117
+ permissions: api_key_permissions_schema.merge(description: "Permissions required for the request")
118
+ },
119
+ required: ["key"]
120
+ )
121
+ ),
122
+ responses: {
123
+ "200" => BetterAuth::OpenAPI.json_response(
124
+ "API key verification result",
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)
129
+ }
130
+ }
131
+ }
132
+ end
133
+
134
+ def get_api_key_openapi
135
+ {
136
+ openapi: {
137
+ description: "Get an API key by ID",
138
+ parameters: [
139
+ BetterAuth::OpenAPI.query_parameter("id", required: true, description: "The API key ID"),
140
+ BetterAuth::OpenAPI.query_parameter("configId", description: "Configuration ID to use for the lookup")
141
+ ],
142
+ responses: {
143
+ "200" => BetterAuth::OpenAPI.json_response("API key retrieved successfully", api_key_record_schema(include_secret: false))
144
+ }
145
+ }
146
+ }
147
+ end
148
+
149
+ def update_api_key_openapi
150
+ {
151
+ openapi: {
152
+ description: "Update an existing API key by ID",
153
+ requestBody: BetterAuth::OpenAPI.json_request_body(api_key_update_body_schema, required: true),
154
+ responses: {
155
+ "200" => BetterAuth::OpenAPI.json_response("API key updated successfully", api_key_record_schema(include_secret: false))
156
+ }
157
+ }
158
+ }
159
+ end
160
+
161
+ def delete_api_key_openapi
162
+ {
163
+ openapi: {
164
+ description: "Delete an API key by ID",
165
+ requestBody: BetterAuth::OpenAPI.json_request_body(
166
+ BetterAuth::OpenAPI.object_schema(
167
+ {
168
+ keyId: {type: "string", description: "The API key ID"},
169
+ configId: {type: "string", description: "Configuration ID to use for the lookup"}
170
+ },
171
+ required: ["keyId"]
172
+ )
173
+ ),
174
+ responses: {
175
+ "200" => BetterAuth::OpenAPI.json_response("API key deleted successfully", BetterAuth::OpenAPI.success_response_schema)
176
+ }
177
+ }
178
+ }
179
+ end
180
+
181
+ def list_api_keys_openapi
182
+ {
183
+ openapi: {
184
+ description: "List all API keys for the authenticated user or for a specific organization",
185
+ parameters: [
186
+ BetterAuth::OpenAPI.query_parameter("configId", description: "Filter by configuration ID"),
187
+ BetterAuth::OpenAPI.query_parameter("organizationId", description: "Organization ID to list keys for"),
188
+ BetterAuth::OpenAPI.query_parameter("limit", schema: {type: "number"}, description: "The number of API keys to return"),
189
+ BetterAuth::OpenAPI.query_parameter("offset", schema: {type: "number"}, description: "The offset to start from"),
190
+ BetterAuth::OpenAPI.query_parameter("sortBy", description: "The field to sort by"),
191
+ BetterAuth::OpenAPI.query_parameter("sortDirection", schema: {type: "string", enum: ["asc", "desc"]}, description: "The direction to sort by")
192
+ ],
193
+ responses: {
194
+ "200" => BetterAuth::OpenAPI.json_response(
195
+ "API keys retrieved successfully",
196
+ BetterAuth::OpenAPI.object_schema(
197
+ {
198
+ apiKeys: BetterAuth::OpenAPI.array_schema(api_key_record_schema(include_secret: false)),
199
+ total: {type: "number"},
200
+ limit: {type: ["number", "null"]},
201
+ offset: {type: ["number", "null"]}
202
+ },
203
+ required: ["apiKeys", "total"]
204
+ )
205
+ )
206
+ }
207
+ }
208
+ }
209
+ end
210
+
211
+ def delete_all_expired_api_keys_openapi
212
+ {
213
+ openapi: {
214
+ description: "Delete all expired API keys",
215
+ requestBody: BetterAuth::OpenAPI.empty_request_body,
216
+ responses: {
217
+ "200" => BetterAuth::OpenAPI.json_response(
218
+ "Expired API key cleanup result",
219
+ BetterAuth::OpenAPI.object_schema(
220
+ {
221
+ success: {type: "boolean"},
222
+ error: {type: ["object", "null"], additionalProperties: true}
223
+ },
224
+ required: ["success", "error"]
225
+ )
226
+ )
227
+ }
228
+ }
229
+ }
230
+ end
231
+
232
+ def api_key_create_body_schema
233
+ BetterAuth::OpenAPI.object_schema(
234
+ {
235
+ configId: {type: "string", description: "The configuration ID to use for the API key"},
236
+ name: {type: "string", description: "Name of the API key"},
237
+ expiresIn: {type: ["number", "null"], description: "Expiration time of the API key in seconds"},
238
+ prefix: {type: "string", description: "Prefix of the API key"},
239
+ remaining: {type: ["number", "null"], description: "Remaining number of requests"},
240
+ metadata: {nullable: true, description: "Metadata associated with the API key"},
241
+ refillAmount: {type: "number", description: "Amount to refill the remaining count"},
242
+ refillInterval: {type: "number", description: "Interval to refill the API key in milliseconds"},
243
+ rateLimitTimeWindow: {type: "number", description: "Rate limit time window in milliseconds"},
244
+ rateLimitMax: {type: "number", description: "Maximum requests allowed within a window"},
245
+ rateLimitEnabled: {type: "boolean", description: "Whether the key has rate limiting enabled"},
246
+ permissions: api_key_permissions_schema.merge(description: "Permissions of the API key"),
247
+ userId: {type: "string", description: "User ID that the API key belongs to"},
248
+ organizationId: {type: "string", description: "Organization ID that the API key belongs to"}
249
+ }
250
+ )
251
+ end
252
+
253
+ def api_key_update_body_schema
254
+ BetterAuth::OpenAPI.object_schema(
255
+ api_key_create_body_schema[:properties].merge(
256
+ keyId: {type: "string", description: "The API key ID"},
257
+ enabled: {type: "boolean", description: "Whether the API key is enabled"}
258
+ ).except(:prefix, :organizationId),
259
+ required: ["keyId"]
260
+ )
261
+ end
262
+
263
+ def api_key_permissions_schema
264
+ {
265
+ type: "object",
266
+ additionalProperties: {
267
+ type: "array",
268
+ items: {type: "string"}
269
+ }
270
+ }
271
+ end
272
+
273
+ def api_key_record_schema(include_secret:)
274
+ properties = {
275
+ id: {type: "string", description: "Unique identifier of the API key"},
276
+ createdAt: {type: "string", format: "date-time", description: "Creation timestamp"},
277
+ updatedAt: {type: "string", format: "date-time", description: "Last update timestamp"},
278
+ name: {type: ["string", "null"], description: "Name of the API key"},
279
+ start: {type: ["string", "null"], description: "Starting characters of the key"},
280
+ prefix: {type: ["string", "null"], description: "Prefix of the API key"},
281
+ enabled: {type: "boolean", description: "Whether the key is enabled"},
282
+ expiresAt: {type: ["string", "null"], format: "date-time", description: "Expiration timestamp"},
283
+ referenceId: {type: "string", description: "ID of the reference owning the key"},
284
+ lastRefillAt: {type: ["string", "null"], format: "date-time", description: "Last refill timestamp"},
285
+ lastRequest: {type: ["string", "null"], format: "date-time", description: "Last request timestamp"},
286
+ metadata: {type: ["object", "null"], additionalProperties: true, description: "Metadata associated with the key"},
287
+ rateLimitMax: {type: ["number", "null"], description: "Maximum requests in time window"},
288
+ rateLimitTimeWindow: {type: ["number", "null"], description: "Rate limit time window in milliseconds"},
289
+ rateLimitEnabled: {type: "boolean", description: "Whether rate limiting is enabled"},
290
+ remaining: {type: ["number", "null"], description: "Remaining number of requests"},
291
+ refillAmount: {type: ["number", "null"], description: "Amount to refill"},
292
+ refillInterval: {type: ["number", "null"], description: "Refill interval in milliseconds"},
293
+ permissions: api_key_permissions_schema.merge(nullable: true, description: "Permissions of the API key"),
294
+ userId: {type: ["string", "null"], description: "ID of the user owning the key"}
295
+ }
296
+ properties[:key] = {type: "string", description: "The full API key"} if include_secret
297
+ BetterAuth::OpenAPI.object_schema(properties)
298
+ end
72
299
  end
73
300
  end
74
301
  end
@@ -4,33 +4,40 @@ 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
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/list", method: "GET") do |ctx|
12
+ BetterAuth::Endpoint.new(path: "/api-key/list", method: "GET", metadata: Routes.openapi_for(:list_api_keys)) do |ctx|
13
13
  session = BetterAuth::Routes.current_session(ctx)
14
14
  query = BetterAuth::Plugins.normalize_hash(ctx.query)
15
15
  BetterAuth::Plugins.api_key_validate_list_query!(query)
16
- configs = query[:config_id] ? [BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, query[:config_id])] : config.fetch(:configurations, [config])
16
+ configs = query[:config_id] ? [BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, query[:config_id])] : storage_groups(config.fetch(:configurations, [config]))
17
17
  reference_id = query[:organization_id] || session[:user]["id"]
18
18
  expected_reference = query[:organization_id] ? "organization" : "user"
19
19
  BetterAuth::Plugins.api_key_check_org_permission!(ctx, session[:user]["id"], reference_id, "read") if query[:organization_id]
20
- records = configs.flat_map { |entry| BetterAuth::Plugins.api_key_list_for_reference(ctx, reference_id, entry) }.uniq { |record| record["id"] }
21
- records = records.select do |record|
22
- record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
23
- record_config[:references].to_s == expected_reference &&
24
- BetterAuth::Plugins.api_key_record_reference_id(record) == reference_id &&
25
- (!query[:config_id] || BetterAuth::Plugins.api_key_config_id_matches?(BetterAuth::Plugins.api_key_record_config_id(record), query[:config_id]))
26
- end
27
- total = records.length
28
- records = BetterAuth::Plugins.api_key_sort_records(records, query[:sort_by], query[:sort_direction])
29
20
  offset = query.key?(:offset) ? query[:offset].to_i : nil
30
21
  limit = query.key?(:limit) ? query[:limit].to_i : nil
31
- records = records.drop(offset) if offset
32
- records = records.first(limit) if limit
33
- records.each { |record| BetterAuth::Plugins.api_key_delete_expired(ctx.context, BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))) }
22
+ pushed = database_paginated_records(ctx, configs.first, reference_id, expected_reference, query, limit, offset)
23
+ if pushed
24
+ records = pushed.fetch(:records)
25
+ total = pushed.fetch(:total)
26
+ else
27
+ records = configs.flat_map { |entry| BetterAuth::Plugins.api_key_list_for_reference(ctx, reference_id, entry) }.uniq { |record| record["id"] }
28
+ records = records.select do |record|
29
+ record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
30
+ record_config[:references].to_s == expected_reference &&
31
+ BetterAuth::Plugins.api_key_record_reference_id(record) == reference_id &&
32
+ (!query[:config_id] || BetterAuth::Plugins.api_key_config_id_matches?(BetterAuth::Plugins.api_key_record_config_id(record), query[:config_id]))
33
+ end
34
+ total = records.length
35
+ records = BetterAuth::Plugins.api_key_sort_records(records, query[:sort_by], query[:sort_direction])
36
+ records = records.drop(offset) if offset
37
+ records = records.first(limit) if limit
38
+ end
39
+ cleanup_config = query[:config_id] ? configs.first : config
40
+ BetterAuth::Plugins.api_key_delete_expired(ctx.context, cleanup_config)
34
41
  migration_records = records.select { |record| BetterAuth::APIKey::Adapter.legacy_metadata_migration_needed?(record) }
35
42
  if migration_records.any?
36
43
  BetterAuth::APIKey::Utils.run_background_task(
@@ -50,6 +57,62 @@ module BetterAuth
50
57
  ctx.json({apiKeys: api_keys, total: total, limit: limit, offset: offset})
51
58
  end
52
59
  end
60
+
61
+ def storage_groups(configurations)
62
+ seen = {}
63
+ configurations.each_with_object([]) do |entry, groups|
64
+ key = storage_identifier(entry)
65
+ next if seen[key]
66
+
67
+ seen[key] = true
68
+ groups << entry
69
+ end
70
+ end
71
+
72
+ def storage_identifier(config)
73
+ return "database" if config[:storage].to_s == "database"
74
+ return "custom:#{config[:config_id] || "default"}" if config[:custom_storage]
75
+
76
+ config[:fallback_to_database] ? "secondary-storage-with-fallback" : "secondary-storage"
77
+ end
78
+
79
+ def database_paginated_records(ctx, config, reference_id, expected_reference, query, limit, offset)
80
+ return nil unless query[:config_id] && config
81
+ return nil unless config[:storage].to_s == "database"
82
+ return nil if BetterAuth::APIKey::Routes.default_config_id?(query[:config_id])
83
+ return nil unless config[:references].to_s == expected_reference
84
+ return nil if expected_reference == "user" && legacy_user_id_records?(ctx, reference_id, query[:config_id])
85
+
86
+ where = [
87
+ {field: "referenceId", value: reference_id},
88
+ {field: "configId", value: query[:config_id]}
89
+ ]
90
+ sort_by = query[:sort_by] ? {field: query[:sort_by].to_s, direction: (query[:sort_direction] || "asc").to_s} : nil
91
+ {
92
+ records: ctx.context.adapter.find_many(
93
+ model: BetterAuth::Plugins::API_KEY_TABLE_NAME,
94
+ where: where,
95
+ sort_by: sort_by,
96
+ limit: limit,
97
+ offset: offset
98
+ ),
99
+ total: ctx.context.adapter.count(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: where)
100
+ }
101
+ end
102
+
103
+ def legacy_user_id_records?(ctx, reference_id, config_id)
104
+ where = [
105
+ {field: "userId", value: reference_id},
106
+ {field: "configId", value: config_id}
107
+ ]
108
+ ctx.context.adapter.count(model: BetterAuth::Plugins::API_KEY_TABLE_NAME, where: where).positive?
109
+ rescue KeyError, NoMethodError
110
+ false
111
+ rescue BetterAuth::Error => error
112
+ raise unless error.message.include?("Field userId not found")
113
+
114
+ false
115
+ end
53
116
  end
54
117
  end
55
118
  end
@@ -4,15 +4,19 @@ 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
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/update", method: "POST") do |ctx|
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)
17
+ if !session && BetterAuth::Plugins.api_key_auth_required?(ctx)
18
+ raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
19
+ end
16
20
  user_id = session&.dig(:user, "id") || body[:user_id]
17
21
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) unless user_id
18
22
  if session && body[:user_id] && body[:user_id] != session[:user]["id"]
@@ -34,6 +38,13 @@ module BetterAuth
34
38
  raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["NO_VALUES_TO_UPDATE"]) if update.empty?
35
39
 
36
40
  updated = BetterAuth::Plugins.api_key_update_record(ctx, record, update.merge(updatedAt: Time.now), record_config)
41
+ unless updated
42
+ raise BetterAuth::APIError.new(
43
+ "INTERNAL_SERVER_ERROR",
44
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["FAILED_TO_UPDATE_API_KEY"],
45
+ code: "FAILED_TO_UPDATE_API_KEY"
46
+ )
47
+ end
37
48
  updated = BetterAuth::Plugins.api_key_migrate_legacy_metadata(ctx, updated, record_config)
38
49
  BetterAuth::Plugins.api_key_delete_expired(ctx.context, record_config)
39
50
  ctx.json(BetterAuth::Plugins.api_key_public(updated, include_key_field: false))
@@ -4,39 +4,71 @@ 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
 
11
11
  def endpoint(config)
12
- BetterAuth::Endpoint.new(path: "/api-key/verify", method: "POST") do |ctx|
12
+ BetterAuth::Endpoint.new(
13
+ path: "/api-key/verify",
14
+ method: "POST",
15
+ body_schema: ->(value) { value },
16
+ metadata: Routes.openapi_for(:verify_api_key)
17
+ ) do |ctx|
13
18
  body = BetterAuth::Plugins.normalize_hash(ctx.body)
14
19
  resolved_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, body[:config_id])
15
20
  key = body[:key]
16
21
  if key.to_s.empty?
17
22
  raise BetterAuth::APIError.new(
18
- "FORBIDDEN",
23
+ "UNAUTHORIZED",
19
24
  message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"],
20
25
  code: "INVALID_API_KEY"
21
26
  )
22
27
  end
23
28
 
24
- if resolved_config[:custom_api_key_validator].respond_to?(:call) && !resolved_config[:custom_api_key_validator].call({ctx: ctx, key: key})
25
- ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "KEY_NOT_FOUND"}, key: nil})
29
+ validation_config = body[:config_id] ? resolved_config : config_for_key(ctx, key, config)
30
+ validation_config ||= resolved_config
31
+ validator = validation_config[:custom_api_key_validator]
32
+ if validator.respond_to?(:call) && !validator.call({ctx: ctx, key: key})
33
+ raise BetterAuth::APIError.new(
34
+ "UNAUTHORIZED",
35
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"],
36
+ code: "KEY_NOT_FOUND"
37
+ )
26
38
  else
27
- record = BetterAuth::Plugins.api_key_validate!(ctx, key, resolved_config, permissions: body[:permissions])
39
+ record = BetterAuth::Plugins.api_key_validate!(ctx, key, validation_config, permissions: body[:permissions])
28
40
  record_config = BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, BetterAuth::Plugins.api_key_record_config_id(record))
29
41
  BetterAuth::Plugins.api_key_schedule_cleanup(ctx, record_config)
30
42
  ctx.json({valid: true, error: nil, key: BetterAuth::Plugins.api_key_public(record, include_key_field: false)})
31
43
  end
32
44
  rescue BetterAuth::APIError => error
33
45
  ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
34
- 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
+ )
35
53
  rescue => error
36
54
  ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
37
55
  ctx.json({valid: false, error: {message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY"}, key: nil})
38
56
  end
39
57
  end
58
+
59
+ def config_for_key(ctx, key, config)
60
+ config.fetch(:configurations, [config]).each do |entry|
61
+ hashed = BetterAuth::Plugins.api_key_hash(key, entry)
62
+ record = BetterAuth::Plugins.api_key_find_by_hash(ctx, hashed, entry)
63
+ next unless record
64
+
65
+ record_config_id = BetterAuth::Plugins.api_key_record_config_id(record)
66
+ return entry if BetterAuth::Plugins.api_key_config_id_matches?(record_config_id, entry[:config_id])
67
+
68
+ return BetterAuth::Plugins.api_key_resolve_config(ctx.context, config, record_config_id)
69
+ end
70
+ nil
71
+ end
40
72
  end
41
73
  end
42
74
  end
@@ -8,6 +8,7 @@ module BetterAuth
8
8
  def schema(config, custom_schema = nil)
9
9
  base = {
10
10
  apikey: {
11
+ model_name: "api_keys",
11
12
  fields: {
12
13
  configId: {type: "string", required: true, default_value: "default", index: true},
13
14
  name: {type: "string", required: false},
@@ -24,6 +24,9 @@ module BetterAuth
24
24
  minimum = create ? 0 : 1
25
25
  raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_REMAINING"]) if body[:remaining].to_i < minimum
26
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
27
30
  if body[:metadata] && (create || config[:enable_metadata])
28
31
  raise BetterAuth::APIError.new("BAD_REQUEST", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["METADATA_DISABLED"]) unless config[:enable_metadata]
29
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)
@@ -69,63 +72,258 @@ module BetterAuth
69
72
  def validate_api_key!(ctx, key, config, permissions: nil)
70
73
  hashed = BetterAuth::APIKey::Keys.hash(key, config)
71
74
  record = BetterAuth::APIKey::Adapter.find_by_hash(ctx, hashed, config)
72
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
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
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
73
97
  unless BetterAuth::APIKey::Routes.config_id_matches?(BetterAuth::APIKey::Types.record_config_id(record), config[:config_id])
74
- raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["INVALID_API_KEY"])
98
+ raise invalid_api_key_error
75
99
  end
76
100
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
77
- if record["expiresAt"] && record["expiresAt"] <= Time.now
101
+ if record["expiresAt"] && BetterAuth::APIKey::Utils.normalize_time(record["expiresAt"]) <= Time.now
78
102
  BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
79
103
  raise BetterAuth::APIError.new("UNAUTHORIZED", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["KEY_EXPIRED"])
80
104
  end
81
- if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].nil?
82
- BetterAuth::APIKey::Adapter.schedule_record_delete(ctx, record, config)
83
- raise BetterAuth::APIError.new("TOO_MANY_REQUESTS", message: BetterAuth::Plugins::API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
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
84
112
  end
85
113
 
86
114
  check_permissions!(record, permissions)
87
- update = usage_update(record, config)
88
- updated = BetterAuth::APIKey::Adapter.update_record(ctx, record, update, config, defer: true)
89
- BetterAuth::APIKey::Adapter.migrate_legacy_metadata(ctx, updated || record.merge(update.transform_keys { |key_name| BetterAuth::Schema.storage_key(key_name) }), config)
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
121
+
122
+ BetterAuth::APIKey::Adapter.migrate_legacy_metadata(ctx, updated, config)
90
123
  end
91
124
 
92
- def usage_update(record, config)
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"]}])
137
+ raise BetterAuth::APIError.new(
138
+ "INTERNAL_SERVER_ERROR",
139
+ message: BetterAuth::Plugins::API_KEY_ERROR_CODES["FAILED_TO_UPDATE_API_KEY"],
140
+ code: "FAILED_TO_UPDATE_API_KEY"
141
+ )
142
+ end
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
160
+ end
161
+ final_row
162
+ end
163
+
164
+ def consume_remaining_database(ctx, record)
93
165
  now = Time.now
94
- 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
95
184
 
96
- if (try_again_in = BetterAuth::APIKey::RateLimit.try_again_in(record, config, now))
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
195
+
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
97
200
  raise BetterAuth::APIError.new(
98
- "UNAUTHORIZED",
99
- message: BetterAuth::Plugins::API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
201
+ "TOO_MANY_REQUESTS",
202
+ message: decision[:message],
100
203
  code: "RATE_LIMITED",
101
204
  body: {
102
- message: BetterAuth::Plugins::API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
205
+ message: decision[:message],
103
206
  code: "RATE_LIMITED",
104
- details: {tryAgainIn: try_again_in}
207
+ details: {tryAgainIn: decision[:try_again_in]}
105
208
  }
106
209
  )
107
210
  end
108
- 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
109
289
 
110
290
  remaining = record["remaining"]
111
291
  if !remaining.nil?
112
- if remaining.to_i <= 0 && record["refillAmount"] && record["refillInterval"]
292
+ if remaining.to_i <= 0 && record["refillAmount"].to_i.positive? && record["refillInterval"]
113
293
  last_refill = BetterAuth::APIKey::Utils.normalize_time(record["lastRefillAt"] || record["createdAt"])
114
294
  if !last_refill || ((now - last_refill) * 1000) > record["refillInterval"].to_i
115
295
  remaining = record["refillAmount"].to_i
116
296
  update[:lastRefillAt] = now
117
297
  end
118
298
  end
119
- 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
120
300
 
121
301
  update[:remaining] = remaining.to_i - 1
122
302
  end
123
303
  update
124
304
  end
125
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
+
126
323
  def check_permissions!(record, required)
127
324
  return if required.nil? || required == {}
128
325
 
326
+ BetterAuth::Plugins.load_plugin!(:access)
129
327
  actual = BetterAuth::APIKey::Utils.decode_json(record["permissions"]) || {}
130
328
  result = BetterAuth::Plugins::Role.new(actual).authorize(required)
131
329
  unless result[:success]
@@ -2,6 +2,6 @@
2
2
 
3
3
  module BetterAuth
4
4
  module APIKey
5
- VERSION = "0.8.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.8.0
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sebastian Sala
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.5'
40
+ - !ruby/object:Gem::Dependency
41
+ name: better_auth-mongodb
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.9'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.9'
54
+ - !ruby/object:Gem::Dependency
55
+ name: better_auth-redis-storage
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.9'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.9'
40
68
  - !ruby/object:Gem::Dependency
41
69
  name: minitest
42
70
  requirement: !ruby/object:Gem::Requirement
@@ -51,6 +79,34 @@ dependencies:
51
79
  - - "~>"
52
80
  - !ruby/object:Gem::Version
53
81
  version: '5.25'
82
+ - !ruby/object:Gem::Dependency
83
+ name: mysql2
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.5'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - "~>"
94
+ - !ruby/object:Gem::Version
95
+ version: '0.5'
96
+ - !ruby/object:Gem::Dependency
97
+ name: pg
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - "~>"
101
+ - !ruby/object:Gem::Version
102
+ version: '1.5'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - "~>"
108
+ - !ruby/object:Gem::Version
109
+ version: '1.5'
54
110
  - !ruby/object:Gem::Dependency
55
111
  name: rake
56
112
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +121,48 @@ dependencies:
65
121
  - - "~>"
66
122
  - !ruby/object:Gem::Version
67
123
  version: '13.2'
124
+ - !ruby/object:Gem::Dependency
125
+ name: redis
126
+ requirement: !ruby/object:Gem::Requirement
127
+ requirements:
128
+ - - "~>"
129
+ - !ruby/object:Gem::Version
130
+ version: '5.0'
131
+ type: :development
132
+ prerelease: false
133
+ version_requirements: !ruby/object:Gem::Requirement
134
+ requirements:
135
+ - - "~>"
136
+ - !ruby/object:Gem::Version
137
+ version: '5.0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: sequel
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - "~>"
143
+ - !ruby/object:Gem::Version
144
+ version: '5.83'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - "~>"
150
+ - !ruby/object:Gem::Version
151
+ version: '5.83'
152
+ - !ruby/object:Gem::Dependency
153
+ name: sqlite3
154
+ requirement: !ruby/object:Gem::Requirement
155
+ requirements:
156
+ - - "~>"
157
+ - !ruby/object:Gem::Version
158
+ version: '2.0'
159
+ type: :development
160
+ prerelease: false
161
+ version_requirements: !ruby/object:Gem::Requirement
162
+ requirements:
163
+ - - "~>"
164
+ - !ruby/object:Gem::Version
165
+ version: '2.0'
68
166
  - !ruby/object:Gem::Dependency
69
167
  name: standardrb
70
168
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +177,20 @@ dependencies:
79
177
  - - "~>"
80
178
  - !ruby/object:Gem::Version
81
179
  version: '1.0'
180
+ - !ruby/object:Gem::Dependency
181
+ name: tiny_tds
182
+ requirement: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - "~>"
185
+ - !ruby/object:Gem::Version
186
+ version: '2.1'
187
+ type: :development
188
+ prerelease: false
189
+ version_requirements: !ruby/object:Gem::Requirement
190
+ requirements:
191
+ - - "~>"
192
+ - !ruby/object:Gem::Version
193
+ version: '2.1'
82
194
  description: Adds API key creation, verification, management, quotas, metadata, and
83
195
  permissions for Better Auth Ruby. Includes storage modes. Also supports API-key-backed
84
196
  sessions. Better Auth Ruby is an independent modern authentication framework for
@@ -90,6 +202,7 @@ extensions: []
90
202
  extra_rdoc_files: []
91
203
  files:
92
204
  - CHANGELOG.md
205
+ - LICENSE.md
93
206
  - README.md
94
207
  - lib/better_auth/api_key.rb
95
208
  - lib/better_auth/api_key/adapter.rb
@@ -114,14 +227,14 @@ files:
114
227
  - lib/better_auth/api_key/validation.rb
115
228
  - lib/better_auth/api_key/version.rb
116
229
  - lib/better_auth/plugins/api_key.rb
117
- homepage: https://github.com/sebasxsala/better-auth
230
+ homepage: https://github.com/sebasxsala/better-auth-rb
118
231
  licenses:
119
232
  - MIT
120
233
  metadata:
121
- homepage_uri: https://github.com/sebasxsala/better-auth
122
- source_code_uri: https://github.com/sebasxsala/better-auth
123
- changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-api-key/CHANGELOG.md
124
- bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
234
+ homepage_uri: https://github.com/sebasxsala/better-auth-rb
235
+ source_code_uri: https://github.com/sebasxsala/better-auth-rb
236
+ changelog_uri: https://github.com/sebasxsala/better-auth-rb/blob/main/packages/better_auth-api-key/CHANGELOG.md
237
+ bug_tracker_uri: https://github.com/sebasxsala/better-auth-rb/issues
125
238
  rdoc_options: []
126
239
  require_paths:
127
240
  - lib