api_keys 0.4.3 → 0.5.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/README.md +195 -27
  4. data/SECURITY.md +21 -2
  5. data/app/controllers/api_keys/keys_controller.rb +137 -11
  6. data/app/views/api_keys/keys/_form.html.erb +80 -17
  7. data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
  8. data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
  9. data/app/views/api_keys/security/best_practices.html.erb +1 -1
  10. data/app/views/layouts/api_keys/application.html.erb +9 -1
  11. data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
  12. data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
  13. data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
  14. data/lib/api_keys/authentication.rb +7 -1
  15. data/lib/api_keys/configuration.rb +54 -8
  16. data/lib/api_keys/engine.rb +6 -0
  17. data/lib/api_keys/errors.rb +11 -0
  18. data/lib/api_keys/form_builder_extensions.rb +40 -3
  19. data/lib/api_keys/helpers/token_session.rb +92 -3
  20. data/lib/api_keys/models/api_key.rb +240 -11
  21. data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
  22. data/lib/api_keys/restrictions.rb +430 -0
  23. data/lib/api_keys/services/authenticator.rb +110 -12
  24. data/lib/api_keys/version.rb +1 -1
  25. data/lib/api_keys.rb +1 -0
  26. data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
  27. data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
  28. data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
  29. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
  30. data/lib/generators/api_keys/templates/initializer.rb +49 -6
  31. metadata +8 -1
@@ -3,6 +3,7 @@
3
3
  require "active_support/core_ext/numeric/time"
4
4
  require "active_support/core_ext/string/inflections"
5
5
  require "active_support/security_utils"
6
+ require_relative "restrictions"
6
7
 
7
8
  module ApiKeys
8
9
  # Defines the configuration options for the ApiKeys gem.
@@ -41,6 +42,24 @@ module ApiKeys
41
42
  # Security
42
43
  attr_reader :https_only_production, :https_strict_mode
43
44
 
45
+ # Request Restrictions
46
+ #
47
+ # @!attribute [rw] client_ip_resolver
48
+ # @return [#call] Callable receiving the request and returning the client
49
+ # IP address used to evaluate a key's `allowed_ips` list. The default,
50
+ # `request.remote_ip`, is right only behind a proxy that is the sole way
51
+ # in, rewrites `X-Forwarded-For`, and is covered by
52
+ # `config.action_dispatch.trusted_proxies`. When the Rails server is
53
+ # directly reachable, a client can forge `X-Forwarded-For`; resolve the
54
+ # socket peer with `request.remote_addr` instead. A resolver that trusts
55
+ # a vendor header is safe only when network ingress rejects requests
56
+ # that bypass that vendor.
57
+ # @example Directly reachable server
58
+ # config.client_ip_resolver = ->(request) { request.remote_addr }
59
+ # @example Behind Cloudflare, with direct origin access blocked
60
+ # config.client_ip_resolver = ->(request) { request.headers.fetch("CF-Connecting-IP") }
61
+ attr_reader :client_ip_resolver
62
+
44
63
  # Tenant Resolution
45
64
  attr_reader :tenant_resolver
46
65
 
@@ -81,13 +100,18 @@ module ApiKeys
81
100
  # - :permissions [Array<String>, :all] Scope ceiling for this type
82
101
  # - :revocable [Boolean] Whether keys can be revoked (default: true)
83
102
  # - :limit [Integer, nil] Max keys per owner per environment (nil = unlimited)
84
- # - :public [Boolean] If true AND revocable: false, store plaintext token in
85
- # metadata so it can be viewed again in dashboard. Use ONLY for publishable
86
- # keys that are designed to be embedded in distributed apps. (default: false)
103
+ # - :public [Boolean] If true, store the plaintext token in metadata so it
104
+ # can be viewed again in the dashboard. Use ONLY for publishable keys
105
+ # designed to be embedded in distributed apps. Public types must have a
106
+ # finite, non-empty permissions list. (default: false)
107
+ # - :restrictions [Array<Symbol>] Which request-restriction kinds keys of this
108
+ # type may carry: any subset of [:origins, :ips]. Omitted means both are
109
+ # allowed; `[]` forbids restrictions entirely for this type.
87
110
  # @example
88
111
  # config.key_types = {
