api_keys 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +147 -38
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +92 -10
- data/app/controllers/api_keys/keys_controller.rb +40 -18
- data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
- data/app/views/api_keys/keys/_form.html.erb +3 -3
- data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
- data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
- data/app/views/api_keys/keys/_key_row.html.erb +1 -1
- data/app/views/api_keys/keys/_key_status.html.erb +3 -3
- data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
- data/app/views/api_keys/keys/_show_token.html.erb +5 -46
- data/app/views/api_keys/keys/_token_display.html.erb +3 -3
- data/app/views/api_keys/keys/index.html.erb +2 -2
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +7 -7
- data/app/views/layouts/api_keys/application.html.erb +159 -12
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +412 -24
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/form_builder_extensions.rb +12 -2
- data/lib/api_keys/helpers/expiration_options.rb +11 -3
- data/lib/api_keys/helpers/token_session.rb +143 -8
- data/lib/api_keys/helpers/view_helpers.rb +5 -1
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +244 -25
- data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
- data/lib/api_keys/services/authenticator.rb +263 -118
- data/lib/api_keys/services/digestor.rb +76 -13
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +2 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +24 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -14
- data/lib/generators/api_keys/templates/initializer.rb +54 -17
- metadata +16 -16
- data/.simplecov +0 -36
- data/AGENTS.md +0 -5
- data/Appraisals +0 -17
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -37
- data/context7.json +0 -4
- data/gemfiles/rails_7.2.gemfile +0 -21
- data/gemfiles/rails_8.0.gemfile +0 -21
- data/gemfiles/rails_8.1.gemfile +0 -21
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "active_support/core_ext/numeric/time"
|
|
4
|
+
require "active_support/core_ext/string/inflections"
|
|
4
5
|
require "active_support/security_utils"
|
|
5
6
|
|
|
6
7
|
module ApiKeys
|
|
@@ -9,51 +10,68 @@ module ApiKeys
|
|
|
9
10
|
class Configuration
|
|
10
11
|
# Default empty callback proc
|
|
11
12
|
DEFAULT_CALLBACK = ->(_context){}.freeze
|
|
13
|
+
DEFAULT_PARENT_CONTROLLER = "::ApplicationController"
|
|
12
14
|
|
|
13
15
|
# == Accessors ==
|
|
14
16
|
|
|
15
17
|
# Core Authentication
|
|
16
|
-
|
|
18
|
+
attr_reader :header, :query_param
|
|
17
19
|
|
|
18
20
|
# Token Generation
|
|
19
|
-
|
|
21
|
+
attr_reader :token_prefix, :token_length, :token_alphabet
|
|
20
22
|
|
|
21
23
|
# Storage & Verification
|
|
22
|
-
|
|
24
|
+
attr_reader :hash_strategy
|
|
25
|
+
attr_reader :secure_compare_proc
|
|
26
|
+
attr_accessor :key_store_adapter, :policy_provider
|
|
23
27
|
|
|
24
28
|
# Engine Configuration
|
|
25
|
-
|
|
29
|
+
attr_reader :parent_controller
|
|
26
30
|
|
|
27
31
|
# Owner Context Configuration
|
|
28
|
-
|
|
32
|
+
attr_reader :current_owner_method, :authenticate_owner_method
|
|
29
33
|
|
|
30
34
|
# Optional Behaviors
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
attr_reader :default_max_keys_per_owner, :require_key_name
|
|
36
|
+
attr_reader :expire_after, :default_scopes, :track_requests_count
|
|
33
37
|
|
|
34
38
|
# Performance
|
|
35
|
-
|
|
39
|
+
attr_reader :cache_ttl, :stats_update_interval
|
|
36
40
|
|
|
37
41
|
# Security
|
|
38
|
-
|
|
42
|
+
attr_reader :https_only_production, :https_strict_mode
|
|
39
43
|
|
|
40
44
|
# Tenant Resolution
|
|
41
|
-
|
|
45
|
+
attr_reader :tenant_resolver
|
|
42
46
|
|
|
43
47
|
# Callbacks (Placeholders for future extension)
|
|
44
|
-
|
|
48
|
+
attr_reader :before_authentication, :after_authentication
|
|
45
49
|
|
|
46
50
|
# Background Job Queues
|
|
47
|
-
|
|
51
|
+
attr_reader :stats_job_queue, :callbacks_job_queue
|
|
48
52
|
|
|
49
53
|
# Global Async Toggle
|
|
50
|
-
|
|
54
|
+
attr_reader :enable_async_operations
|
|
51
55
|
|
|
52
56
|
# Engine UI Configuration
|
|
53
57
|
attr_accessor :return_url, :return_text
|
|
54
58
|
|
|
59
|
+
# Dashboard Content Security Policy
|
|
60
|
+
#
|
|
61
|
+
# @!attribute [rw] dashboard_content_security_policy
|
|
62
|
+
# @return [Symbol, false, nil] Which Content-Security-Policy the mounted
|
|
63
|
+
# dashboard declares for its own pages.
|
|
64
|
+
# - `:default` (default) — hardened but host-compatible: same-origin
|
|
65
|
+
# scripts, styles, and fonts are allowed alongside the engine's
|
|
66
|
+
# per-request nonce, so a host layout's asset tags keep working.
|
|
67
|
+
# - `:strict` — nonce-only: `default-src 'none'` with no source
|
|
68
|
+
# expression for scripts or styles. Requires a host layout that
|
|
69
|
+
# serves no un-nonced assets on engine pages.
|
|
70
|
+
# - `false` / `nil` — declare nothing and leave the host policy alone.
|
|
71
|
+
attr_reader :dashboard_content_security_policy
|
|
72
|
+
|
|
55
73
|
# Debugging
|
|
56
|
-
|
|
74
|
+
attr_reader :debug_logging
|
|
57
75
|
|
|
58
76
|
# Key Types & Environments (Stripe-style publishable/secret keys)
|
|
59
77
|
#
|
|
@@ -96,25 +114,328 @@ module ApiKeys
|
|
|
96
114
|
# If false (default), dashboard only shows keys matching current_environment.
|
|
97
115
|
# @example
|
|
98
116
|
# config.dashboard_allow_cross_environment = false
|
|
99
|
-
|
|
100
|
-
|
|
117
|
+
attr_reader :environments, :current_environment, :strict_environment_isolation,
|
|
118
|
+
:default_key_type, :dashboard_allow_cross_environment
|
|
101
119
|
|
|
102
120
|
# Custom writer for key_types that validates prefix uniqueness
|
|
103
121
|
attr_reader :key_types
|
|
104
122
|
|
|
123
|
+
VALID_HASH_STRATEGIES = %i[sha256 bcrypt].freeze
|
|
124
|
+
VALID_TOKEN_ALPHABETS = %i[base58 hex].freeze
|
|
125
|
+
VALID_DASHBOARD_CONTENT_SECURITY_POLICIES = [:default, :strict, false, nil].freeze
|
|
126
|
+
TOKEN_LENGTH_RANGE = (16..64)
|
|
127
|
+
MAX_CONFIGURED_SCOPES = 100
|
|
128
|
+
CONFIG_NAME_PATTERN = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
129
|
+
HTTP_HEADER_PATTERN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}\z/
|
|
130
|
+
QUERY_PARAM_PATTERN = /\A[a-zA-Z0-9_.~-]{1,128}\z/
|
|
131
|
+
METHOD_NAME_PATTERN = /\A[a-zA-Z_]\w*[!?]?\z/
|
|
132
|
+
CONSTANT_NAME_PATTERN = /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/
|
|
133
|
+
BOOLEAN_SETTINGS = %i[
|
|
134
|
+
require_key_name track_requests_count https_only_production https_strict_mode
|
|
135
|
+
enable_async_operations debug_logging strict_environment_isolation
|
|
136
|
+
dashboard_allow_cross_environment
|
|
137
|
+
].freeze
|
|
138
|
+
|
|
139
|
+
def header=(value)
|
|
140
|
+
unless value.nil? || (value.is_a?(String) && value.match?(HTTP_HEADER_PATTERN))
|
|
141
|
+
raise ArgumentError, "header must be nil or a valid HTTP header name of at most 128 characters"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
@header = value&.dup&.freeze
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def query_param=(value)
|
|
148
|
+
unless value.nil? || (value.is_a?(String) && value.match?(QUERY_PARAM_PATTERN))
|
|
149
|
+
raise ArgumentError, "query_param must be nil or a safe parameter name of at most 128 characters"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
@query_param = value&.dup&.freeze
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def default_max_keys_per_owner=(value)
|
|
156
|
+
unless value.nil? || (value.is_a?(Integer) && value >= 0)
|
|
157
|
+
raise ArgumentError, "default_max_keys_per_owner must be a non-negative Integer or nil"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@default_max_keys_per_owner = value
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def expire_after=(value)
|
|
164
|
+
unless value.nil?
|
|
165
|
+
seconds = value.to_f if value.respond_to?(:to_f)
|
|
166
|
+
valid = value.respond_to?(:from_now) && seconds&.finite? && seconds.positive?
|
|
167
|
+
raise ArgumentError, "expire_after must be a positive duration or nil" unless valid
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
@expire_after = value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def default_scopes=(value)
|
|
174
|
+
unless value.is_a?(Array) && value.length <= MAX_CONFIGURED_SCOPES && value.all? { |scope| valid_scope_name?(scope) }
|
|
175
|
+
raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
@default_scopes = deep_copy_and_freeze(value.uniq)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def cache_ttl=(value)
|
|
182
|
+
unless value.nil?
|
|
183
|
+
seconds = value.to_f if value.respond_to?(:to_f)
|
|
184
|
+
valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
|
|
185
|
+
raise ArgumentError, "cache_ttl must be a finite non-negative duration/number or nil" unless valid
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
@cache_ttl = value
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def stats_update_interval=(value)
|
|
192
|
+
unless value.nil?
|
|
193
|
+
seconds = value.to_f if value.respond_to?(:to_f)
|
|
194
|
+
valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
|
|
195
|
+
raise ArgumentError, "stats_update_interval must be a finite non-negative duration/number or nil" unless valid
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
@stats_update_interval = value
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parent_controller=(value)
|
|
202
|
+
valid = value.is_a?(Class) || (value.is_a?(String) && value.match?(CONSTANT_NAME_PATTERN))
|
|
203
|
+
raise ArgumentError, "parent_controller must be a controller Class or a valid constant name" unless valid
|
|
204
|
+
|
|
205
|
+
@parent_controller = value.is_a?(String) ? value.dup.freeze : value
|
|
206
|
+
@parent_controller_explicitly_configured = true
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def parent_controller_class
|
|
210
|
+
candidate = if !@parent_controller_explicitly_configured && defined?(ApiKeys::Engine) &&
|
|
211
|
+
ApiKeys::Engine.config.parent_controller.present?
|
|
212
|
+
ApiKeys::Engine.config.parent_controller
|
|
213
|
+
else
|
|
214
|
+
parent_controller
|
|
215
|
+
end
|
|
216
|
+
candidate.is_a?(Class) ? candidate : candidate.constantize
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
BOOLEAN_SETTINGS.each do |setting|
|
|
220
|
+
define_method("#{setting}=") do |value|
|
|
221
|
+
raise ArgumentError, "#{setting} must be true or false" unless value == true || value == false
|
|
222
|
+
|
|
223
|
+
instance_variable_set("@#{setting}", value)
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def before_authentication=(value)
|
|
228
|
+
validate_callback!(value, :before_authentication)
|
|
229
|
+
@before_authentication = value
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def after_authentication=(value)
|
|
233
|
+
validate_callback!(value, :after_authentication)
|
|
234
|
+
@after_authentication = value
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def tenant_resolver=(value)
|
|
238
|
+
raise ArgumentError, "tenant_resolver must be callable" unless value.respond_to?(:call)
|
|
239
|
+
|
|
240
|
+
@tenant_resolver = value
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def secure_compare_proc=(value)
|
|
244
|
+
raise ArgumentError, "secure_compare_proc must be callable" unless value.respond_to?(:call)
|
|
245
|
+
|
|
246
|
+
@secure_compare_proc = value
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def stats_job_queue=(value)
|
|
250
|
+
@stats_job_queue = validate_queue_name!(value, :stats_job_queue)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def callbacks_job_queue=(value)
|
|
254
|
+
@callbacks_job_queue = validate_queue_name!(value, :callbacks_job_queue)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
def current_owner_method=(value)
|
|
258
|
+
@current_owner_method = validate_method_name!(value, :current_owner_method)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def authenticate_owner_method=(value)
|
|
262
|
+
@authenticate_owner_method = validate_method_name!(value, :authenticate_owner_method)
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def current_environment=(value)
|
|
266
|
+
unless value.nil? || value.respond_to?(:call) || valid_config_name?(value)
|
|
267
|
+
raise ArgumentError, "current_environment must be callable, a safe String/Symbol, or nil"
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
@current_environment = value
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def default_key_type=(value)
|
|
274
|
+
unless value.nil? || valid_config_name?(value)
|
|
275
|
+
raise ArgumentError, "default_key_type must be a safe String/Symbol or nil"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
@default_key_type = value
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def token_prefix=(value)
|
|
282
|
+
unless value.is_a?(String) || value.respond_to?(:call)
|
|
283
|
+
raise ArgumentError, "token_prefix must be a String or callable object"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
validate_resolved_prefix!(value) if value.is_a?(String)
|
|
287
|
+
@token_prefix = value
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def resolved_token_prefix
|
|
291
|
+
value = @token_prefix.respond_to?(:call) ? @token_prefix.call : @token_prefix
|
|
292
|
+
validate_resolved_prefix!(value)
|
|
293
|
+
value
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def token_length=(value)
|
|
297
|
+
unless value.is_a?(Integer) && TOKEN_LENGTH_RANGE.cover?(value)
|
|
298
|
+
raise ArgumentError, "token_length must be an Integer between #{TOKEN_LENGTH_RANGE.begin} and #{TOKEN_LENGTH_RANGE.end}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
@token_length = value
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
def token_alphabet=(value)
|
|
305
|
+
unless VALID_TOKEN_ALPHABETS.include?(value)
|
|
306
|
+
raise ArgumentError, "token_alphabet must be one of: #{VALID_TOKEN_ALPHABETS.join(', ')}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
@token_alphabet = value
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# Selects the Content-Security-Policy the dashboard declares for its own pages.
|
|
313
|
+
# `true` is accepted as an alias for `:default`; Strings are normalized to Symbols.
|
|
314
|
+
def dashboard_content_security_policy=(value)
|
|
315
|
+
normalized = case value
|
|
316
|
+
when true then :default
|
|
317
|
+
when String then value.to_sym
|
|
318
|
+
else value
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
unless VALID_DASHBOARD_CONTENT_SECURITY_POLICIES.include?(normalized)
|
|
322
|
+
raise ArgumentError,
|
|
323
|
+
"dashboard_content_security_policy must be :default, :strict, false, or nil"
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
@dashboard_content_security_policy = normalized
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def hash_strategy=(value)
|
|
330
|
+
unless VALID_HASH_STRATEGIES.include?(value)
|
|
331
|
+
raise ArgumentError, "hash_strategy must be one of: #{VALID_HASH_STRATEGIES.join(', ')}"
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
@hash_strategy = value
|
|
335
|
+
end
|
|
336
|
+
|
|
105
337
|
# Sets the key types configuration with prefix collision validation.
|
|
106
338
|
# @param value [Hash] Key type definitions
|
|
107
339
|
# @raise [ArgumentError] If multiple key types share the same prefix
|
|
108
340
|
def key_types=(value)
|
|
109
|
-
|
|
110
|
-
|
|
341
|
+
raise ArgumentError, "key_types must be a Hash" unless value.is_a?(Hash)
|
|
342
|
+
|
|
343
|
+
validate_key_types!(value)
|
|
344
|
+
validate_composite_prefixes!(value, @environments || {})
|
|
345
|
+
@key_types = deep_copy_and_freeze(value)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def environments=(value)
|
|
349
|
+
raise ArgumentError, "environments must be a Hash" unless value.is_a?(Hash)
|
|
350
|
+
|
|
351
|
+
value.each do |name, environment_config|
|
|
352
|
+
validate_config_name!(name, "environment")
|
|
353
|
+
raise ArgumentError, "Environment '#{name}' configuration must be a Hash" unless environment_config.is_a?(Hash)
|
|
354
|
+
|
|
355
|
+
segment = environment_config[:prefix_segment]
|
|
356
|
+
validate_config_name!(segment, "environment prefix segment") unless segment.nil?
|
|
357
|
+
end
|
|
358
|
+
validate_duplicate_config_names!(value, "environment")
|
|
359
|
+
validate_composite_prefixes!(@key_types || {}, value)
|
|
360
|
+
@environments = deep_copy_and_freeze(value)
|
|
111
361
|
end
|
|
112
362
|
|
|
113
363
|
private
|
|
114
364
|
|
|
365
|
+
def validate_resolved_prefix!(prefix)
|
|
366
|
+
valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
|
|
367
|
+
prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
368
|
+
return if valid
|
|
369
|
+
|
|
370
|
+
raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
|
|
371
|
+
rescue ArgumentError
|
|
372
|
+
raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def validate_key_types!(key_types_hash)
|
|
376
|
+
validate_duplicate_config_names!(key_types_hash, "key type")
|
|
377
|
+
|
|
378
|
+
key_types_hash.each do |name, type_config|
|
|
379
|
+
validate_config_name!(name, "key type")
|
|
380
|
+
raise ArgumentError, "Key type '#{name}' configuration must be a Hash" unless type_config.is_a?(Hash)
|
|
381
|
+
|
|
382
|
+
validate_config_name!(type_config[:prefix], "key type prefix")
|
|
383
|
+
permissions = type_config[:permissions]
|
|
384
|
+
unless permissions == :all || permissions.is_a?(Array)
|
|
385
|
+
raise ArgumentError, "Key type '#{name}' permissions must be :all or an Array of strings"
|
|
386
|
+
end
|
|
387
|
+
if permissions.is_a?(Array) && permissions.length > MAX_CONFIGURED_SCOPES
|
|
388
|
+
raise ArgumentError, "Key type '#{name}' permissions cannot contain more than #{MAX_CONFIGURED_SCOPES} entries"
|
|
389
|
+
end
|
|
390
|
+
if permissions.is_a?(Array) && permissions.any? { |permission| !valid_scope_name?(permission) }
|
|
391
|
+
raise ArgumentError, "Key type '#{name}' permissions must contain only non-blank strings of at most 128 bytes"
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
%i[revocable public].each do |setting|
|
|
395
|
+
next unless type_config.key?(setting)
|
|
396
|
+
next if [true, false].include?(type_config[setting])
|
|
397
|
+
|
|
398
|
+
raise ArgumentError, "Key type '#{name}' #{setting} must be true or false"
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
limit = type_config[:limit]
|
|
402
|
+
if !limit.nil? && (!limit.is_a?(Integer) || limit <= 0)
|
|
403
|
+
raise ArgumentError, "Key type '#{name}' limit must be a positive Integer or nil"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
next unless type_config[:public] == true
|
|
407
|
+
|
|
408
|
+
unless type_config[:revocable] == false
|
|
409
|
+
raise ArgumentError, "Public key type '#{name}' must explicitly set revocable: false"
|
|
410
|
+
end
|
|
411
|
+
unless permissions.is_a?(Array) && permissions.any?
|
|
412
|
+
raise ArgumentError, "Public key type '#{name}' must have a finite, non-empty permissions list"
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
validate_key_type_prefixes!(key_types_hash)
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def validate_config_name!(value, label)
|
|
420
|
+
return if valid_config_name?(value)
|
|
421
|
+
|
|
422
|
+
raise ArgumentError, "#{label} must contain only letters, numbers, underscores, or hyphens (1-64 characters)"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def valid_config_name?(value)
|
|
426
|
+
(value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(CONFIG_NAME_PATTERN)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def valid_scope_name?(value)
|
|
430
|
+
value.is_a?(String) && value.present? && value.valid_encoding? && value.bytesize <= 128 &&
|
|
431
|
+
value.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
432
|
+
rescue ArgumentError
|
|
433
|
+
false
|
|
434
|
+
end
|
|
435
|
+
|
|
115
436
|
# Validates that all key type prefixes are unique to prevent token collision
|
|
116
437
|
def validate_key_type_prefixes!(key_types_hash)
|
|
117
|
-
prefixes = key_types_hash.map { |_type, config| config[:prefix] }.compact
|
|
438
|
+
prefixes = key_types_hash.map { |_type, config| config[:prefix].to_s }.compact
|
|
118
439
|
duplicates = prefixes.group_by(&:itself).select { |_k, v| v.size > 1 }.keys
|
|
119
440
|
|
|
120
441
|
if duplicates.any?
|
|
@@ -122,6 +443,65 @@ module ApiKeys
|
|
|
122
443
|
end
|
|
123
444
|
end
|
|
124
445
|
|
|
446
|
+
def validate_duplicate_config_names!(configuration, label)
|
|
447
|
+
duplicates = configuration.keys.map(&:to_s).group_by(&:itself).select { |_name, names| names.length > 1 }.keys
|
|
448
|
+
return if duplicates.empty?
|
|
449
|
+
|
|
450
|
+
raise ArgumentError, "#{label} names must be unique after string normalization: #{duplicates.join(', ')}"
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def validate_composite_prefixes!(key_types, environments)
|
|
454
|
+
return if key_types.empty?
|
|
455
|
+
|
|
456
|
+
environment_entries = environments.empty? ? [[nil, {}]] : environments.to_a
|
|
457
|
+
combinations = key_types.flat_map do |type_name, type_config|
|
|
458
|
+
environment_entries.map do |environment_name, environment_config|
|
|
459
|
+
segment = environment_config[:prefix_segment]
|
|
460
|
+
prefix = segment.nil? ? "#{type_config[:prefix]}_" : "#{type_config[:prefix]}_#{segment}_"
|
|
461
|
+
[prefix, "#{type_name}/#{environment_name || 'default'}"]
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
collisions = combinations.group_by(&:first).select { |_prefix, entries| entries.length > 1 }
|
|
465
|
+
return if collisions.empty?
|
|
466
|
+
|
|
467
|
+
details = collisions.map { |prefix, entries| "#{prefix} (#{entries.map(&:last).join(', ')})" }.join("; ")
|
|
468
|
+
raise ArgumentError, "Key type/environment prefixes must be unique: #{details}"
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
def validate_callback!(value, setting)
|
|
472
|
+
raise ArgumentError, "#{setting} must be a Proc" unless value.is_a?(Proc)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
def validate_queue_name!(value, setting)
|
|
476
|
+
unless (value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(/\A[a-zA-Z0-9_-]{1,128}\z/)
|
|
477
|
+
raise ArgumentError, "#{setting} must be a safe String or Symbol"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
value
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
def validate_method_name!(value, setting)
|
|
484
|
+
unless value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(METHOD_NAME_PATTERN))
|
|
485
|
+
raise ArgumentError, "#{setting} must be a valid method name or nil"
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
value
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
def deep_copy_and_freeze(value)
|
|
492
|
+
copied = case value
|
|
493
|
+
when Hash
|
|
494
|
+
value.to_h { |key, nested_value| [deep_copy_and_freeze(key), deep_copy_and_freeze(nested_value)] }
|
|
495
|
+
when Array
|
|
496
|
+
value.map { |nested_value| deep_copy_and_freeze(nested_value) }
|
|
497
|
+
when String
|
|
498
|
+
value.dup
|
|
499
|
+
else
|
|
500
|
+
value
|
|
501
|
+
end
|
|
502
|
+
copied.freeze
|
|
503
|
+
end
|
|
504
|
+
|
|
125
505
|
public
|
|
126
506
|
|
|
127
507
|
# == Initialization ==
|
|
@@ -154,7 +534,8 @@ module ApiKeys
|
|
|
154
534
|
@policy_provider = "ApiKeys::BasePolicy" # Default authorization policy class name
|
|
155
535
|
|
|
156
536
|
# Engine Configuration
|
|
157
|
-
@parent_controller =
|
|
537
|
+
@parent_controller = DEFAULT_PARENT_CONTROLLER
|
|
538
|
+
@parent_controller_explicitly_configured = false
|
|
158
539
|
|
|
159
540
|
# Owner Context Configuration
|
|
160
541
|
@current_owner_method = :current_user # Default to current_user for backward compatibility
|
|
@@ -164,14 +545,15 @@ module ApiKeys
|
|
|
164
545
|
@default_max_keys_per_owner = nil # No global key limit per owner
|
|
165
546
|
@require_key_name = false # Don't require names for keys globally
|
|
166
547
|
@expire_after = nil # Keys do not expire by default (e.g., 90.days)
|
|
167
|
-
@default_scopes = [] # No default scopes assigned globally
|
|
548
|
+
@default_scopes = [].freeze # No default scopes assigned globally
|
|
168
549
|
|
|
169
550
|
# Performance
|
|
170
551
|
@cache_ttl = 5.seconds # Good balance: fast revocation, mostly-cached – still allows most repeated requests to benefit from cache
|
|
552
|
+
@stats_update_interval = 1.minute # Debounce last_used_at writes unless exact request counting is enabled
|
|
171
553
|
|
|
172
554
|
# Security
|
|
173
555
|
@https_only_production = true # Warn if used over HTTP in production
|
|
174
|
-
@https_strict_mode =
|
|
556
|
+
@https_strict_mode = true # Fail closed if a production request is not HTTPS
|
|
175
557
|
|
|
176
558
|
# Background Job Queues
|
|
177
559
|
@stats_job_queue = :default
|
|
@@ -191,6 +573,12 @@ module ApiKeys
|
|
|
191
573
|
@return_url = "/" # Default fallback path
|
|
192
574
|
@return_text = "‹ Home" # Default link text
|
|
193
575
|
|
|
576
|
+
# Dashboard Content Security Policy
|
|
577
|
+
# Hardened, but compatible with a normal host layout: same-origin scripts,
|
|
578
|
+
# styles, and fonts load, and the engine's inline blocks stay nonce-gated.
|
|
579
|
+
# Use :strict for the nonce-only policy, or false/nil to declare nothing.
|
|
580
|
+
@dashboard_content_security_policy = :default
|
|
581
|
+
|
|
194
582
|
# Debugging
|
|
195
583
|
@debug_logging = false # Disable debug logging by default (warn and error get logged regardless of this)
|
|
196
584
|
|
|
@@ -198,8 +586,8 @@ module ApiKeys
|
|
|
198
586
|
@tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
|
|
199
587
|
|
|
200
588
|
# Key Types & Environments (default to empty/disabled for backwards compatibility)
|
|
201
|
-
@key_types = {} # Empty = feature disabled, legacy behavior
|
|
202
|
-
@environments = {} # Empty = no environment-based prefixes
|
|
589
|
+
@key_types = {}.freeze # Empty = feature disabled, legacy behavior
|
|
590
|
+
@environments = {}.freeze # Empty = no environment-based prefixes
|
|
203
591
|
@current_environment = -> { :default } # Default environment detection
|
|
204
592
|
@strict_environment_isolation = false # Don't enforce environment isolation by default
|
|
205
593
|
@default_key_type = nil # No default key type (must be specified explicitly)
|
data/lib/api_keys/engine.rb
CHANGED
|
@@ -8,20 +8,11 @@ module ApiKeys
|
|
|
8
8
|
class Engine < ::Rails::Engine
|
|
9
9
|
isolate_namespace ApiKeys
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
config.autoload_paths << File.expand_path("../models", __dir__)
|
|
17
|
-
config.autoload_paths << File.expand_path("../models/concerns", __dir__)
|
|
18
|
-
|
|
19
|
-
# Set up autoloading paths
|
|
20
|
-
initializer "api_keys.autoload", before: :set_autoload_paths do |app|
|
|
21
|
-
app.config.autoload_paths << root.join("lib")
|
|
22
|
-
app.config.autoload_paths << root.join("lib/api_keys/models")
|
|
23
|
-
app.config.autoload_paths << root.join("lib/api_keys/models/concerns")
|
|
24
|
-
end
|
|
11
|
+
# Backward-compatible fallback for applications that used the old internal
|
|
12
|
+
# Engine config directly. New applications should configure
|
|
13
|
+
# ApiKeys.configuration.parent_controller; an explicit public configuration
|
|
14
|
+
# value takes precedence over this fallback.
|
|
15
|
+
config.parent_controller = nil
|
|
25
16
|
|
|
26
17
|
# Add has_api_keys method to ActiveRecord::Base
|
|
27
18
|
initializer "api_keys.active_record" do
|
|
@@ -38,12 +29,6 @@ module ApiKeys
|
|
|
38
29
|
initializer "api_keys.model_attributes" do
|
|
39
30
|
ActiveSupport.on_load(:active_record) do
|
|
40
31
|
ApiKeys::ApiKey.class_eval do
|
|
41
|
-
# Define JSON attributes for ApiKey model
|
|
42
|
-
# Ensure the ApiKey model class is loaded before reopening
|
|
43
|
-
# Use require_dependency for development/test, rely on autoloading in production
|
|
44
|
-
# Or simply let Zeitwerk handle loading if structure is correct.
|
|
45
|
-
require_dependency "api_keys/models/api_key" if defined?(Rails) && !Rails.env.production?
|
|
46
|
-
|
|
47
32
|
# == JSON Attribute Casting ==
|
|
48
33
|
# Make the gem work in any database (postgres, sqlite3, mysql...)
|
|
49
34
|
# Configure the right json-like attributes for the different databases
|
|
@@ -110,7 +110,8 @@ module ApiKeys
|
|
|
110
110
|
# - :masked [String] The masked token (e.g., "sk_live_••••abc")
|
|
111
111
|
# - :full [String, nil] The full token (only for viewable public keys)
|
|
112
112
|
# - :viewable [Boolean] Whether the full token can be displayed
|
|
113
|
-
# - :type [Symbol]
|
|
113
|
+
# - :type [Symbol, String, nil] A known built-in type is a Symbol; custom
|
|
114
|
+
# untrusted values remain Strings so they are never interned as Symbols.
|
|
114
115
|
# - :environment [String, nil] The environment (e.g., "live", "test")
|
|
115
116
|
#
|
|
116
117
|
# @example
|
|
@@ -127,13 +128,22 @@ module ApiKeys
|
|
|
127
128
|
masked: object.masked_token,
|
|
128
129
|
full: object.viewable_token,
|
|
129
130
|
viewable: object.respond_to?(:public_key_type?) && object.public_key_type?,
|
|
130
|
-
type: object.key_type
|
|
131
|
+
type: safe_key_type(object.key_type),
|
|
131
132
|
environment: object.environment
|
|
132
133
|
}
|
|
133
134
|
end
|
|
134
135
|
|
|
135
136
|
private
|
|
136
137
|
|
|
138
|
+
def safe_key_type(value)
|
|
139
|
+
case value.to_s
|
|
140
|
+
when "publishable" then :publishable
|
|
141
|
+
when "secret" then :secret
|
|
142
|
+
when "" then nil
|
|
143
|
+
else value.to_s
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
137
147
|
def resolve_checked_scopes(scopes, checked)
|
|
138
148
|
case checked
|
|
139
149
|
when :all
|
|
@@ -24,7 +24,7 @@ module ApiKeys
|
|
|
24
24
|
{ label: "60 days", value: "60_days", days: 60 },
|
|
25
25
|
{ label: "90 days", value: "90_days", days: 90 },
|
|
26
26
|
{ label: "1 year", value: "365_days", days: 365 }
|
|
27
|
-
].freeze
|
|
27
|
+
].map(&:freeze).freeze
|
|
28
28
|
|
|
29
29
|
class << self
|
|
30
30
|
# Returns options suitable for a Rails select helper.
|
|
@@ -65,9 +65,13 @@ module ApiKeys
|
|
|
65
65
|
# ExpirationOptions.parse("30_days") # => 30.days.from_now
|
|
66
66
|
# ExpirationOptions.parse("no_expiration") # => nil
|
|
67
67
|
# ExpirationOptions.parse(nil) # => nil
|
|
68
|
-
# ExpirationOptions.parse("invalid") # =>
|
|
68
|
+
# ExpirationOptions.parse("invalid") # => raises ArgumentError
|
|
69
69
|
#
|
|
70
70
|
def parse(preset)
|
|
71
|
+
return nil if preset.nil? || preset == ""
|
|
72
|
+
unless preset.is_a?(String) && preset.valid_encoding? && preset.bytesize <= 32
|
|
73
|
+
raise ArgumentError, "Invalid API key expiration preset"
|
|
74
|
+
end
|
|
71
75
|
return nil if preset.blank? || preset == "no_expiration"
|
|
72
76
|
|
|
73
77
|
# Try to extract days from the preset string (e.g., "30_days" => 30)
|
|
@@ -81,7 +85,7 @@ module ApiKeys
|
|
|
81
85
|
known = DEFAULT_PRESETS.find { |p| p[:value] == preset }
|
|
82
86
|
return known[:days].days.from_now if known && known[:days]
|
|
83
87
|
|
|
84
|
-
|
|
88
|
+
raise ArgumentError, "Invalid API key expiration preset"
|
|
85
89
|
end
|
|
86
90
|
|
|
87
91
|
# Returns the default preset value (useful for form defaults)
|
|
@@ -102,6 +106,10 @@ module ApiKeys
|
|
|
102
106
|
end
|
|
103
107
|
|
|
104
108
|
def build_custom_presets(days_list, include_no_expiration)
|
|
109
|
+
unless days_list.is_a?(Array) && days_list.all? { |days| days.is_a?(Integer) && days.between?(1, 3650) }
|
|
110
|
+
raise ArgumentError, "Expiration presets must be an array of integers between 1 and 3650"
|
|
111
|
+
end
|
|
112
|
+
|
|
105
113
|
options = []
|
|
106
114
|
|
|
107
115
|
if include_no_expiration
|