api_keys 0.3.0 → 0.4.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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +101 -39
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -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 +374 -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 +32 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
  39. data/lib/generators/api_keys/templates/initializer.rb +36 -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
@@ -32,17 +32,21 @@ module ApiKeys
32
32
  current_api_key&.owner
33
33
  end
34
34
 
35
+ alias_method :current_api_key_owner, :current_api_owner
36
+
35
37
  # Convenience helper: returns the owner if it's a User instance.
36
38
  # @return [User, nil]
37
39
  def current_api_user
38
40
  owner = current_api_owner
39
- owner if owner.is_a?(::User) # Assumes a User class exists
41
+ owner if defined?(::User) && owner.is_a?(::User)
40
42
  end
41
43
 
42
44
  private
43
45
 
44
46
  # The core authentication method.
45
47
  def authenticate_api_key!(scope: nil)
48
+ @current_api_key = nil
49
+ remove_instance_variable(:@current_api_tenant) if instance_variable_defined?(:@current_api_tenant)
46
50
  log_debug "[ApiKeys Auth] authenticate_api_key! started for request: #{request.uuid}"
47
51
 
48
52
  # Enqueue before_authentication callback asynchronously
@@ -50,13 +54,12 @@ module ApiKeys
50
54
 
51
55
  # Perform synchronous authentication
52
56
  result = Services::Authenticator.call(request)
53
- log_debug "[ApiKeys Auth] Authenticator result: #{result.inspect}"
57
+ log_debug "[ApiKeys Auth] Authentication result: success=#{result.success?}, error_code=#{result.error_code || 'none'}"
54
58
 
55
59
  # Prepare context for after_authentication callback
56
60
  after_auth_context = {
57
61
  success: result.success?,
58
62
  error_code: result.error_code,
59
- message: result.message,
60
63
  api_key_id: result.api_key&.id # Pass ID only, not the full object
61
64
  }
62
65
 
@@ -65,10 +68,18 @@ module ApiKeys
65
68
  log_debug "[ApiKeys Auth] Authentication successful. Key ID: #{@current_api_key.id}"
66
69
 
67
70
  if scope && !check_api_key_scopes(scope)
68
- log_debug "[ApiKeys Auth] Scope check failed. Required: #{scope}, Key scopes: #{@current_api_key.scopes}"
71
+ log_debug "[ApiKeys Auth] Scope check failed for key ID #{@current_api_key.id}."
69
72
  # Add required scope info to context before rendering/enqueueing
70
73
  after_auth_context[:required_scope_check] = { required: scope, passed: false }
71
- render_unauthorized(error_code: :missing_scope, message: "API key does not have the required scope(s): #{scope}", required_scope: scope)
74
+ after_auth_context[:success] = false
75
+ after_auth_context[:error_code] = :missing_scope
76
+ @current_api_key = nil
77
+ render_unauthorized(
78
+ error_code: :missing_scope,
79
+ message: "API key does not have the required scope(s): #{scope}",
80
+ status: :forbidden,
81
+ required_scope: scope
82
+ )
72
83
  else
73
84
  after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
74
85
  # Authentication and scope check successful, enqueue stats update
@@ -90,7 +101,7 @@ module ApiKeys
90
101
  # @param required_scopes [String, Array<String>] The required scope(s).
91
102
  # @return [Boolean] True if the key has all required scopes, false otherwise.
92
103
  def check_api_key_scopes(required_scopes)
93
- return true unless current_api_key # Should not happen if authenticate_api_key! ran
104
+ return false unless current_api_key
94
105
  return true if required_scopes.blank?
95
106
 
96
107
  Array(required_scopes).all? do |req_scope|
@@ -112,6 +123,11 @@ module ApiKeys
112
123
  return unless ApiKeys.configuration.enable_async_operations
113
124
  return unless current_api_key
114
125
 
126
+ if stats_update_debounced?
127
+ log_debug "[ApiKeys Auth] Skipping a recently recorded last-used update for ApiKey ID: #{current_api_key.id}"
128
+ return
129
+ end
130
+
115
131
  # Check ActiveJob configuration and warn if using suboptimal adapters
116
132
  adapter = ActiveJob::Base.queue_adapter
117
133
  if adapter.is_a?(ActiveJob::QueueAdapters::InlineAdapter)