89
- # publishable: { prefix: "pk", permissions: %w[read], revocable: false, public: true, limit: 1 },
90
- # secret: { prefix: "sk", permissions: :all }
112
+ # publishable: { prefix: "pk", permissions: %w[read], public: true, limit: 1,
113
+ # restrictions: [:origins] },
114
+ # secret: { prefix: "sk", permissions: :all, restrictions: [:ips] }
91
115
  # }
92
116
  #
93
117
  # @!attribute [rw] environments
@@ -240,6 +264,12 @@ module ApiKeys
240
264
  @tenant_resolver = value
241
265
  end
242
266
 
267
+ def client_ip_resolver=(value)
268
+ raise ArgumentError, "client_ip_resolver must be callable" unless value.respond_to?(:call)
269
+
270
+ @client_ip_resolver = value
271
+ end
272
+
243
273
  def secure_compare_proc=(value)
244
274
  raise ArgumentError, "secure_compare_proc must be callable" unless value.respond_to?(:call)
245
275
 
@@ -403,11 +433,10 @@ module ApiKeys
403
433
  raise ArgumentError, "Key type '#{name}' limit must be a positive Integer or nil"
404
434
  end
405
435
 
436
+ validate_restriction_kinds!(name, type_config[:restrictions]) if type_config.key?(:restrictions)
437
+
406
438
  next unless type_config[:public] == true
407
439
 
408
- unless type_config[:revocable] == false
409
- raise ArgumentError, "Public key type '#{name}' must explicitly set revocable: false"
410
- end
411
440
  unless permissions.is_a?(Array) && permissions.any?
412
441
  raise ArgumentError, "Public key type '#{name}' must have a finite, non-empty permissions list"
413
442
  end
@@ -416,6 +445,18 @@ module ApiKeys
416
445
  validate_key_type_prefixes!(key_types_hash)
417
446
  end
418
447
 
448
+ # A key type may declare which request-restriction kinds its keys can carry.
449
+ # Omitting the setting allows every kind; `[]` forbids all of them.
450
+ def validate_restriction_kinds!(name, kinds)
451
+ valid = kinds.is_a?(Array) && kinds.all? do |kind|
452
+ (kind.is_a?(Symbol) || kind.is_a?(String)) && ApiKeys::Restrictions::KIND_NAMES.include?(kind.to_s)
453
+ end
454
+ return if valid
455
+
456
+ raise ArgumentError,
457
+ "Key type '#{name}' restrictions must be an Array containing any of: #{ApiKeys::Restrictions::KIND_NAMES.join(', ')}"
458
+ end
459
+
419
460
  def validate_config_name!(value, label)
420
461
  return if valid_config_name?(value)
421
462
 
@@ -555,6 +596,11 @@ module ApiKeys
555
596
  @https_only_production = true # Warn if used over HTTP in production
556
597
  @https_strict_mode = true # Fail closed if a production request is not HTTPS
557
598
 
599
+ # Request Restrictions
600
+ # Rails' remote_ip already honors config.action_dispatch.trusted_proxies,
601
+ # so the sensible default is simply to trust what Rails resolved.
602
+ @client_ip_resolver = ->(request) { request.remote_ip }
603
+
558
604
  # Background Job Queues
559
605
  @stats_job_queue = :default
560
606
  @callbacks_job_queue = :default
@@ -39,6 +39,12 @@ module ApiKeys
39
39
  ApiKeys::ApiKey.attribute :scopes, json_col_type, default: []
40
40
  ApiKeys::ApiKey.attribute :metadata, json_col_type, default: {}
41
41
 
42
+ # Request restrictions arrived in 0.5.0. Installations that have not
43
+ # run `rails generate api_keys:add_restrictions` yet must not gain a
44
+ # virtual attribute that silently accepts writes it cannot persist.
45
+ if ApiKeys::ApiKey.restrictions_column?
46
+ ApiKeys::ApiKey.attribute :restrictions, json_col_type, default: {}
47
+ end
42
48
  end
43
49
  end
44
50
  end
@@ -69,5 +69,16 @@ module ApiKeys
69
69
  )
70
70
  end
71
71
  end
72
+
73
+ # Raised when request restrictions are used but the `restrictions` column is missing
74
+ class RestrictionsMigrationRequiredError < BaseError
75
+ def initialize(message = nil)
76
+ super(
77
+ message ||
78
+ "Request restrictions are configured but the `restrictions` database column is missing. " \
79
+ "Run: rails generate api_keys:add_restrictions && rails db:migrate"
80
+ )
81
+ end
82
+ end
72
83
  end
