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 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
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - Extract API key support into the `better_auth-api-key` package.
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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BetterAuth
4
+ module APIKey
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "better_auth"
4
+ require_relative "api_key/version"
5
+ require_relative "plugins/api_key"
6
+
7
+ module BetterAuth
8
+ module APIKey
9
+ end
10
+ end
@@ -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: []