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
@@ -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
@@ -1,5 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/core_ext/numeric/time"
4
+ require "active_support/message_encryptor"
5
+ require "json"
6
+
3
7
  module ApiKeys
4
8
  module Helpers
5
9
  # Helper for managing API key tokens in the session.
@@ -8,8 +12,8 @@ module ApiKeys
8
12
  # the plaintext token is not stored in the database. This helper provides
9
13
  # a clean interface for the "show token once" pattern:
10
14
  #
11
- # 1. After creating a key, store the token in the session
12
- # 2. On the success page, retrieve (and clear) the token
15
+ # 1. After creating a key, store an encrypted, short-lived handoff in the session
16
+ # 2. On the success page, decrypt, retrieve, and clear the token
13
17
  # 3. If the user refreshes, the token is gone
14
18
  #
15
19
  # @example In your controller
@@ -29,9 +33,28 @@ module ApiKeys
29
33
  class TokenSession
30
34
  # Default session key for storing the token
31
35
  DEFAULT_SESSION_KEY = :api_keys_new_token
36
+ MAX_TOKEN_BYTESIZE = 512
37
+ MAX_CIPHERTEXT_BYTESIZE = 4096
38
+ MAX_KEY_ID_BYTESIZE = 128
39
+ HANDOFF_VERSION = 2
40
+ HANDOFF_TTL = 10.minutes
41
+ ENCRYPTION_CIPHER = "aes-256-gcm"
42
+ ENCRYPTION_SALT = "api_keys/token_session/v2"
43
+ ENCRYPTION_PURPOSE = "api_keys.token_session"
44
+ JSON_SERIALIZER = Module.new do
45
+ module_function
46
+
47
+ def dump(value)
48
+ JSON.generate(value)
49
+ end
50
+
51
+ def load(value)
52
+ JSON.parse(value)
53
+ end
54
+ end
32
55
 
33
56
  class << self
34
- # Store an API key's token in the session for later retrieval.
57
+ # Store an encrypted API key-token handoff in the session for later retrieval.
35
58
  #
36
59
  # @param session [ActionDispatch::Request::Session] The Rails session
37
60
  # @param api_key [ApiKeys::ApiKey] The newly created API key
@@ -39,7 +62,21 @@ module ApiKeys
39
62
  # @return [String] The token that was stored
40
63
  def store(session, api_key, key: DEFAULT_SESSION_KEY)
41
64
  token = api_key.respond_to?(:token) ? api_key.token : api_key.to_s
42
- session[key] = token
65
+ unless valid_token_payload?(token)
66
+ raise ArgumentError, "Cannot store an invalid API key token in the session"
67
+ end
68
+
69
+ api_key_id = normalize_api_key_id(api_key.id) if api_key.respond_to?(:id)
70
+ encrypted_payload = token_encryptor.encrypt_and_sign(
71
+ { "token" => token, "api_key_id" => api_key_id },
72
+ expires_in: HANDOFF_TTL,
73
+ purpose: ENCRYPTION_PURPOSE
74
+ )
75
+ session[key] = {
76
+ "version" => HANDOFF_VERSION,
77
+ "ciphertext" => encrypted_payload,
78
+ "api_key_id" => api_key_id
79
+ }
43
80
  token
44
81
  end
45
82
 
@@ -49,8 +86,22 @@ module ApiKeys
49
86
  # @param session [ActionDispatch::Request::Session] The Rails session
50
87
  # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
51
88
  # @return [String, nil] The token, or nil if not present