73
84
  end
@@ -21,6 +21,8 @@ module ApiKeys
21
21
  # <%= scope %>
22
22
  # </label>
23
23
  # <% end %>
24
+ # (The helper prepends an empty `api_key[scopes][]` sentinel so that
25
+ # clearing every box submits "no scopes" instead of nothing.)
24
26
  #
25
27
  module FormBuilderExtensions
26
28
  # Renders a select field for API key expiration presets.
@@ -62,6 +64,25 @@ module ApiKeys
62
64
  # - :none - no scopes checked
63
65
  # - Array - specific scopes to check
64
66
  # - nil - uses the object's current scopes
67
+ # @param include_hidden [Boolean, nil] With a block, whether to render an
68
+ # empty `name="api_key[scopes][]" value=""` sentinel before the boxes.
69
+ # Browsers omit unchecked boxes, so without it clearing every box submits
70
+ # no scopes parameter at all, and a create would silently get the default
71
+ # scopes while an update would keep the old ones. With the sentinel the
72
+ # server receives `[""]`, which `ApiKey#scopes=` and `create_api_key!`
73
+ # read as "no scopes". The submitted list replaces the key's scopes, so
74
+ # render a box for every scope the key may hold.
75
+ # - nil (default): render it when there is at least one box and the
76
+ # form's key enforces its scopes (`ApiKey#scopes_enforced?`). Elsewhere
77
+ # an empty list would mean "unrestricted" and clearing the boxes would
78
+ # widen the key. Per-owner default scopes are read through the key's
79
+ # `owner`, so build the form's key from its owner
80
+ # (`owner.api_keys.build`): a form with no object, or on a bare
81
+ # `ApiKey.new`, cannot see them and renders no sentinel.
82
+ # - true: render it whenever there is at least one box, without asking
83
+ # the key. Use it on those ownerless forms when an empty list grants
84
+ # nothing in your app.
85
+ # - false: never render it.
65
86
  # @return [String, Array] HTML if block given, otherwise array of scope data
66
87
  #
67
88
  # @example With block (recommended for custom styling)
@@ -72,27 +93,30 @@ module ApiKeys
72
93
  # </label>
73
94
  # <% end %>
74
95
  #
75
- # @example Simple rendering without block
96
+ # @example Simple rendering without block (render the sentinel yourself)
97
+ # <%= hidden_field_tag "api_key[scopes][]", "", id: nil %>
76
98
  # <% form.api_key_scopes_checkboxes(@scopes).each do |scope_data| %>
77
99
  # <%= check_box_tag "api_key[scopes][]", scope_data[:value], scope_data[:checked] %>
78
100
  # <%= scope_data[:value] %>
79
101
  # <% end %>
80
102
  #
81
- def api_key_scopes_checkboxes(scopes, checked: nil, &block)
103
+ def api_key_scopes_checkboxes(scopes, checked: nil, include_hidden: nil, &block)
82
104
  # Determine which scopes should be checked
83
105
  checked_scopes = resolve_checked_scopes(scopes, checked)
106
+ field_name = "#{object_name}[scopes][]"
84
107
 
85
108
  scope_data = scopes.map do |scope|
86
109
  {
87
110
  value: scope,
88
111
  checked: checked_scopes.include?(scope),
89
- field_name: "#{object_name}[scopes][]"
112
+ field_name: field_name
90
113
  }
91
114
  end
92
115
 
93
116
  if block_given?
94
117
  # Yield each scope for custom rendering
95
118
  safe_buffer = ActiveSupport::SafeBuffer.new
119
+ safe_buffer << @template.hidden_field_tag(field_name, "", id: nil) if render_scope_sentinel?(scopes, include_hidden)
96
120
  scope_data.each do |data|
97
121
  safe_buffer << @template.capture { yield(data[:value], data[:checked]) }
98
122
  end
@@ -135,6 +159,19 @@ module ApiKeys
135
159
 
136
160
  private
137
161
 
162
+ # The sentinel is only safe where an empty list grants nothing. With no
163
+ # boxes there is nothing to clear, so it is never rendered then. By
164
+ # default it also needs a key that enforces its scopes; where the key does
165
+ # not, an empty list would mean "unrestricted", so the parameter is left
166
+ # out and the key keeps the scopes it has. `include_hidden: true` skips
167
+ # that question for forms whose key cannot answer it.
168
+ def render_scope_sentinel?(scopes, include_hidden)
169
+ return false if include_hidden == false || scopes.empty?
170
+ return true if include_hidden == true
171
+
172
+ object.respond_to?(:scopes_enforced?) && object.scopes_enforced?
173
+ end
174
+
138
175
  def safe_key_type(value)
