better_auth-api-key 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +5 -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 +245 -78
- 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: df93f92692f0e4ce8a929b7c13ead11567450cb00052eae52b3d3505c82e0930
|
|
4
|
+
data.tar.gz: df7adda82f9f74ac01760f30ecd8013693a18f91c2fb799d961a9acc168cf78b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0c592f30fac4c14c801eeedb43884c66b6ffac714e407e10e41ede39eab3c382341dd1143dc246ade4ebee9dc63ff9b7f13302da1291b4b4180434739df3d314
|
|
7
|
+
data.tar.gz: '087f99ab7f1e18be8afa5b3c48d64b579580b5468be4d3c387918c9a19f899087b1c695ae766144f05965050ba9cb9430ad02d64d30e53f6fae41270ed41e6b1'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.0 - 2026-04-29
|
|
4
|
+
|
|
5
|
+
- Aligned API key behavior with upstream Better Auth v1.6.9, including key verification, permission checks, metadata updates, expiration, rate limiting, prefix handling, and route response shapes.
|
|
6
|
+
- Expanded package documentation and executable coverage for upstream API key edge cases.
|
|
7
|
+
|
|
3
8
|
## 0.1.0
|
|
4
9
|
|
|
5
10
|
- Extract API key support into the `better_auth-api-key` package.
|
data/README.md
CHANGED
|
@@ -25,3 +25,181 @@ auth = BetterAuth.auth(
|
|
|
25
25
|
## Notes
|
|
26
26
|
|
|
27
27
|
This package matches upstream's separate `@better-auth/api-key` package boundary. The Ruby plugin keeps the public `BetterAuth::Plugins.api_key` entrypoint, while core `better_auth` only provides a compatibility shim.
|
|
28
|
+
|
|
29
|
+
## Upstream parity
|
|
30
|
+
|
|
31
|
+
The Ruby package implements the upstream server contract for `@better-auth/api-key`: the same API key routes, response shapes, error messages, metadata/permissions decoding, organization-owned keys, multiple configurations, rate limits, usage limits, secondary storage, fallback-to-database behavior, and API-key-backed sessions.
|
|
32
|
+
|
|
33
|
+
Frontend applications should use the upstream JavaScript client plugin against the Ruby server:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { createAuthClient } from "better-auth/client";
|
|
37
|
+
import { apiKeyClient } from "@better-auth/api-key/client";
|
|
38
|
+
|
|
39
|
+
export const authClient = createAuthClient({
|
|
40
|
+
baseURL: "https://auth.example.com",
|
|
41
|
+
plugins: [apiKeyClient()]
|
|
42
|
+
});
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Ruby does not expose a separate `apiKeyClient()` equivalent; the public Ruby surface is the server plugin and route contract.
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
auth = BetterAuth.auth(
|
|
51
|
+
secret: ENV.fetch("BETTER_AUTH_SECRET"),
|
|
52
|
+
secondary_storage: redis_storage,
|
|
53
|
+
plugins: [
|
|
54
|
+
BetterAuth::Plugins.api_key(
|
|
55
|
+
default_key_length: 64,
|
|
56
|
+
default_prefix: "ba_",
|
|
57
|
+
enable_metadata: true,
|
|
58
|
+
enable_session_for_api_keys: true,
|
|
59
|
+
disable_key_hashing: false,
|
|
60
|
+
rate_limit: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
time_window: 86_400_000,
|
|
63
|
+
max_requests: 10
|
|
64
|
+
},
|
|
65
|
+
key_expiration: {
|
|
66
|
+
default_expires_in: nil,
|
|
67
|
+
disable_custom_expires_time: false,
|
|
68
|
+
min_expires_in: 1,
|
|
69
|
+
max_expires_in: 365
|
|
70
|
+
},
|
|
71
|
+
starting_characters_config: {
|
|
72
|
+
should_store: true,
|
|
73
|
+
characters_length: 6
|
|
74
|
+
},
|
|
75
|
+
storage: "secondary-storage",
|
|
76
|
+
fallback_to_database: true,
|
|
77
|
+
custom_storage: nil,
|
|
78
|
+
permissions: {
|
|
79
|
+
default_permissions: {files: ["read"]}
|
|
80
|
+
}
|
|
81
|
+
)
|
|
82
|
+
]
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Multiple configurations are supported with required unique `config_id` values:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
BetterAuth::Plugins.api_key([
|
|
90
|
+
{config_id: "user-keys", references: "user", default_prefix: "usr_"},
|
|
91
|
+
{config_id: "org-keys", references: "organization", default_prefix: "org_"}
|
|
92
|
+
])
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Organization-owned keys require `BetterAuth::Plugins.organization` and use organization permissions for `apiKey` actions: `create`, `read`, `update`, and `delete`.
|
|
96
|
+
|
|
97
|
+
Secondary-storage mode uses upstream storage keys such as `api-key:<hash>`, `api-key:by-id:<id>`, and `api-key:by-ref:<referenceId>`. When `fallback_to_database: true` is enabled, the reference list is treated as a cache and invalidated on writes/deletes so concurrent writers cannot lose IDs; listing falls back to the database source of truth.
|
|
98
|
+
|
|
99
|
+
## Storage layout
|
|
100
|
+
|
|
101
|
+
The Ruby gem writes only to the upstream layout; legacy prefixes are read for
|
|
102
|
+
backward compatibility but never produced by new writes:
|
|
103
|
+
|
|
104
|
+
| Purpose | Upstream key (read + write) | Ruby legacy key (read only) |
|
|
105
|
+
|----------------------------------|------------------------------|------------------------------|
|
|
106
|
+
| Lookup by hashed key | `api-key:<hash>` | `api-key:key:<hash>` |
|
|
107
|
+
| Lookup by id | `api-key:by-id:<id>` | `api-key:id:<id>` |
|
|
108
|
+
| Reference -> [id] list | `api-key:by-ref:<refId>` | `api-key:user:<userId>` |
|
|
109
|
+
|
|
110
|
+
When upgrading from older Ruby releases the new server transparently keeps
|
|
111
|
+
serving cached entries from the legacy keys while populating the upstream layout
|
|
112
|
+
on the next mutation. Once a key is rewritten, the legacy entry is also deleted
|
|
113
|
+
on `delete-api-key` to keep the layout converging on a single source of truth.
|
|
114
|
+
|
|
115
|
+
## Plugin metadata
|
|
116
|
+
|
|
117
|
+
The plugin object exposes the package version (mirroring upstream
|
|
118
|
+
`@better-auth/api-key` 1.6.0+):
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
auth.options.plugins.find { |plugin| plugin.id == "api-key" }.version
|
|
122
|
+
# => BetterAuth::APIKey::VERSION
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Hashing
|
|
126
|
+
|
|
127
|
+
The upstream `defaultKeyHasher` equivalent is available as:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
BetterAuth::Plugins.default_api_key_hasher("secret-key")
|
|
131
|
+
BetterAuth::APIKey.default_key_hasher("secret-key")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Both return the SHA-256 base64url digest used for stored API keys when `disable_key_hashing` is false.
|
|
135
|
+
|
|
136
|
+
## Ruby option naming policy
|
|
137
|
+
|
|
138
|
+
Public option keys use idiomatic Ruby `snake_case` while the wire JSON keeps
|
|
139
|
+
upstream's `camelCase`. The mapping is fixed and intentionally lossless:
|
|
140
|
+
|
|
141
|
+
| Ruby option (snake_case) | Wire field (camelCase) |
|
|
142
|
+
|---------------------------------------|-------------------------------|
|
|
143
|
+
| `config_id` | `configId` |
|
|
144
|
+
| `default_key_length` | `defaultKeyLength` |
|
|
145
|
+
| `default_prefix` | `defaultPrefix` |
|
|
146
|
+
| `enable_metadata` | `enableMetadata` |
|
|
147
|
+
| `disable_key_hashing` | `disableKeyHashing` |
|
|
148
|
+
| `require_name` | `requireName` |
|
|
149
|
+
| `enable_session_for_api_keys` | `enableSessionForAPIKeys` |
|
|
150
|
+
| `fallback_to_database` | `fallbackToDatabase` |
|
|
151
|
+
| `custom_storage` | `customStorage` |
|
|
152
|
+
| `defer_updates` | `deferUpdates` |
|
|
153
|
+
| `references` | `references` |
|
|
154
|
+
| `key_expiration.default_expires_in` | `keyExpiration.defaultExpiresIn` |
|
|
155
|
+
| `key_expiration.disable_custom_expires_time` | `keyExpiration.disableCustomExpiresTime` |
|
|
156
|
+
| `key_expiration.max_expires_in` | `keyExpiration.maxExpiresIn` |
|
|
157
|
+
| `key_expiration.min_expires_in` | `keyExpiration.minExpiresIn` |
|
|
158
|
+
| `starting_characters_config.should_store` | `startingCharactersConfig.shouldStore` |
|
|
159
|
+
| `starting_characters_config.characters_length` | `startingCharactersConfig.charactersLength` |
|
|
160
|
+
| `rate_limit.enabled` | `rateLimit.enabled` |
|
|
161
|
+
| `rate_limit.time_window` | `rateLimit.timeWindow` |
|
|
162
|
+
| `rate_limit.max_requests` | `rateLimit.maxRequests` |
|
|
163
|
+
|
|
164
|
+
Endpoint requests/responses always use the upstream `camelCase` field names, so
|
|
165
|
+
TypeScript clients targeting `@better-auth/api-key/client` interoperate without
|
|
166
|
+
configuration changes.
|
|
167
|
+
|
|
168
|
+
## Organization-owned API keys
|
|
169
|
+
|
|
170
|
+
Setting `references: "organization"` on a configuration delegates ownership to
|
|
171
|
+
`BetterAuth::Plugins::Organization`, which must be installed alongside this
|
|
172
|
+
plugin. The organization plugin's access-control bundle must define the
|
|
173
|
+
`apiKey` resource with `create`, `read`, `update`, and `delete` actions:
|
|
174
|
+
|
|
175
|
+
```ruby
|
|
176
|
+
ac = BetterAuth::Plugins.create_access_control(
|
|
177
|
+
organization: ["update", "delete"],
|
|
178
|
+
member: ["create", "update", "delete"],
|
|
179
|
+
invitation: ["create", "cancel"],
|
|
180
|
+
team: ["create", "update", "delete"],
|
|
181
|
+
ac: ["create", "read", "update", "delete"],
|
|
182
|
+
apiKey: ["create", "read", "update", "delete"]
|
|
183
|
+
)
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The configured `creator_role` (default `"owner"`) is treated as having
|
|
187
|
+
implicit permission for every `apiKey` action, mirroring upstream's "owner
|
|
188
|
+
bypasses per-action permission check" behavior. All other roles must be granted
|
|
189
|
+
the appropriate `apiKey:*` permission to perform the corresponding action.
|
|
190
|
+
|
|
191
|
+
## Intentional Ruby-vs-upstream adaptations
|
|
192
|
+
|
|
193
|
+
The following decisions are explicit and locked behind tests:
|
|
194
|
+
|
|
195
|
+
- **OpenAPI metadata blocks** embedded in upstream endpoint definitions are not
|
|
196
|
+
ported. OpenAPI generation is not part of `better_auth-api-key`'s scope.
|
|
197
|
+
- **Browser-only `@better-auth/api-key/client`** helpers are not implemented in
|
|
198
|
+
Ruby. Apps should call `/api-key/create`, `/api-key/verify`, `/api-key/get`,
|
|
199
|
+
`/api-key/list`, `/api-key/update`, `/api-key/delete`, and
|
|
200
|
+
`/api-key/delete-all-expired-api-keys` directly via JSON.
|
|
201
|
+
- **`apikey` table name** mirrors the upstream package (no `_` separator).
|
|
202
|
+
- **Legacy secondary-storage prefixes** (`api-key:key:*`, `api-key:id:*`,
|
|
203
|
+
`api-key:user:*`) are still honored on read so existing deployments do not
|
|
204
|
+
lose data when upgrading. New writes always use the upstream layout
|
|
205
|
+
documented above.
|
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},
|
|
@@ -180,14 +184,9 @@ module BetterAuth
|
|
|
180
184
|
body = normalize_hash(ctx.body)
|
|
181
185
|
resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
|
|
182
186
|
session = Routes.current_session(ctx, allow_nil: true)
|
|
183
|
-
if session && body[:user_id]
|
|
184
|
-
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["SERVER_ONLY_PROPERTY"])
|
|
185
|
-
end
|
|
186
|
-
|
|
187
187
|
reference_id = api_key_create_reference_id!(ctx, body, session, resolved_config)
|
|
188
|
-
user_id = (resolved_config[:references].to_s == "organization") ? (session&.dig(:user, "id") || body[:user_id]) : reference_id
|
|
189
188
|
|
|
190
|
-
api_key_validate_create_update!(body, resolved_config, create: true, client:
|
|
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})
|
|
@@ -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
|
|
|
@@ -420,15 +428,33 @@ module BetterAuth
|
|
|
420
428
|
|
|
421
429
|
def api_key_check_org_permission!(ctx, user_id, organization_id, action)
|
|
422
430
|
org_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "organization" }
|
|
423
|
-
|
|
431
|
+
unless org_plugin
|
|
432
|
+
raise APIError.new(
|
|
433
|
+
"INTERNAL_SERVER_ERROR",
|
|
434
|
+
message: API_KEY_ERROR_CODES["ORGANIZATION_PLUGIN_REQUIRED"],
|
|
435
|
+
code: "ORGANIZATION_PLUGIN_REQUIRED"
|
|
436
|
+
)
|
|
437
|
+
end
|
|
424
438
|
|
|
425
439
|
member = ctx.context.adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}])
|
|
426
|
-
|
|
440
|
+
unless member
|
|
441
|
+
raise APIError.new(
|
|
442
|
+
"FORBIDDEN",
|
|
443
|
+
message: API_KEY_ERROR_CODES["USER_NOT_MEMBER_OF_ORGANIZATION"],
|
|
444
|
+
code: "USER_NOT_MEMBER_OF_ORGANIZATION"
|
|
445
|
+
)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
return member if member["role"].to_s == (org_plugin.options[:creator_role] || "owner").to_s
|
|
427
449
|
|
|
428
450
|
permissions = {"apiKey" => [action]}
|
|
429
451
|
return member if BetterAuth::Plugins.organization_permission?(ctx, org_plugin.options, member["role"], permissions, organization_id)
|
|
430
452
|
|
|
431
|
-
raise APIError.new(
|
|
453
|
+
raise APIError.new(
|
|
454
|
+
"FORBIDDEN",
|
|
455
|
+
message: API_KEY_ERROR_CODES["INSUFFICIENT_API_KEY_PERMISSIONS"],
|
|
456
|
+
code: "INSUFFICIENT_API_KEY_PERMISSIONS"
|
|
457
|
+
)
|
|
432
458
|
end
|
|
433
459
|
|
|
434
460
|
def api_key_sort_records(records, sort_by, direction)
|
|
@@ -443,10 +469,31 @@ module BetterAuth
|
|
|
443
469
|
end
|
|
444
470
|
end
|
|
445
471
|
|
|
472
|
+
def api_key_validate_list_query!(query)
|
|
473
|
+
%i[limit offset].each do |key|
|
|
474
|
+
next unless query.key?(key)
|
|
475
|
+
|
|
476
|
+
value = query[key]
|
|
477
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid #{key}") unless value.to_s.match?(/\A\d+\z/)
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
direction = query[:sort_direction]
|
|
481
|
+
return if direction.nil? || %w[asc desc].include?(direction.to_s.downcase)
|
|
482
|
+
|
|
483
|
+
raise APIError.new("BAD_REQUEST", message: "Invalid sortDirection")
|
|
484
|
+
end
|
|
485
|
+
|
|
446
486
|
def api_key_error_code(error)
|
|
447
487
|
API_KEY_ERROR_CODES.key(error.message) || error.code.to_s
|
|
448
488
|
end
|
|
449
489
|
|
|
490
|
+
def api_key_error_payload(error)
|
|
491
|
+
payload = error.to_h
|
|
492
|
+
return payload if payload.is_a?(Hash) && payload.key?(:details)
|
|
493
|
+
|
|
494
|
+
{message: error.message, code: api_key_error_code(error)}
|
|
495
|
+
end
|
|
496
|
+
|
|
450
497
|
def api_key_session_header_config(ctx, config)
|
|
451
498
|
config.fetch(:configurations, [config]).find do |entry|
|
|
452
499
|
entry[:enable_session_for_api_keys] && api_key_get_from_headers(ctx, entry)
|
|
@@ -466,6 +513,7 @@ module BetterAuth
|
|
|
466
513
|
end
|
|
467
514
|
|
|
468
515
|
record = api_key_validate!(ctx, key, config)
|
|
516
|
+
api_key_schedule_cleanup(ctx, config)
|
|
469
517
|
if config[:references].to_s != "user"
|
|
470
518
|
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"])
|
|
471
519
|
end
|
|
@@ -480,7 +528,7 @@ module BetterAuth
|
|
|
480
528
|
"token" => key,
|
|
481
529
|
"userId" => reference_id,
|
|
482
530
|
"userAgent" => ctx.headers["user-agent"],
|
|
483
|
-
"ipAddress" => ctx.headers
|
|
531
|
+
"ipAddress" => RequestIP.client_ip(ctx.request || ctx.headers, ctx.context.options),
|
|
484
532
|
"createdAt" => Time.now,
|
|
485
533
|
"updatedAt" => Time.now,
|
|
486
534
|
"expiresAt" => record["expiresAt"] || (Time.now + ctx.context.options.session[:expires_in].to_i)
|
|
@@ -493,16 +541,16 @@ module BetterAuth
|
|
|
493
541
|
def api_key_validate!(ctx, key, config, permissions: nil)
|
|
494
542
|
hashed = api_key_hash(key, config)
|
|
495
543
|
record = api_key_find_by_hash(ctx, hashed, config)
|
|
496
|
-
raise APIError.new("
|
|
497
|
-
raise APIError.new("
|
|
498
|
-
raise APIError.new("
|
|
544
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
|
|
545
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless api_key_config_id_matches?(api_key_record_config_id(record), config[:config_id])
|
|
546
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
|
|
499
547
|
if record["expiresAt"] && record["expiresAt"] <= Time.now
|
|
500
|
-
|
|
501
|
-
raise APIError.new("
|
|
548
|
+
api_key_schedule_record_delete(ctx, record, config)
|
|
549
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_EXPIRED"])
|
|
502
550
|
end
|
|
503
551
|
if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].nil?
|
|
504
|
-
|
|
505
|
-
raise APIError.new("
|
|
552
|
+
api_key_schedule_record_delete(ctx, record, config)
|
|
553
|
+
raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
|
|
506
554
|
end
|
|
507
555
|
|
|
508
556
|
api_key_check_permissions!(record, permissions)
|
|
@@ -515,38 +563,57 @@ module BetterAuth
|
|
|
515
563
|
now = Time.now
|
|
516
564
|
update = {lastRequest: now, updatedAt: now}
|
|
517
565
|
|
|
518
|
-
if
|
|
519
|
-
raise APIError.new(
|
|
566
|
+
if (try_again_in = api_key_rate_limit_try_again_in(record, config, now))
|
|
567
|
+
raise APIError.new(
|
|
568
|
+
"UNAUTHORIZED",
|
|
569
|
+
message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
|
|
570
|
+
code: "RATE_LIMITED",
|
|
571
|
+
body: {
|
|
572
|
+
message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"],
|
|
573
|
+
code: "RATE_LIMITED",
|
|
574
|
+
details: {tryAgainIn: try_again_in}
|
|
575
|
+
}
|
|
576
|
+
)
|
|
520
577
|
end
|
|
521
|
-
update[:requestCount] = api_key_next_request_count(record, now)
|
|
578
|
+
update[:requestCount] = api_key_next_request_count(record, now) if api_key_rate_limit_counts_requests?(record, config)
|
|
522
579
|
|
|
523
580
|
remaining = record["remaining"]
|
|
524
581
|
if !remaining.nil?
|
|
525
582
|
if remaining.to_i <= 0 && record["refillAmount"] && record["refillInterval"]
|
|
526
|
-
last_refill = api_key_normalize_time(record["lastRefillAt"] || record["
|
|
527
|
-
if !last_refill || ((now - last_refill) * 1000)
|
|
583
|
+
last_refill = api_key_normalize_time(record["lastRefillAt"] || record["createdAt"])
|
|
584
|
+
if !last_refill || ((now - last_refill) * 1000) > record["refillInterval"].to_i
|
|
528
585
|
remaining = record["refillAmount"].to_i
|
|
529
586
|
update[:lastRefillAt] = now
|
|
530
587
|
end
|
|
531
588
|
end
|
|
532
|
-
raise APIError.new("
|
|
589
|
+
raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
|
|
533
590
|
|
|
534
591
|
update[:remaining] = remaining.to_i - 1
|
|
535
592
|
end
|
|
536
593
|
update
|
|
537
594
|
end
|
|
538
595
|
|
|
539
|
-
def
|
|
540
|
-
return
|
|
596
|
+
def api_key_rate_limit_try_again_in(record, config, now)
|
|
597
|
+
return nil if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
|
|
541
598
|
|
|
542
599
|
window = record["rateLimitTimeWindow"]
|
|
543
600
|
max = record["rateLimitMax"]
|
|
544
|
-
return
|
|
601
|
+
return nil if window.nil? || max.nil?
|
|
545
602
|
|
|
546
603
|
last = api_key_normalize_time(record["lastRequest"])
|
|
547
|
-
return
|
|
604
|
+
return nil unless last
|
|
605
|
+
|
|
606
|
+
elapsed = (now - last) * 1000
|
|
607
|
+
return nil if elapsed > window.to_i
|
|
608
|
+
return nil unless record["requestCount"].to_i >= max.to_i
|
|
548
609
|
|
|
549
|
-
|
|
610
|
+
(window.to_i - elapsed).ceil
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
def api_key_rate_limit_counts_requests?(record, config)
|
|
614
|
+
return false if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
|
|
615
|
+
|
|
616
|
+
!record["rateLimitTimeWindow"].nil? && !record["rateLimitMax"].nil?
|
|
550
617
|
end
|
|
551
618
|
|
|
552
619
|
def api_key_next_request_count(record, now)
|
|
@@ -578,23 +645,25 @@ module BetterAuth
|
|
|
578
645
|
minimum = create ? 0 : 1
|
|
579
646
|
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_REMAINING"]) if body[:remaining].to_i < minimum
|
|
580
647
|
end
|
|
581
|
-
if body
|
|
648
|
+
if body[:metadata] && (create || config[:enable_metadata])
|
|
582
649
|
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["METADATA_DISABLED"]) unless config[:enable_metadata]
|
|
583
650
|
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_METADATA_TYPE"]) unless body[:metadata].nil? || body[:metadata].is_a?(Hash)
|
|
584
651
|
end
|
|
585
|
-
server_only_keys = %i[refill_amount refill_interval rate_limit_max rate_limit_time_window rate_limit_enabled remaining]
|
|
586
|
-
server_only_keys
|
|
587
|
-
if client && server_only_keys.any? { |key| body.key?(key) }
|
|
652
|
+
server_only_keys = %i[refill_amount refill_interval rate_limit_max rate_limit_time_window rate_limit_enabled remaining permissions]
|
|
653
|
+
if client && server_only_keys.any? { |key| (create && key == :remaining) ? !body[:remaining].nil? : body.key?(key) }
|
|
588
654
|
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["SERVER_ONLY_PROPERTY"])
|
|
589
655
|
end
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
if body[:refill_interval] && !body[:refill_amount]
|
|
656
|
+
amount_present = body.key?(:refill_amount)
|
|
657
|
+
interval_present = body.key?(:refill_interval)
|
|
658
|
+
if amount_present && !interval_present
|
|
594
659
|
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_AMOUNT_AND_INTERVAL_REQUIRED"])
|
|
595
660
|
end
|
|
661
|
+
if interval_present && !amount_present
|
|
662
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_INTERVAL_AND_AMOUNT_REQUIRED"])
|
|
663
|
+
end
|
|
596
664
|
if body.key?(:expires_in)
|
|
597
665
|
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["KEY_DISABLED_EXPIRATION"]) if config[:key_expiration][:disable_custom_expires_time]
|
|
666
|
+
return if body[:expires_in].nil?
|
|
598
667
|
|
|
599
668
|
days = body[:expires_in].to_f / 86_400
|
|
600
669
|
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["EXPIRES_IN_IS_TOO_SMALL"]) if days < config[:key_expiration][:min_expires_in].to_f
|
|
@@ -602,7 +671,7 @@ module BetterAuth
|
|
|
602
671
|
end
|
|
603
672
|
end
|
|
604
673
|
|
|
605
|
-
def api_key_update_payload(body,
|
|
674
|
+
def api_key_update_payload(body, config)
|
|
606
675
|
update = {}
|
|
607
676
|
update[:name] = body[:name] if body.key?(:name)
|
|
608
677
|
update[:enabled] = body[:enabled] unless body[:enabled].nil?
|
|
@@ -613,7 +682,7 @@ module BetterAuth
|
|
|
613
682
|
update[:rateLimitTimeWindow] = body[:rate_limit_time_window] if body.key?(:rate_limit_time_window)
|
|
614
683
|
update[:rateLimitMax] = body[:rate_limit_max] if body.key?(:rate_limit_max)
|
|
615
684
|
update[:expiresAt] = body[:expires_in].nil? ? nil : Time.now + body[:expires_in].to_i if body.key?(:expires_in)
|
|
616
|
-
update[:metadata] = api_key_encode_json(body[:metadata]) if body.key?(:metadata)
|
|
685
|
+
update[:metadata] = api_key_encode_json(body[:metadata]) if body.key?(:metadata) && config[:enable_metadata]
|
|
617
686
|
update[:permissions] = api_key_encode_json(body[:permissions]) if body.key?(:permissions)
|
|
618
687
|
update
|
|
619
688
|
end
|
|
@@ -622,16 +691,17 @@ module BetterAuth
|
|
|
622
691
|
generator = config[:custom_key_generator]
|
|
623
692
|
return generator.call({length: config[:default_key_length], prefix: prefix}) if generator.respond_to?(:call)
|
|
624
693
|
|
|
625
|
-
"
|
|
694
|
+
alphabet = [*("a".."z"), *("A".."Z")]
|
|
695
|
+
"#{prefix}#{Array.new(config[:default_key_length].to_i) { alphabet[SecureRandom.random_number(alphabet.length)] }.join}"
|
|
626
696
|
end
|
|
627
697
|
|
|
628
698
|
def api_key_hash(key, config)
|
|
629
|
-
config[:disable_key_hashing] ? key.to_s :
|
|
699
|
+
config[:disable_key_hashing] ? key.to_s : default_api_key_hasher(key)
|
|
630
700
|
end
|
|
631
701
|
|
|
632
702
|
def api_key_expires_at(body, config)
|
|
633
703
|
if body.key?(:expires_in)
|
|
634
|
-
Time.now + body[:expires_in].to_i
|
|
704
|
+
Time.now + body[:expires_in].to_i unless body[:expires_in].nil?
|
|
635
705
|
elsif config[:key_expiration][:default_expires_in]
|
|
636
706
|
Time.now + config[:key_expiration][:default_expires_in].to_i
|
|
637
707
|
end
|
|
@@ -653,7 +723,9 @@ module BetterAuth
|
|
|
653
723
|
return record if record
|
|
654
724
|
return nil unless config[:fallback_to_database]
|
|
655
725
|
end
|
|
656
|
-
ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
|
|
726
|
+
record = ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
|
|
727
|
+
api_key_storage_set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
|
|
728
|
+
record
|
|
657
729
|
end
|
|
658
730
|
|
|
659
731
|
def api_key_find_by_id(ctx, id, config)
|
|
@@ -662,7 +734,9 @@ module BetterAuth
|
|
|
662
734
|
return record if record
|
|
663
735
|
return nil unless config[:fallback_to_database]
|
|
664
736
|
end
|
|
665
|
-
ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
|
|
737
|
+
record = ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
|
|
738
|
+
api_key_storage_set(ctx, record, config) if record && config[:storage] == "secondary-storage" && config[:fallback_to_database]
|
|
739
|
+
record
|
|
666
740
|
end
|
|
667
741
|
|
|
668
742
|
def api_key_list_for_user(ctx, user_id, config)
|
|
@@ -682,7 +756,9 @@ module BetterAuth
|
|
|
682
756
|
end
|
|
683
757
|
records = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "referenceId", value: reference_id}])
|
|
684
758
|
legacy = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "userId", value: reference_id}])
|
|
685
|
-
(records + legacy).uniq { |record| record["id"] }
|
|
759
|
+
combined = (records + legacy).uniq { |record| record["id"] }
|
|
760
|
+
api_key_storage_populate_reference(ctx, reference_id, combined, config) if config[:storage] == "secondary-storage" && config[:fallback_to_database]
|
|
761
|
+
combined
|
|
686
762
|
end
|
|
687
763
|
|
|
688
764
|
def api_key_update_record(ctx, record, update, config, defer: false)
|
|
@@ -710,8 +786,34 @@ module BetterAuth
|
|
|
710
786
|
api_key_storage_delete(ctx, record, config) if config[:storage] == "secondary-storage"
|
|
711
787
|
end
|
|
712
788
|
|
|
713
|
-
def
|
|
789
|
+
def api_key_schedule_record_delete(ctx, record, config)
|
|
790
|
+
task = -> { api_key_delete_record(ctx, record, config) }
|
|
791
|
+
if config[:defer_updates] && api_key_background_tasks?(ctx)
|
|
792
|
+
ctx.context.run_in_background(task)
|
|
793
|
+
else
|
|
794
|
+
task.call
|
|
795
|
+
end
|
|
796
|
+
end
|
|
797
|
+
|
|
798
|
+
def api_key_schedule_cleanup(ctx, config)
|
|
799
|
+
task = -> { api_key_delete_expired(ctx.context, config) }
|
|
800
|
+
if config[:defer_updates] && api_key_background_tasks?(ctx)
|
|
801
|
+
ctx.context.run_in_background(task)
|
|
802
|
+
else
|
|
803
|
+
task.call
|
|
804
|
+
end
|
|
805
|
+
end
|
|
806
|
+
|
|
807
|
+
@api_key_last_expired_check = nil
|
|
808
|
+
|
|
809
|
+
def api_key_delete_expired(context, config, bypass_last_check: false)
|
|
714
810
|
return unless config[:storage] == "database" || config[:fallback_to_database]
|
|
811
|
+
unless bypass_last_check
|
|
812
|
+
now = Time.now
|
|
813
|
+
return if @api_key_last_expired_check && ((now - @api_key_last_expired_check) * 1000) < 10_000
|
|
814
|
+
|
|
815
|
+
@api_key_last_expired_check = now
|
|
816
|
+
end
|
|
715
817
|
|
|
716
818
|
expired = context.adapter.find_many(model: API_KEY_TABLE_NAME).select do |record|
|
|
717
819
|
record["expiresAt"] && record["expiresAt"] < Time.now
|
|
@@ -734,35 +836,98 @@ module BetterAuth
|
|
|
734
836
|
|
|
735
837
|
def api_key_storage_set(ctx, record, config)
|
|
736
838
|
storage = api_key_storage(config, ctx.context)
|
|
737
|
-
|
|
839
|
+
unless storage
|
|
840
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: "Secondary storage is required when storage mode is 'secondary-storage'")
|
|
841
|
+
end
|
|
738
842
|
|
|
739
843
|
serialized = JSON.generate(api_key_storage_record(record))
|
|
740
844
|
expires_at = api_key_normalize_time(record["expiresAt"])
|
|
741
845
|
ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
|
|
742
846
|
reference_id = api_key_record_reference_id(record)
|
|
743
|
-
storage.set("api-key:#{record["key"]}", serialized, ttl)
|
|
744
|
-
storage.set("api-key:by-id:#{record["id"]}", serialized, ttl)
|
|
745
847
|
user_key = "api-key:by-ref:#{reference_id}"
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
848
|
+
|
|
849
|
+
api_key_storage_batch(storage) do
|
|
850
|
+
operations = [
|
|
851
|
+
-> { storage.set("api-key:#{record["key"]}", serialized, ttl) },
|
|
852
|
+
-> { storage.set("api-key:by-id:#{record["id"]}", serialized, ttl) }
|
|
853
|
+
]
|
|
854
|
+
operations << if config[:fallback_to_database]
|
|
855
|
+
# In fallback mode the ref list is a cache invalidated on writes
|
|
856
|
+
# to avoid races with concurrent writers of the same reference.
|
|
857
|
+
-> { storage.delete(user_key) }
|
|
858
|
+
else
|
|
859
|
+
-> { api_key_ref_list_add(storage, user_key, record["id"]) }
|
|
860
|
+
end
|
|
861
|
+
operations.each(&:call)
|
|
862
|
+
end
|
|
751
863
|
end
|
|
752
864
|
|
|
753
865
|
def api_key_storage_delete(ctx, record, config)
|
|
754
866
|
storage = api_key_storage(config, ctx.context)
|
|
755
867
|
return unless storage
|
|
756
868
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
storage
|
|
761
|
-
|
|
762
|
-
|
|
869
|
+
reference_id = api_key_record_reference_id(record)
|
|
870
|
+
user_key = "api-key:by-ref:#{reference_id}"
|
|
871
|
+
|
|
872
|
+
api_key_storage_batch(storage) do
|
|
873
|
+
operations = [
|
|
874
|
+
-> { storage.delete("api-key:#{record["key"]}") },
|
|
875
|
+
-> { storage.delete("api-key:by-id:#{record["id"]}") },
|
|
876
|
+
# Ruby-only legacy storage layout cleanup; upstream never wrote here.
|
|
877
|
+
-> { storage.delete("api-key:key:#{record["key"]}") },
|
|
878
|
+
-> { storage.delete("api-key:id:#{record["id"]}") }
|
|
879
|
+
]
|
|
880
|
+
operations << if config[:fallback_to_database]
|
|
881
|
+
-> { storage.delete(user_key) }
|
|
882
|
+
else
|
|
883
|
+
-> { api_key_ref_list_remove(storage, user_key, record["id"]) }
|
|
884
|
+
end
|
|
885
|
+
operations.each(&:call)
|
|
886
|
+
end
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
def api_key_ref_list_add(storage, user_key, id)
|
|
890
|
+
ids = api_key_safe_parse_id_list(storage.get(user_key))
|
|
891
|
+
ids << id unless ids.include?(id)
|
|
892
|
+
storage.set(user_key, JSON.generate(ids))
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def api_key_ref_list_remove(storage, user_key, id)
|
|
896
|
+
ids = api_key_safe_parse_id_list(storage.get(user_key)).reject { |existing| existing == id }
|
|
763
897
|
ids.empty? ? storage.delete(user_key) : storage.set(user_key, JSON.generate(ids))
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
def api_key_safe_parse_id_list(raw)
|
|
901
|
+
return [] if raw.nil?
|
|
902
|
+
|
|
903
|
+
parsed = JSON.parse(raw.to_s)
|
|
904
|
+
parsed.is_a?(Array) ? parsed : []
|
|
764
905
|
rescue JSON::ParserError
|
|
765
|
-
|
|
906
|
+
[]
|
|
907
|
+
end
|
|
908
|
+
|
|
909
|
+
def api_key_storage_batch(storage, &block)
|
|
910
|
+
if storage.respond_to?(:batch)
|
|
911
|
+
storage.batch(&block)
|
|
912
|
+
else
|
|
913
|
+
block.call
|
|
914
|
+
end
|
|
915
|
+
end
|
|
916
|
+
|
|
917
|
+
def api_key_storage_populate_reference(ctx, reference_id, records, config)
|
|
918
|
+
storage = api_key_storage(config, ctx.context)
|
|
919
|
+
return unless storage
|
|
920
|
+
|
|
921
|
+
ids = []
|
|
922
|
+
records.each do |record|
|
|
923
|
+
serialized = JSON.generate(api_key_storage_record(record))
|
|
924
|
+
expires_at = api_key_normalize_time(record["expiresAt"])
|
|
925
|
+
ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
|
|
926
|
+
storage.set("api-key:#{record["key"]}", serialized, ttl)
|
|
927
|
+
storage.set("api-key:by-id:#{record["id"]}", serialized, ttl)
|
|
928
|
+
ids << record["id"]
|
|
929
|
+
end
|
|
930
|
+
ids.empty? ? storage.delete("api-key:by-ref:#{reference_id}") : storage.set("api-key:by-ref:#{reference_id}", JSON.generate(ids))
|
|
766
931
|
end
|
|
767
932
|
|
|
768
933
|
def api_key_storage_record(record)
|
|
@@ -806,6 +971,10 @@ module BetterAuth
|
|
|
806
971
|
ctx.context.options.advanced.dig(:background_tasks, :handler).respond_to?(:call)
|
|
807
972
|
end
|
|
808
973
|
|
|
974
|
+
def api_key_auth_required?(ctx)
|
|
975
|
+
!!(ctx.request || (ctx.headers && !ctx.headers.empty?))
|
|
976
|
+
end
|
|
977
|
+
|
|
809
978
|
def api_key_get_from_headers(ctx, config)
|
|
810
979
|
getter = config[:custom_api_key_getter]
|
|
811
980
|
return getter.call(ctx) if getter.respond_to?(:call)
|
|
@@ -821,11 +990,9 @@ module BetterAuth
|
|
|
821
990
|
return if required.nil? || required == {}
|
|
822
991
|
|
|
823
992
|
actual = api_key_decode_json(record["permissions"]) || {}
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"])
|
|
828
|
-
end
|
|
993
|
+
result = Role.new(actual).authorize(required)
|
|
994
|
+
unless result[:success]
|
|
995
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"], code: "KEY_NOT_FOUND")
|
|
829
996
|
end
|
|
830
997
|
end
|
|
831
998
|
|