@@ -124,11 +140,23 @@ module ApiKeys
124
140
  timestamp = Time.current # Capture time once for the job
125
141
  log_debug "[ApiKeys Auth] Enqueuing UpdateStatsJob for ApiKey ID: #{current_api_key.id} at #{timestamp}"
126
142
  ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
127
- rescue StandardError => e
128
- log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id}: #{e.message}"
143
+ rescue StandardError => error
144
+ log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id} (#{error.class})."
129
145
  end
130
146
  end
131
147
 
148
+ def stats_update_debounced?
149
+ config = ApiKeys.configuration
150
+ return false if config.track_requests_count
151
+
152
+ interval = config.stats_update_interval
153
+ seconds = interval.to_f if interval.respond_to?(:to_f)
154
+ return false unless seconds&.finite? && seconds.positive?
155
+ return false unless current_api_key.last_used_at
156
+
157
+ current_api_key.last_used_at >= Time.current - seconds
158
+ end
159
+
132
160
  # Helper to safely enqueue callback jobs.
133
161
  def enqueue_callback(callback_type, context)
134
162
  # Return early if async operations are globally disabled
@@ -148,10 +176,10 @@ module ApiKeys
148
176
 
149
177
  # Proceed with enqueueing if it's a configured callback
150
178
  begin
151
- log_debug "[ApiKeys Auth] Enqueuing CallbacksJob for type: #{callback_type} with context: #{context.inspect}"
179
+ log_debug "[ApiKeys Auth] Enqueuing callback job for type: #{callback_type}"
152
180
  ApiKeys::Jobs::CallbacksJob.perform_later(callback_type, context)
153
- rescue StandardError => e
154
- log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type}: #{e.message}"
181
+ rescue StandardError => error
182
+ log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type} (#{error.class})."
155
183
  # Don't fail the request if callback enqueueing fails
156
184
  end
157
185
  end
@@ -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,54 @@ 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
 
55
59
  # Debugging
56
- attr_accessor :debug_logging
60
+ attr_reader :debug_logging
57
61
 
58
62
  # Key Types & Environments (Stripe-style publishable/secret keys)
59
63
  #
@@ -96,25 +100,310 @@ module ApiKeys
96
100
  # If false (default), dashboard only shows keys matching current_environment.
97
101
  # @example
98
102
  # config.dashboard_allow_cross_environment = false
99
- attr_accessor :environments, :current_environment, :strict_environment_isolation,
100
- :default_key_type, :dashboard_allow_cross_environment
103
+ attr_reader :environments, :current_environment, :strict_environment_isolation,
104
+ :default_key_type, :dashboard_allow_cross_environment
101
105
 
102
106
  # Custom writer for key_types that validates prefix uniqueness
103
107
  attr_reader :key_types
104
108
 
109
+ VALID_HASH_STRATEGIES = %i[sha256 bcrypt].freeze
110
+ VALID_TOKEN_ALPHABETS = %i[base58 hex].freeze
111
+ TOKEN_LENGTH_RANGE = (16..64)
112
+ MAX_CONFIGURED_SCOPES = 100
113
+ CONFIG_NAME_PATTERN = /\A[a-zA-Z0-9_-]{1,64}\z/
114
+ HTTP_HEADER_PATTERN = /\A[!#$%&'*+\-.^_`|~0-9A-Za-z]{1,128}\z/
115
+ QUERY_PARAM_PATTERN = /\A[a-zA-Z0-9_.~-]{1,128}\z/
116
+ METHOD_NAME_PATTERN = /\A[a-zA-Z_]\w*[!?]?\z/
117
+ CONSTANT_NAME_PATTERN = /\A(?:::)?[A-Z]\w*(?:::[A-Z]\w*)*\z/
118
+ BOOLEAN_SETTINGS = %i[
119
+ require_key_name track_requests_count https_only_production https_strict_mode
120
+ enable_async_operations debug_logging strict_environment_isolation
121
+ dashboard_allow_cross_environment
122
+ ].freeze
123
+
124
+ def header=(value)
125
+ unless value.nil? || (value.is_a?(String) && value.match?(HTTP_HEADER_PATTERN))
126
+ raise ArgumentError, "header must be nil or a valid HTTP header name of at most 128 characters"
127
+ end
128
+
129
+ @header = value&.dup&.freeze
130
+ end
131
+
132
+ def query_param=(value)
133
+ unless value.nil? || (value.is_a?(String) && value.match?(QUERY_PARAM_PATTERN))
134
+ raise ArgumentError, "query_param must be nil or a safe parameter name of at most 128 characters"
135
+ end
136
+
137
+ @query_param = value&.dup&.freeze
138
+ end
139
+
140
+ def default_max_keys_per_owner=(value)
141
+ unless value.nil? || (value.is_a?(Integer) && value >= 0)
142
+ raise ArgumentError, "default_max_keys_per_owner must be a non-negative Integer or nil"
143
+ end
144
+
145
+ @default_max_keys_per_owner = value
146
+ end
147
+
148
+ def expire_after=(value)
149
+ unless value.nil?
150
+ seconds = value.to_f if value.respond_to?(:to_f)
151
+ valid = value.respond_to?(:from_now) && seconds&.finite? && seconds.positive?
152
+ raise ArgumentError, "expire_after must be a positive duration or nil" unless valid
153
+ end
154
+
155
+ @expire_after = value
156
+ end
157
+
158
+ def default_scopes=(value)
159
+ unless value.is_a?(Array) && value.length <= MAX_CONFIGURED_SCOPES && value.all? { |scope| valid_scope_name?(scope) }
160
+ raise ArgumentError, "default_scopes must be a bounded Array of safe scope strings"
161
+ end
162
+
163
+ @default_scopes = deep_copy_and_freeze(value.uniq)
164
+ end
165
+
166
+ def cache_ttl=(value)
167
+ unless value.nil?
168
+ seconds = value.to_f if value.respond_to?(:to_f)
169
+ valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
170
+ raise ArgumentError, "cache_ttl must be a finite non-negative duration/number or nil" unless valid
171
+ end
172
+
173
+ @cache_ttl = value
174
+ end
175
+
176
+ def stats_update_interval=(value)
177
+ unless value.nil?
178
+ seconds = value.to_f if value.respond_to?(:to_f)
179
+ valid = (value.is_a?(Numeric) || value.respond_to?(:from_now)) && seconds&.finite? && !seconds.negative?
180
+ raise ArgumentError, "stats_update_interval must be a finite non-negative duration/number or nil" unless valid
181
+ end
182
+
183
+ @stats_update_interval = value
184
+ end
185
+
186
+ def parent_controller=(value)
187
+ valid = value.is_a?(Class) || (value.is_a?(String) && value.match?(CONSTANT_NAME_PATTERN))
188
+ raise ArgumentError, "parent_controller must be a controller Class or a valid constant name" unless valid
189
+
190
+ @parent_controller = value.is_a?(String) ? value.dup.freeze : value
191
+ @parent_controller_explicitly_configured = true
192
+ end
193
+
194
+ def parent_controller_class
195
+ candidate = if !@parent_controller_explicitly_configured && defined?(ApiKeys::Engine) &&
196
+ ApiKeys::Engine.config.parent_controller.present?
197
+ ApiKeys::Engine.config.parent_controller
198
+ else
199
+ parent_controller
200
+ end
201
+ candidate.is_a?(Class) ? candidate : candidate.constantize
202
+ end
203
+
204
+ BOOLEAN_SETTINGS.each do |setting|
205
+ define_method("#{setting}=") do |value|
206
+ raise ArgumentError, "#{setting} must be true or false" unless value == true || value == false
207
+
208
+ instance_variable_set("@#{setting}", value)
209
+ end
210
+ end
211
+
212
+ def before_authentication=(value)
213
+ validate_callback!(value, :before_authentication)
214
+ @before_authentication = value
215
+ end
216
+
217
+ def after_authentication=(value)
218
+ validate_callback!(value, :after_authentication)
219
+ @after_authentication = value
220
+ end
221
+
222
+ def tenant_resolver=(value)
223
+ raise ArgumentError, "tenant_resolver must be callable" unless value.respond_to?(:call)
224
+
225
+ @tenant_resolver = value
226
+ end
227
+
228
+ def secure_compare_proc=(value)
229
+ raise ArgumentError, "secure_compare_proc must be callable" unless value.respond_to?(:call)
230
+
231
+ @secure_compare_proc = value
232
+ end
233
+
234
+ def stats_job_queue=(value)
235
+ @stats_job_queue = validate_queue_name!(value, :stats_job_queue)
236
+ end
237
+
238
+ def callbacks_job_queue=(value)
239
+ @callbacks_job_queue = validate_queue_name!(value, :callbacks_job_queue)
240
+ end
241
+
242
+ def current_owner_method=(value)
243
+ @current_owner_method = validate_method_name!(value, :current_owner_method)
244
+ end
245
+
246
+ def authenticate_owner_method=(value)
247
+ @authenticate_owner_method = validate_method_name!(value, :authenticate_owner_method)
248
+ end
249
+
250
+ def current_environment=(value)
251
+ unless value.nil? || value.respond_to?(:call) || valid_config_name?(value)
252
+ raise ArgumentError, "current_environment must be callable, a safe String/Symbol, or nil"
253
+ end
254
+
255
+ @current_environment = value
256
+ end
257
+
258
+ def default_key_type=(value)
259
+ unless value.nil? || valid_config_name?(value)
260
+ raise ArgumentError, "default_key_type must be a safe String/Symbol or nil"
261
+ end
262
+
263
+ @default_key_type = value
264
+ end
265
+
266
+ def token_prefix=(value)
267
+ unless value.is_a?(String) || value.respond_to?(:call)
268
+ raise ArgumentError, "token_prefix must be a String or callable object"
269
+ end
270
+
271
+ validate_resolved_prefix!(value) if value.is_a?(String)
272
+ @token_prefix = value
273
+ end
274
+
275
+ def resolved_token_prefix
276
+ value = @token_prefix.respond_to?(:call) ? @token_prefix.call : @token_prefix
277
+ validate_resolved_prefix!(value)
278
+ value
279
+ end
280
+
281
+ def token_length=(value)
282
+ unless value.is_a?(Integer) && TOKEN_LENGTH_RANGE.cover?(value)
283
+ raise ArgumentError, "token_length must be an Integer between #{TOKEN_LENGTH_RANGE.begin} and #{TOKEN_LENGTH_RANGE.end}"
284
+ end
285
+
286
+ @token_length = value
287
+ end
288
+
289
+ def token_alphabet=(value)
290
+ unless VALID_TOKEN_ALPHABETS.include?(value)
291
+ raise ArgumentError, "token_alphabet must be one of: #{VALID_TOKEN_ALPHABETS.join(', ')}"
292
+ end
293
+
294
+ @token_alphabet = value
295
+ end
296
+
297
+ def hash_strategy=(value)
298
+ unless VALID_HASH_STRATEGIES.include?(value)
299
+ raise ArgumentError, "hash_strategy must be one of: #{VALID_HASH_STRATEGIES.join(', ')}"
300
+ end
301
+
302
+ @hash_strategy = value
303
+ end
304
+
105
305
  # Sets the key types configuration with prefix collision validation.
106
306
  # @param value [Hash] Key type definitions
107
307
  # @raise [ArgumentError] If multiple key types share the same prefix
108
308
  def key_types=(value)
109
- validate_key_type_prefixes!(value) if value.is_a?(Hash) && value.any?
110
- @key_types = value
309
+ raise ArgumentError, "key_types must be a Hash" unless value.is_a?(Hash)
310
+
311
+ validate_key_types!(value)
312
+ validate_composite_prefixes!(value, @environments || {})
313
+ @key_types = deep_copy_and_freeze(value)
314
+ end
315
+
316
+ def environments=(value)
317
+ raise ArgumentError, "environments must be a Hash" unless value.is_a?(Hash)
318
+
319
+ value.each do |name, environment_config|
320
+ validate_config_name!(name, "environment")
321
+ raise ArgumentError, "Environment '#{name}' configuration must be a Hash" unless environment_config.is_a?(Hash)
322
+
323
+ segment = environment_config[:prefix_segment]
324
+ validate_config_name!(segment, "environment prefix segment") unless segment.nil?
325
+ end
326
+ validate_duplicate_config_names!(value, "environment")
327
+ validate_composite_prefixes!(@key_types || {}, value)
328
+ @environments = deep_copy_and_freeze(value)
111
329
  end
112
330
 
113
331
  private
114
332
 
333
+ def validate_resolved_prefix!(prefix)
334
+ valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
335
+ prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
336
+ return if valid
337
+
338
+ raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
339
+ rescue ArgumentError
340
+ raise ArgumentError, "token_prefix must resolve to a non-blank string of at most 64 bytes without whitespace or control characters"
341
+ end
342
+
343
+ def validate_key_types!(key_types_hash)
344
+ validate_duplicate_config_names!(key_types_hash, "key type")
345
+
346
+ key_types_hash.each do |name, type_config|
347
+ validate_config_name!(name, "key type")
348
+ raise ArgumentError, "Key type '#{name}' configuration must be a Hash" unless type_config.is_a?(Hash)
349
+
350
+ validate_config_name!(type_config[:prefix], "key type prefix")
351
+ permissions = type_config[:permissions]
352
+ unless permissions == :all || permissions.is_a?(Array)
353
+ raise ArgumentError, "Key type '#{name}' permissions must be :all or an Array of strings"
354
+ end
355
+ if permissions.is_a?(Array) && permissions.length > MAX_CONFIGURED_SCOPES
356
+ raise ArgumentError, "Key type '#{name}' permissions cannot contain more than #{MAX_CONFIGURED_SCOPES} entries"
357
+ end
358
+ if permissions.is_a?(Array) && permissions.any? { |permission| !valid_scope_name?(permission) }
359
+ raise ArgumentError, "Key type '#{name}' permissions must contain only non-blank strings of at most 128 bytes"
360
+ end
361
+
362
+ %i[revocable public].each do |setting|
363
+ next unless type_config.key?(setting)
364
+ next if [true, false].include?(type_config[setting])
365
+
366
+ raise ArgumentError, "Key type '#{name}' #{setting} must be true or false"
367
+ end
368
+
369
+ limit = type_config[:limit]
370
+ if !limit.nil? && (!limit.is_a?(Integer) || limit <= 0)
371
+ raise ArgumentError, "Key type '#{name}' limit must be a positive Integer or nil"
372
+ end
373
+
374
+ next unless type_config[:public] == true
375
+
376
+ unless type_config[:revocable] == false
377
+ raise ArgumentError, "Public key type '#{name}' must explicitly set revocable: false"
378
+ end
379
+ unless permissions.is_a?(Array) && permissions.any?
380
+ raise ArgumentError, "Public key type '#{name}' must have a finite, non-empty permissions list"
381
+ end
382
+ end
383
+
384
+ validate_key_type_prefixes!(key_types_hash)
385
+ end
386
+
387
+ def validate_config_name!(value, label)
388
+ return if valid_config_name?(value)
389
+
390
+ raise ArgumentError, "#{label} must contain only letters, numbers, underscores, or hyphens (1-64 characters)"
391
+ end
392
+
393
+ def valid_config_name?(value)
394
+ (value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(CONFIG_NAME_PATTERN)
395
+ end
396
+
397
+ def valid_scope_name?(value)
398
+ value.is_a?(String) && value.present? && value.valid_encoding? && value.bytesize <= 128 &&
399
+ value.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
400
+ rescue ArgumentError
401
+ false
402
+ end
403
+
115
404
  # Validates that all key type prefixes are unique to prevent token collision
116
405
  def validate_key_type_prefixes!(key_types_hash)
117
- prefixes = key_types_hash.map { |_type, config| config[:prefix] }.compact
406
+ prefixes = key_types_hash.map { |_type, config| config[:prefix].to_s }.compact
118
407
  duplicates = prefixes.group_by(&:itself).select { |_k, v| v.size > 1 }.keys
119
408
 
120
409
  if duplicates.any?
@@ -122,6 +411,65 @@ module ApiKeys
122
411
  end
123
412
  end
124
413
 
414
+ def validate_duplicate_config_names!(configuration, label)
415
+ duplicates = configuration.keys.map(&:to_s).group_by(&:itself).select { |_name, names| names.length > 1 }.keys
416
+ return if duplicates.empty?
417
+
418
+ raise ArgumentError, "#{label} names must be unique after string normalization: #{duplicates.join(', ')}"
419
+ end
420
+
421
+ def validate_composite_prefixes!(key_types, environments)
422
+ return if key_types.empty?
423
+
424
+ environment_entries = environments.empty? ? [[nil, {}]] : environments.to_a
425
+ combinations = key_types.flat_map do |type_name, type_config|
426
+ environment_entries.map do |environment_name, environment_config|
427
+ segment = environment_config[:prefix_segment]
428
+ prefix = segment.nil? ? "#{type_config[:prefix]}_" : "#{type_config[:prefix]}_#{segment}_"
429
+ [prefix, "#{type_name}/#{environment_name || 'default'}"]
430
+ end
431
+ end
432
+ collisions = combinations.group_by(&:first).select { |_prefix, entries| entries.length > 1 }
433
+ return if collisions.empty?
434
+
435
+ details = collisions.map { |prefix, entries| "#{prefix} (#{entries.map(&:last).join(', ')})" }.join("; ")
436
+ raise ArgumentError, "Key type/environment prefixes must be unique: #{details}"
437
+ end
438
+
439
+ def validate_callback!(value, setting)
440
+ raise ArgumentError, "#{setting} must be a Proc" unless value.is_a?(Proc)
441
+ end
442
+
443
+ def validate_queue_name!(value, setting)
444
+ unless (value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(/\A[a-zA-Z0-9_-]{1,128}\z/)
445
+ raise ArgumentError, "#{setting} must be a safe String or Symbol"
446
+ end
447
+
448
+ value
449
+ end
450
+
451
+ def validate_method_name!(value, setting)
452
+ unless value.nil? || ((value.is_a?(String) || value.is_a?(Symbol)) && value.to_s.match?(METHOD_NAME_PATTERN))
453
+ raise ArgumentError, "#{setting} must be a valid method name or nil"
454
+ end
455
+
456
+ value
457
+ end
458
+
459
+ def deep_copy_and_freeze(value)
460
+ copied = case value
461
+ when Hash
462
+ value.to_h { |key, nested_value| [deep_copy_and_freeze(key), deep_copy_and_freeze(nested_value)] }
463
+ when Array
464
+ value.map { |nested_value| deep_copy_and_freeze(nested_value) }
465
+ when String
466
+ value.dup
467
+ else
468
+ value
469
+ end
470
+ copied.freeze
471
+ end
472
+
125
473
  public
126
474
 
127
475
  # == Initialization ==
@@ -154,7 +502,8 @@ module ApiKeys
154
502
  @policy_provider = "ApiKeys::BasePolicy" # Default authorization policy class name
155
503
 
156
504
  # Engine Configuration
157
- @parent_controller = '::ApplicationController'
505
+ @parent_controller = DEFAULT_PARENT_CONTROLLER
506
+ @parent_controller_explicitly_configured = false
158
507
 
159
508
  # Owner Context Configuration
160
509
  @current_owner_method = :current_user # Default to current_user for backward compatibility
@@ -164,14 +513,15 @@ module ApiKeys
164
513
  @default_max_keys_per_owner = nil # No global key limit per owner
165
514
  @require_key_name = false # Don't require names for keys globally
166
515
  @expire_after = nil # Keys do not expire by default (e.g., 90.days)
167
- @default_scopes = [] # No default scopes assigned globally
516
+ @default_scopes = [].freeze # No default scopes assigned globally
168
517
 
169
518
  # Performance
170
519
  @cache_ttl = 5.seconds # Good balance: fast revocation, mostly-cached – still allows most repeated requests to benefit from cache
520
+ @stats_update_interval = 1.minute # Debounce last_used_at writes unless exact request counting is enabled
171
521
 
172
522
  # Security
173
523
  @https_only_production = true # Warn if used over HTTP in production
174
- @https_strict_mode = false # Don't raise error, just warn
524
+ @https_strict_mode = true # Fail closed if a production request is not HTTPS
175
525
 
176
526
  # Background Job Queues
177
527
  @stats_job_queue = :default
@@ -198,8 +548,8 @@ module ApiKeys
198
548
  @tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
199
549
 
200
550
  # 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
551
+ @key_types = {}.freeze # Empty = feature disabled, legacy behavior
552
+ @environments = {}.freeze # Empty = no environment-based prefixes
203
553
  @current_environment = -> { :default } # Default environment detection
204
554
  @strict_environment_isolation = false # Don't enforce environment isolation by default
205
555
  @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