better_auth-api-key 0.1.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 +7 -0
- data/CHANGELOG.md +5 -0
- data/README.md +27 -0
- data/lib/better_auth/api_key/version.rb +7 -0
- data/lib/better_auth/api_key.rb +10 -0
- data/lib/better_auth/plugins/api_key.rb +855 -0
- metadata +120 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5a2a00e41f4d86078b25bb86e9b515412d48b732f1817a16a33de6916d4ac287
|
|
4
|
+
data.tar.gz: 0cea4d53a5bee49499501804ea8a80db5a62555f4c5049b24bb1a7469f7eddcf
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ae64ad016758516945f7a76860196c381358183df9918f06d2c4588d746bbdb57ccf3c6143ecaf8a14074d33eadea558f321ad0295b6012393bc3a306b442b20
|
|
7
|
+
data.tar.gz: 65b54fd1360d696467f09888e16c0c76b9681aa6ea9b59137b4c16f132f34993d5ebf30b8a63e99f711ab0da136f9eb1e1ab43de474ff023d6b846058b049296
|
data/CHANGELOG.md
ADDED
data/README.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# better_auth-api-key
|
|
2
|
+
|
|
3
|
+
API key plugin package for Better Auth Ruby.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add the gem and require the package before configuring the plugin:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "better_auth-api-key"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
require "better_auth/api_key"
|
|
15
|
+
|
|
16
|
+
auth = BetterAuth.auth(
|
|
17
|
+
secret: ENV.fetch("BETTER_AUTH_SECRET"),
|
|
18
|
+
database: :memory,
|
|
19
|
+
plugins: [
|
|
20
|
+
BetterAuth::Plugins.api_key
|
|
21
|
+
]
|
|
22
|
+
)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Notes
|
|
26
|
+
|
|
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.
|
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module BetterAuth
|
|
8
|
+
module Plugins
|
|
9
|
+
singleton_class.remove_method(:api_key) if singleton_class.method_defined?(:api_key)
|
|
10
|
+
remove_method(:api_key) if method_defined?(:api_key) || private_method_defined?(:api_key)
|
|
11
|
+
|
|
12
|
+
API_KEY_ERROR_CODES = {
|
|
13
|
+
"INVALID_METADATA_TYPE" => "metadata must be an object or undefined",
|
|
14
|
+
"REFILL_AMOUNT_AND_INTERVAL_REQUIRED" => "refillAmount is required when refillInterval is provided",
|
|
15
|
+
"REFILL_INTERVAL_AND_AMOUNT_REQUIRED" => "refillInterval is required when refillAmount is provided",
|
|
16
|
+
"USER_BANNED" => "User is banned",
|
|
17
|
+
"UNAUTHORIZED_SESSION" => "Unauthorized or invalid session",
|
|
18
|
+
"KEY_NOT_FOUND" => "API Key not found",
|
|
19
|
+
"KEY_DISABLED" => "API Key is disabled",
|
|
20
|
+
"KEY_EXPIRED" => "API Key has expired",
|
|
21
|
+
"USAGE_EXCEEDED" => "API Key has reached its usage limit",
|
|
22
|
+
"KEY_NOT_RECOVERABLE" => "API Key is not recoverable",
|
|
23
|
+
"EXPIRES_IN_IS_TOO_SMALL" => "The expiresIn is smaller than the predefined minimum value.",
|
|
24
|
+
"EXPIRES_IN_IS_TOO_LARGE" => "The expiresIn is larger than the predefined maximum value.",
|
|
25
|
+
"INVALID_REMAINING" => "The remaining count is either too large or too small.",
|
|
26
|
+
"INVALID_PREFIX_LENGTH" => "The prefix length is either too large or too small.",
|
|
27
|
+
"INVALID_NAME_LENGTH" => "The name length is either too large or too small.",
|
|
28
|
+
"METADATA_DISABLED" => "Metadata is disabled.",
|
|
29
|
+
"RATE_LIMIT_EXCEEDED" => "Rate limit exceeded.",
|
|
30
|
+
"NO_VALUES_TO_UPDATE" => "No values to update.",
|
|
31
|
+
"KEY_DISABLED_EXPIRATION" => "Custom key expiration values are disabled.",
|
|
32
|
+
"INVALID_API_KEY" => "Invalid API key.",
|
|
33
|
+
"INVALID_USER_ID_FROM_API_KEY" => "The user id from the API key is invalid.",
|
|
34
|
+
"INVALID_REFERENCE_ID_FROM_API_KEY" => "The reference id from the API key is invalid.",
|
|
35
|
+
"INVALID_API_KEY_GETTER_RETURN_TYPE" => "API Key getter returned an invalid key type. Expected string.",
|
|
36
|
+
"SERVER_ONLY_PROPERTY" => "The property you're trying to set can only be set from the server auth instance only.",
|
|
37
|
+
"FAILED_TO_UPDATE_API_KEY" => "Failed to update API key",
|
|
38
|
+
"NAME_REQUIRED" => "API Key name is required.",
|
|
39
|
+
"ORGANIZATION_ID_REQUIRED" => "Organization ID is required for organization-owned API keys.",
|
|
40
|
+
"USER_NOT_MEMBER_OF_ORGANIZATION" => "You are not a member of the organization that owns this API key.",
|
|
41
|
+
"INSUFFICIENT_API_KEY_PERMISSIONS" => "You do not have permission to perform this action on organization API keys.",
|
|
42
|
+
"NO_DEFAULT_API_KEY_CONFIGURATION_FOUND" => "No default api-key configuration found.",
|
|
43
|
+
"ORGANIZATION_PLUGIN_REQUIRED" => "Organization plugin is required for organization-owned API keys. Please install and configure the organization plugin."
|
|
44
|
+
}.freeze
|
|
45
|
+
|
|
46
|
+
API_KEY_TABLE_NAME = "apikey"
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def api_key(configurations = {}, options = nil)
|
|
51
|
+
config = api_key_config(configurations, options)
|
|
52
|
+
Plugin.new(
|
|
53
|
+
id: "api-key",
|
|
54
|
+
hooks: {
|
|
55
|
+
before: [
|
|
56
|
+
{
|
|
57
|
+
matcher: ->(ctx) { !!api_key_session_header_config(ctx, config) },
|
|
58
|
+
handler: ->(ctx) { api_key_session_hook(ctx, config) }
|
|
59
|
+
}
|
|
60
|
+
]
|
|
61
|
+
},
|
|
62
|
+
endpoints: {
|
|
63
|
+
create_api_key: api_key_create_endpoint(config),
|
|
64
|
+
verify_api_key: api_key_verify_endpoint(config),
|
|
65
|
+
get_api_key: api_key_get_endpoint(config),
|
|
66
|
+
update_api_key: api_key_update_endpoint(config),
|
|
67
|
+
delete_api_key: api_key_delete_endpoint(config),
|
|
68
|
+
list_api_keys: api_key_list_endpoint(config),
|
|
69
|
+
delete_all_expired_api_keys: api_key_delete_expired_endpoint(config)
|
|
70
|
+
},
|
|
71
|
+
schema: api_key_schema(config, config[:schema]),
|
|
72
|
+
error_codes: API_KEY_ERROR_CODES,
|
|
73
|
+
options: config
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def api_key_config(configurations, options = nil)
|
|
78
|
+
if configurations.is_a?(Array)
|
|
79
|
+
normalized_configs = configurations.map { |config| api_key_single_config(config) }
|
|
80
|
+
if normalized_configs.any? { |config| config[:config_id].to_s.empty? }
|
|
81
|
+
raise Error, "configId is required for each API key configuration in the api-key plugin."
|
|
82
|
+
end
|
|
83
|
+
config_ids = normalized_configs.map { |config| config[:config_id] }
|
|
84
|
+
raise Error, "configId must be unique for each API key configuration in the api-key plugin." if config_ids.uniq.length != config_ids.length
|
|
85
|
+
|
|
86
|
+
plugin_options = normalize_hash(options || {})
|
|
87
|
+
default_config = normalized_configs.find { |config| api_key_default_config_id?(config[:config_id]) }
|
|
88
|
+
default_config ||= normalized_configs.first
|
|
89
|
+
default_config.merge(
|
|
90
|
+
configurations: normalized_configs,
|
|
91
|
+
schema: plugin_options[:schema] || default_config[:schema]
|
|
92
|
+
)
|
|
93
|
+
else
|
|
94
|
+
config = api_key_single_config(configurations)
|
|
95
|
+
config[:config_id] ||= "default"
|
|
96
|
+
config.merge(configurations: [config])
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def api_key_single_config(options)
|
|
101
|
+
data = normalize_hash(options || {})
|
|
102
|
+
rate_limit_options = data[:rate_limit] || {}
|
|
103
|
+
starting_characters_options = data[:starting_characters_config] || {}
|
|
104
|
+
{
|
|
105
|
+
config_id: data[:config_id],
|
|
106
|
+
api_key_headers: data[:api_key_headers] || "x-api-key",
|
|
107
|
+
default_key_length: data[:default_key_length] || 64,
|
|
108
|
+
default_prefix: data[:default_prefix],
|
|
109
|
+
maximum_prefix_length: data.key?(:maximum_prefix_length) ? data[:maximum_prefix_length] : 32,
|
|
110
|
+
minimum_prefix_length: data.key?(:minimum_prefix_length) ? data[:minimum_prefix_length] : 1,
|
|
111
|
+
maximum_name_length: data.key?(:maximum_name_length) ? data[:maximum_name_length] : 32,
|
|
112
|
+
minimum_name_length: data.key?(:minimum_name_length) ? data[:minimum_name_length] : 1,
|
|
113
|
+
enable_metadata: data[:enable_metadata] || false,
|
|
114
|
+
disable_key_hashing: data[:disable_key_hashing] || false,
|
|
115
|
+
require_name: data[:require_name] || false,
|
|
116
|
+
storage: data[:storage] || "database",
|
|
117
|
+
rate_limit: {
|
|
118
|
+
enabled: rate_limit_options.fetch(:enabled, true),
|
|
119
|
+
time_window: rate_limit_options[:time_window] || 86_400_000,
|
|
120
|
+
max_requests: rate_limit_options[:max_requests] || 10
|
|
121
|
+
},
|
|
122
|
+
key_expiration: {
|
|
123
|
+
default_expires_in: data.dig(:key_expiration, :default_expires_in),
|
|
124
|
+
disable_custom_expires_time: data.dig(:key_expiration, :disable_custom_expires_time) || false,
|
|
125
|
+
max_expires_in: data.dig(:key_expiration, :max_expires_in) || 365,
|
|
126
|
+
min_expires_in: data.dig(:key_expiration, :min_expires_in) || 1
|
|
127
|
+
},
|
|
128
|
+
starting_characters_config: {
|
|
129
|
+
should_store: starting_characters_options.fetch(:should_store, true),
|
|
130
|
+
characters_length: starting_characters_options[:characters_length] || 6
|
|
131
|
+
},
|
|
132
|
+
enable_session_for_api_keys: data[:enable_session_for_api_keys] || false,
|
|
133
|
+
fallback_to_database: data[:fallback_to_database] || false,
|
|
134
|
+
custom_storage: data[:custom_storage],
|
|
135
|
+
custom_key_generator: data[:custom_key_generator],
|
|
136
|
+
custom_api_key_getter: data[:custom_api_key_getter],
|
|
137
|
+
custom_api_key_validator: data[:custom_api_key_validator],
|
|
138
|
+
default_permissions: data[:default_permissions],
|
|
139
|
+
permissions: data[:permissions] || {},
|
|
140
|
+
references: data[:references] || "user",
|
|
141
|
+
defer_updates: data[:defer_updates] || false,
|
|
142
|
+
schema: data[:schema]
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def api_key_schema(config, custom_schema = nil)
|
|
147
|
+
base = {
|
|
148
|
+
apikey: {
|
|
149
|
+
fields: {
|
|
150
|
+
configId: {type: "string", required: true, default_value: "default", index: true},
|
|
151
|
+
name: {type: "string", required: false},
|
|
152
|
+
start: {type: "string", required: false},
|
|
153
|
+
prefix: {type: "string", required: false},
|
|
154
|
+
key: {type: "string", required: true, index: true},
|
|
155
|
+
referenceId: {type: "string", required: true, index: true},
|
|
156
|
+
userId: {type: "string", required: false, index: true, references: {model: "user", field: "id", on_delete: "cascade"}},
|
|
157
|
+
refillInterval: {type: "number", required: false},
|
|
158
|
+
refillAmount: {type: "number", required: false},
|
|
159
|
+
lastRefillAt: {type: "date", required: false},
|
|
160
|
+
enabled: {type: "boolean", required: false, default_value: true},
|
|
161
|
+
rateLimitEnabled: {type: "boolean", required: false, default_value: true},
|
|
162
|
+
rateLimitTimeWindow: {type: "number", required: false, default_value: config[:rate_limit][:time_window]},
|
|
163
|
+
rateLimitMax: {type: "number", required: false, default_value: config[:rate_limit][:max_requests]},
|
|
164
|
+
requestCount: {type: "number", required: false, default_value: 0},
|
|
165
|
+
remaining: {type: "number", required: false},
|
|
166
|
+
lastRequest: {type: "date", required: false},
|
|
167
|
+
expiresAt: {type: "date", required: false},
|
|
168
|
+
createdAt: {type: "date", required: true},
|
|
169
|
+
updatedAt: {type: "date", required: true},
|
|
170
|
+
permissions: {type: "string", required: false},
|
|
171
|
+
metadata: {type: "string", required: false}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
deep_merge_hashes(base, normalize_hash(custom_schema || {}))
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def api_key_create_endpoint(config)
|
|
179
|
+
Endpoint.new(path: "/api-key/create", method: "POST") do |ctx|
|
|
180
|
+
body = normalize_hash(ctx.body)
|
|
181
|
+
resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
|
|
182
|
+
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
|
+
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
|
+
|
|
190
|
+
api_key_validate_create_update!(body, resolved_config, create: true, client: !!session)
|
|
191
|
+
key_prefix = body.key?(:prefix) ? body[:prefix] : resolved_config[:default_prefix]
|
|
192
|
+
key = api_key_generate_key(resolved_config, key_prefix)
|
|
193
|
+
now = Time.now
|
|
194
|
+
hashed = api_key_hash(key, resolved_config)
|
|
195
|
+
data = {
|
|
196
|
+
configId: resolved_config[:config_id] || "default",
|
|
197
|
+
name: body[:name],
|
|
198
|
+
start: resolved_config[:starting_characters_config][:should_store] ? key[0, resolved_config[:starting_characters_config][:characters_length].to_i] : nil,
|
|
199
|
+
prefix: key_prefix,
|
|
200
|
+
key: hashed,
|
|
201
|
+
userId: user_id,
|
|
202
|
+
referenceId: reference_id,
|
|
203
|
+
enabled: true,
|
|
204
|
+
rateLimitEnabled: body.key?(:rate_limit_enabled) ? body[:rate_limit_enabled] : resolved_config[:rate_limit][:enabled],
|
|
205
|
+
rateLimitTimeWindow: body[:rate_limit_time_window] || resolved_config[:rate_limit][:time_window],
|
|
206
|
+
rateLimitMax: body[:rate_limit_max] || resolved_config[:rate_limit][:max_requests],
|
|
207
|
+
requestCount: 0,
|
|
208
|
+
remaining: body.key?(:remaining) ? body[:remaining] : (body[:refill_amount] || nil),
|
|
209
|
+
refillAmount: body[:refill_amount],
|
|
210
|
+
refillInterval: body[:refill_interval],
|
|
211
|
+
lastRefillAt: now,
|
|
212
|
+
expiresAt: api_key_expires_at(body, resolved_config),
|
|
213
|
+
createdAt: now,
|
|
214
|
+
updatedAt: now,
|
|
215
|
+
permissions: api_key_encode_json(body[:permissions] || api_key_default_permissions(resolved_config, reference_id, ctx)),
|
|
216
|
+
metadata: body.key?(:metadata) ? api_key_encode_json(body[:metadata]) : nil
|
|
217
|
+
}
|
|
218
|
+
record = api_key_store(ctx, data, resolved_config)
|
|
219
|
+
api_key_public(record, reveal_key: key, include_key_field: true)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def api_key_verify_endpoint(config)
|
|
224
|
+
Endpoint.new(path: "/api-key/verify", method: "POST") do |ctx|
|
|
225
|
+
body = normalize_hash(ctx.body)
|
|
226
|
+
resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
|
|
227
|
+
key = body[:key] || api_key_get_from_headers(ctx, config)
|
|
228
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY") if key.to_s.empty?
|
|
229
|
+
|
|
230
|
+
record = api_key_validate!(ctx, key, resolved_config, permissions: body[:permissions])
|
|
231
|
+
record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
|
|
232
|
+
api_key_delete_expired(ctx.context, record_config)
|
|
233
|
+
ctx.json({valid: true, error: nil, key: api_key_public(record, include_key_field: false)})
|
|
234
|
+
rescue APIError => error
|
|
235
|
+
ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
|
|
236
|
+
ctx.json({valid: false, error: {message: error.message, code: api_key_error_code(error)}, key: nil})
|
|
237
|
+
rescue => error
|
|
238
|
+
ctx.context.logger.error("Failed to validate API key: #{error.message}") if ctx.context.logger.respond_to?(:error)
|
|
239
|
+
ctx.json({valid: false, error: {message: API_KEY_ERROR_CODES["INVALID_API_KEY"], code: "INVALID_API_KEY"}, key: nil})
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def api_key_get_endpoint(config)
|
|
244
|
+
Endpoint.new(path: "/api-key/get", method: "GET") do |ctx|
|
|
245
|
+
session = Routes.current_session(ctx)
|
|
246
|
+
query = normalize_hash(ctx.query)
|
|
247
|
+
resolved_config = api_key_resolve_config(ctx.context, config, query[:config_id])
|
|
248
|
+
id = query[:id]
|
|
249
|
+
record = api_key_find_by_id(ctx, id, resolved_config)
|
|
250
|
+
raise APIError.new("NOT_FOUND", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"]) unless record && api_key_config_id_matches?(api_key_record_config_id(record), resolved_config[:config_id])
|
|
251
|
+
|
|
252
|
+
record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
|
|
253
|
+
api_key_authorize_reference!(ctx, record_config, session[:user]["id"], api_key_record_reference_id(record), "read")
|
|
254
|
+
|
|
255
|
+
record = api_key_migrate_legacy_metadata(ctx, record, record_config)
|
|
256
|
+
api_key_delete_expired(ctx.context, record_config)
|
|
257
|
+
ctx.json(api_key_public(record, include_key_field: false))
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def api_key_update_endpoint(config)
|
|
262
|
+
Endpoint.new(path: "/api-key/update", method: "POST") do |ctx|
|
|
263
|
+
body = normalize_hash(ctx.body)
|
|
264
|
+
resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
|
|
265
|
+
session = Routes.current_session(ctx, allow_nil: true)
|
|
266
|
+
user_id = session&.dig(:user, "id") || body[:user_id]
|
|
267
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) unless user_id
|
|
268
|
+
if session && body[:user_id] && body[:user_id] != session[:user]["id"]
|
|
269
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
key_id = body[:key_id]
|
|
273
|
+
record = api_key_find_by_id(ctx, key_id, resolved_config)
|
|
274
|
+
raise APIError.new("NOT_FOUND", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"]) unless record
|
|
275
|
+
raise APIError.new("NOT_FOUND", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"]) unless api_key_config_id_matches?(api_key_record_config_id(record), resolved_config[:config_id])
|
|
276
|
+
|
|
277
|
+
record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
|
|
278
|
+
api_key_authorize_reference!(ctx, record_config, user_id, api_key_record_reference_id(record), "update")
|
|
279
|
+
|
|
280
|
+
api_key_validate_create_update!(body, record_config, create: false, client: !!session)
|
|
281
|
+
update = api_key_update_payload(body, record_config)
|
|
282
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["NO_VALUES_TO_UPDATE"]) if update.empty?
|
|
283
|
+
|
|
284
|
+
updated = api_key_update_record(ctx, record, update.merge(updatedAt: Time.now), record_config)
|
|
285
|
+
updated = api_key_migrate_legacy_metadata(ctx, updated, record_config)
|
|
286
|
+
api_key_delete_expired(ctx.context, record_config)
|
|
287
|
+
ctx.json(api_key_public(updated, include_key_field: false))
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def api_key_delete_endpoint(config)
|
|
292
|
+
Endpoint.new(path: "/api-key/delete", method: "POST") do |ctx|
|
|
293
|
+
session = Routes.current_session(ctx)
|
|
294
|
+
body = normalize_hash(ctx.body)
|
|
295
|
+
resolved_config = api_key_resolve_config(ctx.context, config, body[:config_id])
|
|
296
|
+
key_id = body[:key_id]
|
|
297
|
+
record = api_key_find_by_id(ctx, key_id, resolved_config)
|
|
298
|
+
raise APIError.new("NOT_FOUND", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"]) unless record && api_key_config_id_matches?(api_key_record_config_id(record), resolved_config[:config_id])
|
|
299
|
+
|
|
300
|
+
record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
|
|
301
|
+
api_key_authorize_reference!(ctx, record_config, session[:user]["id"], api_key_record_reference_id(record), "delete")
|
|
302
|
+
|
|
303
|
+
api_key_delete_record(ctx, record, record_config)
|
|
304
|
+
api_key_delete_expired(ctx.context, record_config)
|
|
305
|
+
ctx.json({success: true})
|
|
306
|
+
end
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def api_key_list_endpoint(config)
|
|
310
|
+
Endpoint.new(path: "/api-key/list", method: "GET") do |ctx|
|
|
311
|
+
session = Routes.current_session(ctx)
|
|
312
|
+
query = normalize_hash(ctx.query)
|
|
313
|
+
configs = query[:config_id] ? [api_key_resolve_config(ctx.context, config, query[:config_id])] : config.fetch(:configurations, [config])
|
|
314
|
+
reference_id = query[:organization_id] || session[:user]["id"]
|
|
315
|
+
expected_reference = query[:organization_id] ? "organization" : "user"
|
|
316
|
+
api_key_check_org_permission!(ctx, session[:user]["id"], reference_id, "read") if query[:organization_id]
|
|
317
|
+
records = configs.flat_map { |entry| api_key_list_for_reference(ctx, reference_id, entry) }.uniq { |record| record["id"] }
|
|
318
|
+
records = records.select do |record|
|
|
319
|
+
record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
|
|
320
|
+
record_config[:references].to_s == expected_reference &&
|
|
321
|
+
api_key_record_reference_id(record) == reference_id &&
|
|
322
|
+
(!query[:config_id] || api_key_config_id_matches?(api_key_record_config_id(record), query[:config_id]))
|
|
323
|
+
end
|
|
324
|
+
total = records.length
|
|
325
|
+
records = api_key_sort_records(records, query[:sort_by], query[:sort_direction])
|
|
326
|
+
offset = query.key?(:offset) ? query[:offset].to_i : nil
|
|
327
|
+
limit = query.key?(:limit) ? query[:limit].to_i : nil
|
|
328
|
+
records = records.drop(offset) if offset
|
|
329
|
+
records = records.first(limit) if limit
|
|
330
|
+
records.each { |record| api_key_delete_expired(ctx.context, api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))) }
|
|
331
|
+
api_keys = records.map do |record|
|
|
332
|
+
record_config = api_key_resolve_config(ctx.context, config, api_key_record_config_id(record))
|
|
333
|
+
api_key_public(api_key_migrate_legacy_metadata(ctx, record, record_config), include_key_field: false)
|
|
334
|
+
end
|
|
335
|
+
ctx.json({apiKeys: api_keys, total: total, limit: limit, offset: offset})
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def api_key_delete_expired_endpoint(config)
|
|
340
|
+
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})
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def api_key_resolve_config(context, config, config_id = nil)
|
|
347
|
+
configurations = config.fetch(:configurations, [config])
|
|
348
|
+
return configurations.find { |entry| api_key_default_config_id?(entry[:config_id]) } || configurations.first if config_id.to_s.empty?
|
|
349
|
+
|
|
350
|
+
configurations.find { |entry| entry[:config_id].to_s == config_id.to_s } ||
|
|
351
|
+
begin
|
|
352
|
+
default = configurations.find { |entry| api_key_default_config_id?(entry[:config_id]) }
|
|
353
|
+
unless default
|
|
354
|
+
context.logger.error(API_KEY_ERROR_CODES["NO_DEFAULT_API_KEY_CONFIGURATION_FOUND"]) if context.respond_to?(:logger) && context.logger.respond_to?(:error)
|
|
355
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["NO_DEFAULT_API_KEY_CONFIGURATION_FOUND"])
|
|
356
|
+
end
|
|
357
|
+
default
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def api_key_default_config_id?(value)
|
|
362
|
+
value.nil? || value.to_s.empty? || value.to_s == "default"
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def api_key_config_id_matches?(record_config_id, expected_config_id)
|
|
366
|
+
return true if api_key_default_config_id?(record_config_id) && api_key_default_config_id?(expected_config_id)
|
|
367
|
+
|
|
368
|
+
record_config_id.to_s == expected_config_id.to_s
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def api_key_create_reference_id!(ctx, body, session, config)
|
|
372
|
+
if config[:references].to_s == "organization"
|
|
373
|
+
organization_id = body[:organization_id]
|
|
374
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["ORGANIZATION_ID_REQUIRED"]) if organization_id.to_s.empty?
|
|
375
|
+
|
|
376
|
+
user_id = session&.dig(:user, "id") || body[:user_id]
|
|
377
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) if user_id.to_s.empty?
|
|
378
|
+
|
|
379
|
+
api_key_check_org_permission!(ctx, user_id, organization_id, "create")
|
|
380
|
+
organization_id
|
|
381
|
+
elsif session && body[:user_id] && body[:user_id] != session[:user]["id"]
|
|
382
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"])
|
|
383
|
+
elsif session
|
|
384
|
+
|
|
385
|
+
session[:user]["id"]
|
|
386
|
+
else
|
|
387
|
+
user_id = body[:user_id]
|
|
388
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["UNAUTHORIZED_SESSION"]) if user_id.to_s.empty?
|
|
389
|
+
|
|
390
|
+
user_id
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def api_key_record_reference_id(record)
|
|
395
|
+
record["referenceId"] || record[:referenceId] || record["userId"] || record[:userId]
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def api_key_record_user_id(record)
|
|
399
|
+
record["userId"] || record[:userId] || (api_key_default_config_id?(record["configId"] || record[:configId]) && (record["referenceId"] || record[:referenceId]))
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def api_key_record_config_id(record)
|
|
403
|
+
record["configId"] || record[:configId] || "default"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def api_key_default_permissions(config, reference_id, ctx)
|
|
407
|
+
permissions = config.dig(:permissions, :default_permissions) || config[:default_permissions]
|
|
408
|
+
return permissions.call(reference_id, ctx) if permissions.respond_to?(:call)
|
|
409
|
+
|
|
410
|
+
permissions
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
def api_key_authorize_reference!(ctx, config, user_id, reference_id, action)
|
|
414
|
+
if config[:references].to_s == "organization"
|
|
415
|
+
api_key_check_org_permission!(ctx, user_id, reference_id, action)
|
|
416
|
+
elsif reference_id != user_id
|
|
417
|
+
raise APIError.new("NOT_FOUND", message: API_KEY_ERROR_CODES["KEY_NOT_FOUND"])
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def api_key_check_org_permission!(ctx, user_id, organization_id, action)
|
|
422
|
+
org_plugin = ctx.context.options.plugins.find { |plugin| plugin.id == "organization" }
|
|
423
|
+
raise APIError.new("INTERNAL_SERVER_ERROR", message: API_KEY_ERROR_CODES["ORGANIZATION_PLUGIN_REQUIRED"]) unless org_plugin
|
|
424
|
+
|
|
425
|
+
member = ctx.context.adapter.find_one(model: "member", where: [{field: "userId", value: user_id}, {field: "organizationId", value: organization_id}])
|
|
426
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["USER_NOT_MEMBER_OF_ORGANIZATION"]) unless member
|
|
427
|
+
|
|
428
|
+
permissions = {"apiKey" => [action]}
|
|
429
|
+
return member if BetterAuth::Plugins.organization_permission?(ctx, org_plugin.options, member["role"], permissions, organization_id)
|
|
430
|
+
|
|
431
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INSUFFICIENT_API_KEY_PERMISSIONS"])
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
def api_key_sort_records(records, sort_by, direction)
|
|
435
|
+
return records unless sort_by
|
|
436
|
+
|
|
437
|
+
key = Schema.storage_key(sort_by)
|
|
438
|
+
sorted = records.sort_by { |record| record[key] || record[key.to_sym] || "" }
|
|
439
|
+
if direction.to_s.downcase == "desc"
|
|
440
|
+
sorted.reverse
|
|
441
|
+
else
|
|
442
|
+
sorted
|
|
443
|
+
end
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def api_key_error_code(error)
|
|
447
|
+
API_KEY_ERROR_CODES.key(error.message) || error.code.to_s
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def api_key_session_header_config(ctx, config)
|
|
451
|
+
config.fetch(:configurations, [config]).find do |entry|
|
|
452
|
+
entry[:enable_session_for_api_keys] && api_key_get_from_headers(ctx, entry)
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def api_key_session_hook(ctx, config)
|
|
457
|
+
config = api_key_session_header_config(ctx, config) || config
|
|
458
|
+
key = api_key_get_from_headers(ctx, config)
|
|
459
|
+
unless key.is_a?(String)
|
|
460
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_API_KEY_GETTER_RETURN_TYPE"])
|
|
461
|
+
end
|
|
462
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) if key.length < config[:default_key_length].to_i
|
|
463
|
+
|
|
464
|
+
if config[:custom_api_key_validator].respond_to?(:call) && !config[:custom_api_key_validator].call({ctx: ctx, key: key})
|
|
465
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"])
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
record = api_key_validate!(ctx, key, config)
|
|
469
|
+
if config[:references].to_s != "user"
|
|
470
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"])
|
|
471
|
+
end
|
|
472
|
+
reference_id = api_key_record_reference_id(record)
|
|
473
|
+
user = ctx.context.internal_adapter.find_user_by_id(reference_id)
|
|
474
|
+
raise APIError.new("UNAUTHORIZED", message: API_KEY_ERROR_CODES["INVALID_REFERENCE_ID_FROM_API_KEY"]) unless user
|
|
475
|
+
|
|
476
|
+
session = {
|
|
477
|
+
user: user,
|
|
478
|
+
session: {
|
|
479
|
+
"id" => record["id"],
|
|
480
|
+
"token" => key,
|
|
481
|
+
"userId" => reference_id,
|
|
482
|
+
"userAgent" => ctx.headers["user-agent"],
|
|
483
|
+
"ipAddress" => ctx.headers["x-forwarded-for"],
|
|
484
|
+
"createdAt" => Time.now,
|
|
485
|
+
"updatedAt" => Time.now,
|
|
486
|
+
"expiresAt" => record["expiresAt"] || (Time.now + ctx.context.options.session[:expires_in].to_i)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
ctx.context.set_current_session(session)
|
|
490
|
+
nil
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def api_key_validate!(ctx, key, config, permissions: nil)
|
|
494
|
+
hashed = api_key_hash(key, config)
|
|
495
|
+
record = api_key_find_by_hash(ctx, hashed, config)
|
|
496
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless record
|
|
497
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"]) unless api_key_config_id_matches?(api_key_record_config_id(record), config[:config_id])
|
|
498
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["KEY_DISABLED"]) if record["enabled"] == false
|
|
499
|
+
if record["expiresAt"] && record["expiresAt"] <= Time.now
|
|
500
|
+
api_key_delete_record(ctx, record, config)
|
|
501
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["KEY_EXPIRED"])
|
|
502
|
+
end
|
|
503
|
+
if record["remaining"].to_i <= 0 && !record["remaining"].nil? && record["refillAmount"].nil?
|
|
504
|
+
api_key_delete_record(ctx, record, config)
|
|
505
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"])
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
api_key_check_permissions!(record, permissions)
|
|
509
|
+
update = api_key_usage_update(record, config)
|
|
510
|
+
updated = api_key_update_record(ctx, record, update, config, defer: true)
|
|
511
|
+
api_key_migrate_legacy_metadata(ctx, updated || record.merge(update.transform_keys { |key_name| Schema.storage_key(key_name) }), config)
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
def api_key_usage_update(record, config)
|
|
515
|
+
now = Time.now
|
|
516
|
+
update = {lastRequest: now, updatedAt: now}
|
|
517
|
+
|
|
518
|
+
if api_key_rate_limited?(record, config, now)
|
|
519
|
+
raise APIError.new("TOO_MANY_REQUESTS", message: API_KEY_ERROR_CODES["RATE_LIMIT_EXCEEDED"])
|
|
520
|
+
end
|
|
521
|
+
update[:requestCount] = api_key_next_request_count(record, now)
|
|
522
|
+
|
|
523
|
+
remaining = record["remaining"]
|
|
524
|
+
if !remaining.nil?
|
|
525
|
+
if remaining.to_i <= 0 && record["refillAmount"] && record["refillInterval"]
|
|
526
|
+
last_refill = api_key_normalize_time(record["lastRefillAt"] || record["lastRequest"] || record["createdAt"])
|
|
527
|
+
if !last_refill || ((now - last_refill) * 1000) >= record["refillInterval"].to_i
|
|
528
|
+
remaining = record["refillAmount"].to_i
|
|
529
|
+
update[:lastRefillAt] = now
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["USAGE_EXCEEDED"]) if remaining.to_i <= 0
|
|
533
|
+
|
|
534
|
+
update[:remaining] = remaining.to_i - 1
|
|
535
|
+
end
|
|
536
|
+
update
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def api_key_rate_limited?(record, config, now)
|
|
540
|
+
return false if config[:rate_limit][:enabled] == false || record["rateLimitEnabled"] == false
|
|
541
|
+
|
|
542
|
+
window = record["rateLimitTimeWindow"]
|
|
543
|
+
max = record["rateLimitMax"]
|
|
544
|
+
return false if window.nil? || max.nil?
|
|
545
|
+
|
|
546
|
+
last = api_key_normalize_time(record["lastRequest"])
|
|
547
|
+
return false unless last && ((now - last) * 1000) <= window.to_i
|
|
548
|
+
|
|
549
|
+
record["requestCount"].to_i >= max.to_i
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def api_key_next_request_count(record, now)
|
|
553
|
+
last = api_key_normalize_time(record["lastRequest"])
|
|
554
|
+
window = record["rateLimitTimeWindow"].to_i
|
|
555
|
+
if last && window.positive? && ((now - last) * 1000) <= window
|
|
556
|
+
record["requestCount"].to_i + 1
|
|
557
|
+
else
|
|
558
|
+
1
|
|
559
|
+
end
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def api_key_validate_create_update!(body, config, create:, client:)
|
|
563
|
+
name = body[:name]
|
|
564
|
+
if create && config[:require_name] && name.to_s.empty?
|
|
565
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["NAME_REQUIRED"])
|
|
566
|
+
end
|
|
567
|
+
if name && !name.to_s.length.between?(config[:minimum_name_length].to_i, config[:maximum_name_length].to_i)
|
|
568
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_NAME_LENGTH"])
|
|
569
|
+
end
|
|
570
|
+
prefix = body[:prefix]
|
|
571
|
+
if prefix && !prefix.to_s.length.between?(config[:minimum_prefix_length].to_i, config[:maximum_prefix_length].to_i)
|
|
572
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_PREFIX_LENGTH"])
|
|
573
|
+
end
|
|
574
|
+
if prefix && !prefix.to_s.match?(/\A[a-zA-Z0-9_-]+\z/)
|
|
575
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_PREFIX_LENGTH"])
|
|
576
|
+
end
|
|
577
|
+
if body.key?(:remaining) && !body[:remaining].nil?
|
|
578
|
+
minimum = create ? 0 : 1
|
|
579
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_REMAINING"]) if body[:remaining].to_i < minimum
|
|
580
|
+
end
|
|
581
|
+
if body.key?(:metadata)
|
|
582
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["METADATA_DISABLED"]) unless config[:enable_metadata]
|
|
583
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["INVALID_METADATA_TYPE"]) unless body[:metadata].nil? || body[:metadata].is_a?(Hash)
|
|
584
|
+
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 << :permissions unless create
|
|
587
|
+
if client && server_only_keys.any? { |key| body.key?(key) }
|
|
588
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["SERVER_ONLY_PROPERTY"])
|
|
589
|
+
end
|
|
590
|
+
if body[:refill_amount] && !body[:refill_interval]
|
|
591
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_INTERVAL_AND_AMOUNT_REQUIRED"])
|
|
592
|
+
end
|
|
593
|
+
if body[:refill_interval] && !body[:refill_amount]
|
|
594
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["REFILL_AMOUNT_AND_INTERVAL_REQUIRED"])
|
|
595
|
+
end
|
|
596
|
+
if body.key?(:expires_in)
|
|
597
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["KEY_DISABLED_EXPIRATION"]) if config[:key_expiration][:disable_custom_expires_time]
|
|
598
|
+
|
|
599
|
+
days = body[:expires_in].to_f / 86_400
|
|
600
|
+
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
|
|
601
|
+
raise APIError.new("BAD_REQUEST", message: API_KEY_ERROR_CODES["EXPIRES_IN_IS_TOO_LARGE"]) if days > config[:key_expiration][:max_expires_in].to_f
|
|
602
|
+
end
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
def api_key_update_payload(body, _config)
|
|
606
|
+
update = {}
|
|
607
|
+
update[:name] = body[:name] if body.key?(:name)
|
|
608
|
+
update[:enabled] = body[:enabled] unless body[:enabled].nil?
|
|
609
|
+
update[:remaining] = body[:remaining] if body.key?(:remaining)
|
|
610
|
+
update[:refillAmount] = body[:refill_amount] if body.key?(:refill_amount)
|
|
611
|
+
update[:refillInterval] = body[:refill_interval] if body.key?(:refill_interval)
|
|
612
|
+
update[:rateLimitEnabled] = body[:rate_limit_enabled] if body.key?(:rate_limit_enabled)
|
|
613
|
+
update[:rateLimitTimeWindow] = body[:rate_limit_time_window] if body.key?(:rate_limit_time_window)
|
|
614
|
+
update[:rateLimitMax] = body[:rate_limit_max] if body.key?(:rate_limit_max)
|
|
615
|
+
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)
|
|
617
|
+
update[:permissions] = api_key_encode_json(body[:permissions]) if body.key?(:permissions)
|
|
618
|
+
update
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
def api_key_generate_key(config, prefix)
|
|
622
|
+
generator = config[:custom_key_generator]
|
|
623
|
+
return generator.call({length: config[:default_key_length], prefix: prefix}) if generator.respond_to?(:call)
|
|
624
|
+
|
|
625
|
+
"#{prefix}#{Array.new(config[:default_key_length].to_i) { ("A".."Z").to_a[SecureRandom.random_number(26)] }.join}"
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
def api_key_hash(key, config)
|
|
629
|
+
config[:disable_key_hashing] ? key.to_s : Crypto.sha256(key.to_s, encoding: :base64url)
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def api_key_expires_at(body, config)
|
|
633
|
+
if body.key?(:expires_in)
|
|
634
|
+
Time.now + body[:expires_in].to_i
|
|
635
|
+
elsif config[:key_expiration][:default_expires_in]
|
|
636
|
+
Time.now + config[:key_expiration][:default_expires_in].to_i
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def api_key_store(ctx, data, config)
|
|
641
|
+
record = nil
|
|
642
|
+
if config[:storage] == "database" || config[:fallback_to_database]
|
|
643
|
+
record = ctx.context.adapter.create(model: API_KEY_TABLE_NAME, data: data)
|
|
644
|
+
end
|
|
645
|
+
record ||= data.transform_keys { |key| Schema.storage_key(key) }.merge("id" => SecureRandom.hex(16))
|
|
646
|
+
api_key_storage_set(ctx, record, config) if config[:storage] == "secondary-storage"
|
|
647
|
+
record
|
|
648
|
+
end
|
|
649
|
+
|
|
650
|
+
def api_key_find_by_hash(ctx, hashed, config)
|
|
651
|
+
if config[:storage] == "secondary-storage"
|
|
652
|
+
record = api_key_storage_get(ctx, "api-key:#{hashed}", config) || api_key_storage_get(ctx, "api-key:key:#{hashed}", config)
|
|
653
|
+
return record if record
|
|
654
|
+
return nil unless config[:fallback_to_database]
|
|
655
|
+
end
|
|
656
|
+
ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "key", value: hashed}])
|
|
657
|
+
end
|
|
658
|
+
|
|
659
|
+
def api_key_find_by_id(ctx, id, config)
|
|
660
|
+
if config[:storage] == "secondary-storage"
|
|
661
|
+
record = api_key_storage_get(ctx, "api-key:by-id:#{id}", config) || api_key_storage_get(ctx, "api-key:id:#{id}", config)
|
|
662
|
+
return record if record
|
|
663
|
+
return nil unless config[:fallback_to_database]
|
|
664
|
+
end
|
|
665
|
+
ctx.context.adapter.find_one(model: API_KEY_TABLE_NAME, where: [{field: "id", value: id}])
|
|
666
|
+
end
|
|
667
|
+
|
|
668
|
+
def api_key_list_for_user(ctx, user_id, config)
|
|
669
|
+
api_key_list_for_reference(ctx, user_id, config)
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
def api_key_list_for_reference(ctx, reference_id, config)
|
|
673
|
+
if config[:storage] == "secondary-storage"
|
|
674
|
+
begin
|
|
675
|
+
storage = api_key_storage(config, ctx.context)
|
|
676
|
+
ids = JSON.parse((storage&.get("api-key:by-ref:#{reference_id}") || storage&.get("api-key:user:#{reference_id}")).to_s)
|
|
677
|
+
records = ids.filter_map { |id| api_key_find_by_id(ctx, id, config) }
|
|
678
|
+
return records unless records.empty? && config[:fallback_to_database]
|
|
679
|
+
rescue JSON::ParserError, NoMethodError
|
|
680
|
+
return [] unless config[:fallback_to_database]
|
|
681
|
+
end
|
|
682
|
+
end
|
|
683
|
+
records = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "referenceId", value: reference_id}])
|
|
684
|
+
legacy = ctx.context.adapter.find_many(model: API_KEY_TABLE_NAME, where: [{field: "userId", value: reference_id}])
|
|
685
|
+
(records + legacy).uniq { |record| record["id"] }
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def api_key_update_record(ctx, record, update, config, defer: false)
|
|
689
|
+
performer = lambda do
|
|
690
|
+
updated = nil
|
|
691
|
+
if config[:storage] == "database" || config[:fallback_to_database]
|
|
692
|
+
updated = ctx.context.adapter.update(model: API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: update)
|
|
693
|
+
end
|
|
694
|
+
updated ||= record.merge(update.transform_keys { |key| Schema.storage_key(key) })
|
|
695
|
+
api_key_storage_set(ctx, updated, config) if config[:storage] == "secondary-storage"
|
|
696
|
+
updated
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
if defer && config[:defer_updates] && api_key_background_tasks?(ctx)
|
|
700
|
+
scheduled = record.merge(update.transform_keys { |key| Schema.storage_key(key) })
|
|
701
|
+
ctx.context.run_in_background(performer)
|
|
702
|
+
scheduled
|
|
703
|
+
else
|
|
704
|
+
performer.call
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def api_key_delete_record(ctx, record, config)
|
|
709
|
+
ctx.context.adapter.delete(model: API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}]) if config[:storage] == "database" || config[:fallback_to_database]
|
|
710
|
+
api_key_storage_delete(ctx, record, config) if config[:storage] == "secondary-storage"
|
|
711
|
+
end
|
|
712
|
+
|
|
713
|
+
def api_key_delete_expired(context, config)
|
|
714
|
+
return unless config[:storage] == "database" || config[:fallback_to_database]
|
|
715
|
+
|
|
716
|
+
expired = context.adapter.find_many(model: API_KEY_TABLE_NAME).select do |record|
|
|
717
|
+
record["expiresAt"] && record["expiresAt"] < Time.now
|
|
718
|
+
end
|
|
719
|
+
expired.each do |record|
|
|
720
|
+
context.adapter.delete(model: API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}])
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
def api_key_storage(config, context = nil)
|
|
725
|
+
config[:custom_storage] || context&.options&.secondary_storage
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
def api_key_storage_get(ctx, key, config)
|
|
729
|
+
raw = api_key_storage(config, ctx.context)&.get(key)
|
|
730
|
+
raw && api_key_deserialize_storage_record(JSON.parse(raw))
|
|
731
|
+
rescue JSON::ParserError
|
|
732
|
+
nil
|
|
733
|
+
end
|
|
734
|
+
|
|
735
|
+
def api_key_storage_set(ctx, record, config)
|
|
736
|
+
storage = api_key_storage(config, ctx.context)
|
|
737
|
+
return unless storage
|
|
738
|
+
|
|
739
|
+
serialized = JSON.generate(api_key_storage_record(record))
|
|
740
|
+
expires_at = api_key_normalize_time(record["expiresAt"])
|
|
741
|
+
ttl = expires_at ? [(expires_at - Time.now).to_i, 0].max : nil
|
|
742
|
+
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
|
+
user_key = "api-key:by-ref:#{reference_id}"
|
|
746
|
+
ids = JSON.parse(storage.get(user_key).to_s)
|
|
747
|
+
ids << record["id"] unless ids.include?(record["id"])
|
|
748
|
+
storage.set(user_key, JSON.generate(ids))
|
|
749
|
+
rescue JSON::ParserError
|
|
750
|
+
storage.set("api-key:by-ref:#{api_key_record_reference_id(record)}", JSON.generate([record["id"]]))
|
|
751
|
+
end
|
|
752
|
+
|
|
753
|
+
def api_key_storage_delete(ctx, record, config)
|
|
754
|
+
storage = api_key_storage(config, ctx.context)
|
|
755
|
+
return unless storage
|
|
756
|
+
|
|
757
|
+
storage.delete("api-key:#{record["key"]}")
|
|
758
|
+
storage.delete("api-key:by-id:#{record["id"]}")
|
|
759
|
+
storage.delete("api-key:key:#{record["key"]}")
|
|
760
|
+
storage.delete("api-key:id:#{record["id"]}")
|
|
761
|
+
user_key = "api-key:by-ref:#{api_key_record_reference_id(record)}"
|
|
762
|
+
ids = JSON.parse(storage.get(user_key).to_s).reject { |id| id == record["id"] }
|
|
763
|
+
ids.empty? ? storage.delete(user_key) : storage.set(user_key, JSON.generate(ids))
|
|
764
|
+
rescue JSON::ParserError
|
|
765
|
+
nil
|
|
766
|
+
end
|
|
767
|
+
|
|
768
|
+
def api_key_storage_record(record)
|
|
769
|
+
record.transform_values { |value| value.is_a?(Time) ? value.iso8601 : value }
|
|
770
|
+
end
|
|
771
|
+
|
|
772
|
+
def api_key_deserialize_storage_record(record)
|
|
773
|
+
%w[createdAt updatedAt expiresAt lastRefillAt lastRequest].each do |field|
|
|
774
|
+
record[field] = api_key_normalize_time(record[field]) if record[field]
|
|
775
|
+
end
|
|
776
|
+
record
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def api_key_public(record, reveal_key: nil, include_key_field: false)
|
|
780
|
+
data = record.transform_keys(&:to_sym)
|
|
781
|
+
output = data.except(:key)
|
|
782
|
+
output[:configId] ||= api_key_record_config_id(record)
|
|
783
|
+
output[:referenceId] ||= api_key_record_reference_id(record)
|
|
784
|
+
output[:key] = reveal_key if include_key_field && reveal_key
|
|
785
|
+
output[:metadata] = api_key_decode_json(data[:metadata])
|
|
786
|
+
output[:permissions] = api_key_decode_json(data[:permissions])
|
|
787
|
+
output
|
|
788
|
+
end
|
|
789
|
+
|
|
790
|
+
def api_key_migrate_legacy_metadata(ctx, record, config)
|
|
791
|
+
parsed = api_key_decode_json(record["metadata"])
|
|
792
|
+
return record unless parsed.is_a?(Hash)
|
|
793
|
+
|
|
794
|
+
encoded = api_key_encode_json(parsed)
|
|
795
|
+
return record.merge("metadata" => encoded) if record["metadata"] == encoded
|
|
796
|
+
|
|
797
|
+
updated = record.merge("metadata" => encoded)
|
|
798
|
+
if config[:storage] == "database" || config[:fallback_to_database]
|
|
799
|
+
ctx.context.adapter.update(model: API_KEY_TABLE_NAME, where: [{field: "id", value: record["id"]}], update: {metadata: encoded})
|
|
800
|
+
end
|
|
801
|
+
api_key_storage_set(ctx, updated, config) if config[:storage] == "secondary-storage"
|
|
802
|
+
updated
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def api_key_background_tasks?(ctx)
|
|
806
|
+
ctx.context.options.advanced.dig(:background_tasks, :handler).respond_to?(:call)
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def api_key_get_from_headers(ctx, config)
|
|
810
|
+
getter = config[:custom_api_key_getter]
|
|
811
|
+
return getter.call(ctx) if getter.respond_to?(:call)
|
|
812
|
+
|
|
813
|
+
Array(config[:api_key_headers]).each do |header|
|
|
814
|
+
value = ctx.headers[header.to_s.downcase]
|
|
815
|
+
return value if value
|
|
816
|
+
end
|
|
817
|
+
nil
|
|
818
|
+
end
|
|
819
|
+
|
|
820
|
+
def api_key_check_permissions!(record, required)
|
|
821
|
+
return if required.nil? || required == {}
|
|
822
|
+
|
|
823
|
+
actual = api_key_decode_json(record["permissions"]) || {}
|
|
824
|
+
required.each do |resource, actions|
|
|
825
|
+
allowed = Array(actual[resource.to_s] || actual[resource.to_sym])
|
|
826
|
+
unless Array(actions).all? { |action| allowed.include?(action) || allowed.include?(action.to_s) }
|
|
827
|
+
raise APIError.new("FORBIDDEN", message: API_KEY_ERROR_CODES["INVALID_API_KEY"])
|
|
828
|
+
end
|
|
829
|
+
end
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
def api_key_encode_json(value)
|
|
833
|
+
return nil if value.nil?
|
|
834
|
+
|
|
835
|
+
JSON.generate(value)
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
def api_key_decode_json(value)
|
|
839
|
+
return nil if value.nil?
|
|
840
|
+
return value if value.is_a?(Hash)
|
|
841
|
+
|
|
842
|
+
parsed = JSON.parse(value.to_s)
|
|
843
|
+
parsed.is_a?(String) ? api_key_decode_json(parsed) : parsed
|
|
844
|
+
rescue JSON::ParserError
|
|
845
|
+
nil
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
def api_key_normalize_time(value)
|
|
849
|
+
return value if value.is_a?(Time)
|
|
850
|
+
return nil if value.nil?
|
|
851
|
+
|
|
852
|
+
Time.parse(value.to_s)
|
|
853
|
+
end
|
|
854
|
+
end
|
|
855
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: better_auth-api-key
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Sebastian Sala
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: better_auth
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '0.1'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '0.1'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: bundler
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.5'
|
|
33
|
+
type: :development
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.5'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: minitest
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '5.25'
|
|
47
|
+
type: :development
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '5.25'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: rake
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '13.2'
|
|
61
|
+
type: :development
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '13.2'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: standardrb
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '1.0'
|
|
75
|
+
type: :development
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '1.0'
|
|
82
|
+
description: Adds API key creation, verification, management, quotas, metadata, permissions,
|
|
83
|
+
storage modes, and API-key-backed sessions for Better Auth Ruby.
|
|
84
|
+
email:
|
|
85
|
+
- sebastian.sala.tech@gmail.com
|
|
86
|
+
executables: []
|
|
87
|
+
extensions: []
|
|
88
|
+
extra_rdoc_files: []
|
|
89
|
+
files:
|
|
90
|
+
- CHANGELOG.md
|
|
91
|
+
- README.md
|
|
92
|
+
- lib/better_auth/api_key.rb
|
|
93
|
+
- lib/better_auth/api_key/version.rb
|
|
94
|
+
- lib/better_auth/plugins/api_key.rb
|
|
95
|
+
homepage: https://github.com/sebasxsala/better-auth
|
|
96
|
+
licenses:
|
|
97
|
+
- MIT
|
|
98
|
+
metadata:
|
|
99
|
+
homepage_uri: https://github.com/sebasxsala/better-auth
|
|
100
|
+
source_code_uri: https://github.com/sebasxsala/better-auth
|
|
101
|
+
changelog_uri: https://github.com/sebasxsala/better-auth/blob/main/packages/better_auth-api-key/CHANGELOG.md
|
|
102
|
+
bug_tracker_uri: https://github.com/sebasxsala/better-auth/issues
|
|
103
|
+
rdoc_options: []
|
|
104
|
+
require_paths:
|
|
105
|
+
- lib
|
|
106
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - ">="
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: 3.2.0
|
|
111
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
112
|
+
requirements:
|
|
113
|
+
- - ">="
|
|
114
|
+
- !ruby/object:Gem::Version
|
|
115
|
+
version: '0'
|
|
116
|
+
requirements: []
|
|
117
|
+
rubygems_version: 3.6.9
|
|
118
|
+
specification_version: 4
|
|
119
|
+
summary: API key plugin package for Better Auth Ruby
|
|
120
|
+
test_files: []
|