139
176
  case value.to_s
140
177
  when "publishable" then :publishable
@@ -1,8 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "active_support/cache"
3
4
  require "active_support/core_ext/numeric/time"
4
5
  require "active_support/message_encryptor"
6
+ require "digest"
5
7
  require "json"
8
+ require "securerandom"
9
+ require_relative "../logging"
6
10
 
7
11
  module ApiKeys
8
12
  module Helpers
@@ -16,6 +20,18 @@ module ApiKeys
16
20
  # 2. On the success page, decrypt, retrieve, and clear the token
17
21
  # 3. If the user refreshes, the token is gone
18
22
  #
23
+ # Deleting the payload from the session only consumes it in that session.
24
+ # A copy of the session taken before retrieval (a copied CookieStore
25
+ # cookie, or two requests racing with the same cookie) would still carry
26
+ # it. So each handoff also has a random ID, and the first retrieval
27
+ # records an atomic consumption marker for that ID in `Rails.cache`
28
+ # (`write(..., unless_exist: true)`). Any later retrieval of the same
29
+ # handoff finds the marker and gets nil. This guarantee needs a cache store
30
+ # that every application process shares and that implements
31
+ # `unless_exist:` atomically. With `ActiveSupport::Cache::NullStore` the
32
+ # marker cannot be recorded, so deleting the payload from the session is
33
+ # the only protection, as it was before 0.5.1.
34
+ #
19
35
  # @example In your controller
20
36
  # # After creating a key:
21
37
  # def create
@@ -31,13 +47,20 @@ module ApiKeys
31
47
  # end
32
48
  #
33
49
  class TokenSession
50
+ extend ApiKeys::Logging
51
+
34
52
  # Default session key for storing the token
35
53
  DEFAULT_SESSION_KEY = :api_keys_new_token
36
54
  MAX_TOKEN_BYTESIZE = 512
37
55
  MAX_CIPHERTEXT_BYTESIZE = 4096
38
56
  MAX_KEY_ID_BYTESIZE = 128
57
+ # The wire format is unchanged since 0.4.0, so processes on 0.5.0 and
58
+ # 0.5.1 can read each other's handoffs during a rolling deploy. 0.5.1
59
+ # adds a random handoff ID inside the encrypted payload.
39
60
  HANDOFF_VERSION = 2
40
61
  HANDOFF_TTL = 10.minutes
62
+ HANDOFF_ID_PATTERN = /\A[A-Za-z0-9_-]{22,64}\z/
63
+ CONSUMPTION_CACHE_NAMESPACE = "api_keys:v1:token_handoff_consumed"
41
64
  ENCRYPTION_CIPHER = "aes-256-gcm"
42
65
  ENCRYPTION_SALT = "api_keys/token_session/v2"
43
66
  ENCRYPTION_PURPOSE = "api_keys.token_session"
@@ -68,7 +91,7 @@ module ApiKeys
68
91
 
69
92
  api_key_id = normalize_api_key_id(api_key.id) if api_key.respond_to?(:id)
70
93
  encrypted_payload = token_encryptor.encrypt_and_sign(
71
- { "token" => token, "api_key_id" => api_key_id },
94
+ { "token" => token, "api_key_id" => api_key_id, "handoff_id" => SecureRandom.urlsafe_base64(24) },
72
95
  expires_in: HANDOFF_TTL,
73
96
  purpose: ENCRYPTION_PURPOSE
74
97
  )
@@ -81,7 +104,8 @@ module ApiKeys
81
104
  end
82
105
 
83
106
  # Retrieve and clear the token from the session.
84
- # Returns nil if no token is stored (e.g., page was refreshed).
107
+ # Returns nil if no token is stored (e.g., page was refreshed), or if
108
+ # this handoff was already retrieved through any copy of the session.
85
109
  #
86
110
  # @param session [ActionDispatch::Request::Session] The Rails session
87
111
  # @param key [Symbol] Optional custom session key (default: :api_keys_new_token)
@@ -96,6 +120,11 @@ module ApiKeys
96
120
  decoded_payload = decode_payload(payload)
