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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -0
- data/README.md +101 -39
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -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 +374 -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 +32 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
- data/lib/generators/api_keys/templates/initializer.rb +36 -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
|
@@ -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)
|
|
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]
|
|
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
|
|
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
|
-
|
|
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
|
|
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 =>
|
|
128
|
-
log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id}
|
|
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
|
|
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 =>
|
|
154
|
-
log_error "[ApiKeys Auth] Failed to enqueue CallbacksJob for type #{callback_type}
|
|
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
|
-
|
|
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
|
|
|
55
59
|
# Debugging
|
|
56
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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 =
|
|
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 =
|
|
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)
|
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
|