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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +147 -38
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +92 -10
  6. data/app/controllers/api_keys/keys_controller.rb +40 -18
  7. data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
  8. data/app/views/api_keys/keys/_form.html.erb +3 -3
  9. data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
  10. data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
  11. data/app/views/api_keys/keys/_key_row.html.erb +1 -1
  12. data/app/views/api_keys/keys/_key_status.html.erb +3 -3
  13. data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
  14. data/app/views/api_keys/keys/_show_token.html.erb +5 -46
  15. data/app/views/api_keys/keys/_token_display.html.erb +3 -3
  16. data/app/views/api_keys/keys/index.html.erb +2 -2
  17. data/app/views/api_keys/keys/show.html.erb +2 -2
  18. data/app/views/api_keys/security/best_practices.html.erb +7 -7
  19. data/app/views/layouts/api_keys/application.html.erb +159 -12
  20. data/lib/api_keys/authentication.rb +39 -11
  21. data/lib/api_keys/configuration.rb +412 -24
  22. data/lib/api_keys/engine.rb +5 -20
  23. data/lib/api_keys/form_builder_extensions.rb +12 -2
  24. data/lib/api_keys/helpers/expiration_options.rb +11 -3
  25. data/lib/api_keys/helpers/token_session.rb +143 -8
  26. data/lib/api_keys/helpers/view_helpers.rb +5 -1
  27. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  28. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  29. data/lib/api_keys/models/api_key.rb +244 -25
  30. data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
  31. data/lib/api_keys/services/authenticator.rb +263 -118
  32. data/lib/api_keys/services/digestor.rb +76 -13
  33. data/lib/api_keys/services/token_generator.rb +41 -1
  34. data/lib/api_keys/tenant_resolution.rb +2 -4
  35. data/lib/api_keys/version.rb +1 -1
  36. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  37. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +24 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -14
  39. data/lib/generators/api_keys/templates/initializer.rb +54 -17
  40. metadata +16 -16
  41. data/.simplecov +0 -36
  42. data/AGENTS.md +0 -5
  43. data/Appraisals +0 -17
  44. data/CLAUDE.md +0 -5
  45. data/Rakefile +0 -37
  46. data/context7.json +0 -4
  47. data/gemfiles/rails_7.2.gemfile +0 -21
  48. data/gemfiles/rails_8.0.gemfile +0 -21
  49. 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
- attr_accessor :header, :query_param
18
+ attr_reader :header, :query_param
17
19
 
18
20
  # Token Generation
19
- attr_accessor :token_prefix, :token_length, :token_alphabet
21
+ attr_reader :token_prefix, :token_length, :token_alphabet
20
22
 
21
23
  # Storage & Verification
22
- attr_accessor :hash_strategy, :secure_compare_proc, :key_store_adapter, :policy_provider
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
- attr_accessor :parent_controller
29
+ attr_reader :parent_controller
26
30
 
27
31
  # Owner Context Configuration
28
- attr_accessor :current_owner_method, :authenticate_owner_method
32
+ attr_reader :current_owner_method, :authenticate_owner_method
29
33
 
30
34
  # Optional Behaviors
31
- attr_accessor :default_max_keys_per_owner, :require_key_name
32
- attr_accessor :expire_after, :default_scopes, :track_requests_count
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
- attr_accessor :cache_ttl
39
+ attr_reader :cache_ttl, :stats_update_interval
36
40
 
37
41
  # Security
38
- attr_accessor :https_only_production, :https_strict_mode
42
+ attr_reader :https_only_production, :https_strict_mode
39
43
 
40
44
  # Tenant Resolution
41
- attr_accessor :tenant_resolver
45
+ attr_reader :tenant_resolver
42
46
 
43
47
  # Callbacks (Placeholders for future extension)
44
- attr_accessor :before_authentication, :after_authentication
48
+ attr_reader :before_authentication, :after_authentication
45
49
 
46
50
  # Background Job Queues
47
- attr_accessor :stats_job_queue, :callbacks_job_queue
51
+ attr_reader :stats_job_queue, :callbacks_job_queue
48
52
 
49
53
  # Global Async Toggle
50
- attr_accessor :enable_async_operations
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
- attr_accessor :debug_logging
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
- attr_accessor :environments, :current_environment, :strict_environment_isolation,
100
- :default_key_type, :dashboard_allow_cross_environment
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
- validate_key_type_prefixes!(value) if value.is_a?(Hash) && value.any?
110
- @key_types = value
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 = '::ApplicationController'
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 = false # Don't raise error, just warn
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)
@@ -8,20 +8,11 @@ module ApiKeys
8
8
  class Engine < ::Rails::Engine
9
9
  isolate_namespace ApiKeys
10
10
 
11
- # Allows configuring the parent controller for the engine's controllers
12
- # Defaults to ::ApplicationController, assuming a standard Rails app structure.
13
- config.parent_controller = '::ApplicationController'
14
-
15
- # Ensure our models load first
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] The key type (:publishable, :secret, or nil)
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&.to_sym,
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") # => nil
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
- nil
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