52
- def retrieve_once(session, key: DEFAULT_SESSION_KEY)
53
- session.delete(key)
89
+ def retrieve_once(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
90
+ payload = session.delete(key)
91
+ expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
92
+
93
+ # Plain string payloads from older versions remain readable only when
94
+ # the caller does not request ID binding.
95
+ return payload if expected_id.nil? && valid_token_payload?(payload)
96
+ decoded_payload = decode_payload(payload)
97
+ return nil unless decoded_payload
98
+
99
+ token = decoded_payload["token"] || decoded_payload[:token]
100
+ stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
101
+ return nil if expected_id && stored_id.to_s != expected_id.to_s
102
+ return nil unless valid_token_payload?(token)
103
+
104
+ token
54
105
  end
55
106
 
56
107
  # Check if a token is available in the session without removing it.
@@ -59,8 +110,92 @@ module ApiKeys
59
110
  # @param session [ActionDispatch::Request::Session] The Rails session
60
111
  # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
61
112
  # @return [Boolean] true if a token is stored
62
- def available?(session, key: DEFAULT_SESSION_KEY)
63
- session[key].present?
113
+ def available?(session, key: DEFAULT_SESSION_KEY, api_key: nil, api_key_id: nil)
114
+ payload = session[key]
115
+ expected_id = normalize_api_key_id(api_key_id || (api_key.id if api_key.respond_to?(:id)))
116
+
117
+ return valid_token_payload?(payload) if payload.is_a?(String) && expected_id.nil?
118
+ decoded_payload = decode_payload(payload)
119
+ return false unless decoded_payload
120
+
121
+ token = decoded_payload["token"] || decoded_payload[:token]
122
+ stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
123
+ return false if expected_id && stored_id.to_s != expected_id.to_s
124
+
125
+ valid_token_payload?(token)
126
+ end
127
+
128
+ private
129
+
130
+ def decode_payload(payload)
131
+ return nil unless payload.is_a?(Hash)
132
+
133
+ version_present = payload.key?("version") || payload.key?(:version)
134
+ return legacy_payload(payload) unless version_present
135
+
136
+ version = payload["version"] || payload[:version]
137
+ return nil unless version == HANDOFF_VERSION
138
+
139
+ ciphertext = payload["ciphertext"] || payload[:ciphertext]
140
+ outer_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
141
+ return nil unless ciphertext.is_a?(String) && ciphertext.bytesize <= MAX_CIPHERTEXT_BYTESIZE
142
+
143
+ decoded = token_encryptor.decrypt_and_verify(ciphertext, purpose: ENCRYPTION_PURPOSE)
144
+ return nil unless decoded.is_a?(Hash)
145
+
146
+ inner_id = normalize_api_key_id(decoded["api_key_id"] || decoded[:api_key_id])
147
+ return nil unless outer_id == inner_id
148
+
149
+ decoded
150
+ rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
151
+ nil
152
+ end
153
+
154
+ def legacy_payload(payload)
155
+ token = payload["token"] || payload[:token]
156
+ stored_id = normalize_api_key_id(payload["api_key_id"] || payload[:api_key_id])
157
+ return nil unless valid_token_payload?(token)
158
+
159
+ { "token" => token, "api_key_id" => stored_id }
160
+ rescue ArgumentError
161
+ nil
162
+ end
163
+
164
+ def normalize_api_key_id(value)
165
+ return nil if value.nil?
166
+
167
+ normalized = value.to_s
168
+ valid = normalized.present? && normalized.valid_encoding? &&
169
+ normalized.bytesize <= MAX_KEY_ID_BYTESIZE &&
170
+ normalized.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
171
+ raise ArgumentError, "API key ID is invalid" unless valid
172
+
173
+ normalized
174
+ rescue ArgumentError
175
+ raise ArgumentError, "API key ID is invalid"
176
+ end
177
+
178
+ def token_encryptor
179
+ application = Rails.application if defined?(Rails) && Rails.respond_to?(:application)
180
+ unless application&.respond_to?(:key_generator)
181
+ raise ArgumentError, "Rails.application.key_generator is required for secure token handoff"
182
+ end
183
+
184
+ key_length = ActiveSupport::MessageEncryptor.key_len(ENCRYPTION_CIPHER)
185
+ encryption_key = application.key_generator.generate_key(ENCRYPTION_SALT, key_length)
186
+ ActiveSupport::MessageEncryptor.new(
187
+ encryption_key,
188
+ cipher: ENCRYPTION_CIPHER,
189
+ serializer: JSON_SERIALIZER
190
+ )
191
+ end
192
+
193
+ def valid_token_payload?(token)
194
+ token.is_a?(String) && token.present? && token.valid_encoding? &&
195
+ token.bytesize <= MAX_TOKEN_BYTESIZE &&
196
+ token.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
197
+ rescue ArgumentError
198
+ false
64
199
  end
65
200
  end
66
201
  end
@@ -185,7 +185,11 @@ module ApiKeys
185
185
  # # => { type: :publishable, label: "Publishable", color: :green }
186
186
  #
187
187
  def api_key_type_info(api_key)
188
- type = api_key.key_type.presence&.to_sym || :secret
188
+ type = case api_key.key_type.to_s
189
+ when "", "secret" then :secret
190
+ when "publishable" then :publishable
191
+ else api_key.key_type.to_s
192
+ end
189
193
  {
190
194
  type: type,
191
195
  label: api_key_type_label(api_key),
@@ -10,8 +10,8 @@ module ApiKeys
10
10
  class CallbacksJob < ActiveJob::Base
11
11
  include ApiKeys::Logging
12
12
 
13
- # Use the queue name specified in the configuration (evaluated at load time)
14
- queue_as ApiKeys.configuration.callbacks_job_queue
13
+ # Resolve the configured queue for each job so initializer changes apply.
14
+ queue_as { ApiKeys.configuration.callbacks_job_queue }
15
15
 
16
16
  # Executes the appropriate callback based on the type.
17
17
  #
@@ -28,10 +28,8 @@ module ApiKeys
28
28
  else
29
29
  log_warn "[ApiKeys::Jobs::CallbacksJob] Unknown callback type: #{callback_type}"
30
30
  end
31
- rescue StandardError => e
32
- log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type} with context #{context.inspect}: #{e.class}: #{e.message}
33
- #{e.backtrace.join("
34
- ")}"
31
+ rescue StandardError => error
32
+ log_error "[ApiKeys::Jobs::CallbacksJob] Error executing callback #{callback_type} (#{error.class})."
35
33
  # Avoid retrying callback errors by default, as the original request succeeded.
36
34
  # Depending on callback importance, users might configure retries separately.
37
35
  end
@@ -51,17 +49,12 @@ module ApiKeys
51
49
  arity = callback_proc.arity
52
50
  log_debug "[ApiKeys::Jobs::CallbacksJob] Executing callback with arity #{arity}"
53
51
 
54
- begin
55
- if arity == 1 || arity < 0 # Handle procs accepting one arg or variable args (*args)
56
- callback_proc.call(context)
57
- elsif arity == 0 # Handle procs accepting no args
58
- callback_proc.call
59
- else
60
- log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
61
- end
62
- rescue StandardError => e
63
- # Log the specific error from the user's callback code
64
- raise # Re-raise to be caught by the main perform rescue block for logging
52
+ if arity == 1 || arity < 0 # Handle procs accepting one arg or variable args (*args)
53
+ callback_proc.call(context)
54
+ elsif arity == 0 # Handle procs accepting no args
55
+ callback_proc.call
56
+ else
57
+ log_warn "[ApiKeys::Jobs::CallbacksJob] Callback has unexpected arity (#{arity}). Expected 0 or 1 argument (context hash). Skipping execution."
65
58
  end
66
59
  end
67
60
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "active_job"
4
+ require "active_support/core_ext/numeric/time"
4
5
  require_relative "../models/api_key"
5
6
  require_relative "../logging"
6
7
 
@@ -11,8 +12,10 @@ module ApiKeys
11
12
  class UpdateStatsJob < ActiveJob::Base
12
13
  include ApiKeys::Logging # Include logging helpers
13
14
 
14
- # Use the queue name specified in the configuration (evaluated at load time)
15
- queue_as ApiKeys.configuration.stats_job_queue
15
+ MAX_FUTURE_CLOCK_SKEW = 5.minutes
16
+
17
+ # Resolve the configured queue for each job so initializer changes apply.
18
+ queue_as { ApiKeys.configuration.stats_job_queue }
16
19
 
17
20
  # Perform the database updates for the given ApiKey.
18
21
  #
@@ -26,11 +29,25 @@ module ApiKeys
26
29
  return
27
30
  end
28
31
 
29
- log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id} at #{timestamp}"
32
+ unless timestamp.respond_to?(:to_time)
33
+ log_warn "[ApiKeys::Jobs::UpdateStatsJob] Invalid timestamp for ApiKey ID: #{api_key_id}. Skipping stats update."
34
+ return
35
+ end
36
+ timestamp = timestamp.to_time
37
+
38
+ if timestamp > Time.current + MAX_FUTURE_CLOCK_SKEW
39
+ log_warn "[ApiKeys::Jobs::UpdateStatsJob] Rejected a timestamp too far in the future."
40
+ return
41
+ end
42
+
43
+ log_debug "[ApiKeys::Jobs::UpdateStatsJob] Updating stats for ApiKey ID: #{api_key_id}"
30
44
 
