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 +4 -4
- data/CHANGELOG.md +30 -0
- data/LICENSE.md +20 -0
- data/README.md +19 -5
- data/lib/better_auth/api_key/adapter.rb +33 -5
- data/lib/better_auth/api_key/rate_limit.rb +30 -0
- data/lib/better_auth/api_key/routes/create_api_key.rb +5 -2
- data/lib/better_auth/api_key/routes/delete_all_expired_api_keys.rb +1 -1
- data/lib/better_auth/api_key/routes/delete_api_key.rb +1 -1
- data/lib/better_auth/api_key/routes/get_api_key.rb +1 -1
- data/lib/better_auth/api_key/routes/index.rb +12 -9
- data/lib/better_auth/api_key/routes/list_api_keys.rb +1 -1
- data/lib/better_auth/api_key/routes/update_api_key.rb +3 -2
- data/lib/better_auth/api_key/routes/verify_api_key.rb +14 -4
- data/lib/better_auth/api_key/validation.rb +218 -36
- data/lib/better_auth/api_key/version.rb +1 -1
- data/lib/better_auth/plugins/api_key.rb +2 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e054d9e429e2c199d594467dcc924a9ef44c22c0be63f96e240f47308e4444ef
|
|
4
|
+
data.tar.gz: d34741924ce2951654646c1127f0d3caa09d16cd765f58647220ff6c71221e57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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>`.
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
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
|
|
164
|
+
def consume_remaining_database(ctx, record)
|
|
109
165
|
now = Time.now
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
"
|
|
115
|
-
message:
|
|
201
|
+
"TOO_MANY_REQUESTS",
|
|
202
|
+
message: decision[:message],
|
|
116
203
|
code: "RATE_LIMITED",
|
|
117
204
|
body: {
|
|
118
|
-
message:
|
|
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
|
-
|
|
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
|
|
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]
|
|
@@ -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.
|
|
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
|