97
121
  return nil unless decoded_payload
98
122
 
123
+ # Any authentic retrieval attempt uses the handoff up, matching the
124
+ # session deletion above: a mismatched or replayed attempt never
125
+ # leaves the handoff available to a later one.
126
+ return nil unless consume_handoff(decoded_payload)
127
+
99
128
  token = decoded_payload["token"] || decoded_payload[:token]
100
129
  stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
101
130
  return nil if expected_id && stored_id.to_s != expected_id.to_s
@@ -121,8 +150,9 @@ module ApiKeys
121
150
  token = decoded_payload["token"] || decoded_payload[:token]
122
151
  stored_id = decoded_payload["api_key_id"] || decoded_payload[:api_key_id]
123
152
  return false if expected_id && stored_id.to_s != expected_id.to_s
153
+ return false unless valid_token_payload?(token)
124
154
 
125
- valid_token_payload?(token)
155
+ !handoff_consumed?(decoded_payload)
126
156
  end
127
157
 
128
158
  private
@@ -146,6 +176,11 @@ module ApiKeys
146
176
  inner_id = normalize_api_key_id(decoded["api_key_id"] || decoded[:api_key_id])
147
177
  return nil unless outer_id == inner_id
148
178
 
179
+ # Handoffs written before 0.5.1 carry no ID; a present ID must be
180
+ # well formed, since it names the consumption marker.
181
+ handoff_id = decoded["handoff_id"]
182
+ return nil unless handoff_id.nil? || (handoff_id.is_a?(String) && handoff_id.match?(HANDOFF_ID_PATTERN))
183
+
149
184
  decoded
150
185
  rescue ActiveSupport::MessageEncryptor::InvalidMessage, ArgumentError
151
186
  nil
@@ -161,6 +196,60 @@ module ApiKeys
161
196
  nil
162
197
  end
163
198
 
199
+ # Records the consumption marker. Answers true only to the first
200
+ # consumer of a handoff; every later one, and any cache failure, is
201
+ # treated as "no token" (fail closed).
202
+ def consume_handoff(decoded_payload)
203
+ cache = consumption_cache
204
+ return true unless cache
205
+
206
+ marker = consumption_marker_key(decoded_payload)
207
+ return true unless marker
208
+
209
+ cache.write(marker, true, unless_exist: true, expires_in: HANDOFF_TTL) ? true : false
210
+ rescue StandardError => error
211
+ log_warn "[ApiKeys Security] Token handoff consumption could not be recorded (#{error.class}); withholding the token."
212
+ false
213
+ end
214
+
215
+ def handoff_consumed?(decoded_payload)
216
+ cache = consumption_cache
217
+ return false unless cache
218
+
219
+ marker = consumption_marker_key(decoded_payload)
220
+ return false unless marker
221
+
222
+ cache.exist?(marker)
223
+ rescue StandardError => error
224
+ log_warn "[ApiKeys Security] Token handoff consumption could not be checked (#{error.class})."
225
+ true
226
+ end
227
+
228
+ # The cache that can enforce one-time retrieval across copies of a
229
+ # session, or nil when there is none. NullStore accepts every write and
230
+ # remembers nothing, so it cannot enforce anything.
231
+ def consumption_cache
232
+ cache = Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
233
+ return nil if cache.nil? || cache.is_a?(ActiveSupport::Cache::NullStore)
234
+
235
+ cache
236
+ end
237
+
238
+ # Handoffs written by 0.5.1 are named by their random ID. Handoffs
239
+ # written before it have none; they are named by a domain-separated
240
+ # digest of their token, which the encryption authenticates and which
241
+ # is identical in every copy of the session. (Bare-string handoffs from
242
+ # before 0.4.0 are returned before decoding and have no marker.)
243
+ def consumption_marker_key(decoded_payload)
244
+ handoff_id = decoded_payload["handoff_id"]
245
+ return "#{CONSUMPTION_CACHE_NAMESPACE}:#{handoff_id}" if handoff_id
246
+
247
+ token = decoded_payload["token"]
248
+ return nil unless valid_token_payload?(token)
249
+
250
+ "#{CONSUMPTION_CACHE_NAMESPACE}:token-#{Digest::SHA256.hexdigest("api_keys token handoff\0#{token}")}"
251
+ end
252
+
164
253
  def normalize_api_key_id(value)
165
254
  return nil if value.nil?
166
255