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