31
- # Use provided timestamp for consistency
32
- # Use update_column to skip validations/callbacks for performance
33
- api_key.update_column(:last_used_at, timestamp)
45
+ # Jobs may execute out of order. Update only when this event is newer so
46
+ # last_used_at can never regress to an earlier request timestamp.
47
+ ApiKey
48
+ .where(id: api_key.id)
49
+ .where("last_used_at IS NULL OR last_used_at < ?", timestamp)
50
+ .update_all(last_used_at: timestamp)
34
51
 
35
52
  # Conditionally increment requests_count if configured
36
53
  if ApiKeys.configuration.track_requests_count
@@ -41,16 +58,14 @@ module ApiKeys
41
58
 
42
59
  log_debug "[ApiKeys::Jobs::UpdateStatsJob] Finished updating stats for ApiKey ID: #{api_key_id}"
43
60
 
44
- rescue ActiveRecord::ActiveRecordError => e
61
+ rescue ActiveRecord::ActiveRecordError => error
45
62
  # Log error but don't automatically retry unless configured to do so.
46
63
  # Frequent stats updates might tolerate occasional failures better than endless retries.
47
- log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id}. Error: #{e.message}"
64
+ log_error "[ApiKeys::Jobs::UpdateStatsJob] Failed to update stats for ApiKey ID: #{api_key_id} (#{error.class})."
48
65
  # Depending on ActiveJob adapter, specific retry logic might be needed here
49
66
  # or configured globally. For now, just log.
50
- rescue StandardError => e
51
- log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id}. Error: #{e.class}: #{e.message}
52
- #{e.backtrace.join("
53
- ")}"
67
+ rescue StandardError => error
68
+ log_error "[ApiKeys::Jobs::UpdateStatsJob] Unexpected error processing ApiKey ID: #{api_key_id} (#{error.class})."
54
69
  # Consider re-raising or using a dead-letter queue strategy depending on job system
55
70
  end
56
71
  end