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
@@ -0,0 +1,430 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+ require "uri"
5
+
6
+ module ApiKeys
7
+ # Value object describing *where* an API key may be used from: a list of web
8
+ # origins (hosts, with optional `*.` subdomain wildcards) and a list of IP
9
+ # addresses or CIDR ranges.
10
+ #
11
+ # Restrictions are plain data stored in the `restrictions` JSON column:
12
+ #
13
+ # { "origins" => ["example.com", "*.example.com"],
14
+ # "ips" => ["203.0.113.7", "10.0.0.0/8", "2001:db8::/32"] }
15
+ #
16
+ # Matching semantics (normative):
17
+ #
18
+ # - Within a list: OR. Any entry that matches admits the request.
19
+ # - Across lists: AND. Every list that is present and non-empty must pass.
20
+ # - Empty (or absent) restrictions mean unrestricted. Presence is the toggle.
21
+ # - Every failure mode fails closed: a locked list plus an unreadable request
22
+ # context refuses the request.
23
+ #
24
+ # The object is immutable and has no Active Record dependency. Malformed
25
+ # persisted values are represented explicitly and deny authentication; model
26
+ # validations keep them out during ordinary writes.
27
+ class Restrictions
28
+ # The restriction kinds this gem understands. Anything else stored in the
29
+ # column is a validation error rather than a silently ignored key.
30
+ KINDS = %i[origins ips].freeze
31
+ KIND_NAMES = KINDS.map(&:to_s).freeze
32
+
33
+ # Entries are split on commas, whitespace, and newlines so that a single
34
+ # text field can hold a whole list ("example.com, *.example.com").
35
+ ENTRY_SEPARATOR = /[\s,;]+/
36
+
37
+ # A bare host, optionally prefixed with a `*.` subdomain wildcard.
38
+ # `*` alone is deliberately invalid: an empty list already means "anywhere".
39
+ DNS_LABEL = /[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?/
40
+ ORIGIN_ENTRY_PATTERN = /\A(?:\*\.)?#{DNS_LABEL}(?:\.#{DNS_LABEL})*\z/
41
+
42
+ attr_reader :origins, :ips, :extras
43
+
44
+ class << self
45
+ # Coerces anything into a Restrictions instance. Never raises.
46
+ #
47
+ # @param value [Restrictions, Hash, nil, Object] The stored column value,
48
+ # a hash of lists, or an existing instance.
49
+ # @return [ApiKeys::Restrictions]
50
+ def wrap(value)
51
+ return value if value.is_a?(self)
52
+ return none if value.nil?
53
+ return new(origins: [], ips: [], extras: {}, malformed: true) unless value.is_a?(Hash)
54
+
55
+ known, extras = value.partition { |key, _entries| KIND_NAMES.include?(key.to_s) }
56
+ known = known.to_h { |key, entries| [key.to_s, entries] }
57
+
58
+ # Only an absent kind means "no list". A kind that is present must
59
+ # hold a list; `"ips" => nil` is as malformed as `"ips" => false`.
60
+ new(
61
+ origins: known.key?("origins") ? coerce_list(known["origins"]) : [],
62
+ ips: known.key?("ips") ? coerce_list(known["ips"]) : [],
63
+ extras: extras.to_h
64
+ )
65
+ rescue StandardError
66
+ # Stored policy is untrusted input. Preserve the core invariant even if
67
+ # an exotic object raises while being coerced: malformed never means
68
+ # unrestricted.
69
+ new(origins: [], ips: [], extras: {}, malformed: true)
70
+ end
71
+
72
+ # Reads a restrictions hash a caller passes in (`ApiKey#restrictions=`
73
+ # and `create_api_key!(restrictions:)`). A kind given as `nil` or blank
74
+ # text is cleared, exactly as the `allowed_origins=` / `allowed_ips=`
75
+ # writers clear it. Everything else passes through untouched, so
76
+ # `false`, wrong types, and malformed list members still fail
77
+ # validation. Stored rows never go through here: `wrap` reads them
78
+ # strictly, and a stored `null` refuses authentication.
79
+ #
80
+ # @param value [Hash, Object] Caller input; non-hashes are returned as is.
81
+ # @return [Hash, Object]
82
+ def normalize_input(value)
83
+ return value unless value.is_a?(Hash)
84
+
85
+ value.to_h do |kind, entries|
86
+ cleared = KIND_NAMES.include?(kind.to_s) && blank_input?(entries)
87
+ [kind, cleared ? [] : entries]
88
+ end
89
+ rescue StandardError
90
+ value
91
+ end
92
+
93
+ # The shared empty instance: no origins, no IPs, no restrictions at all.
94
+ # @return [ApiKeys::Restrictions]
95
+ def none
96
+ @none ||= new(origins: [], ips: [], extras: {}).freeze
97
+ end
98
+
99
+ # Forgiving parser for the raw string a dashboard text field submits.
100
+ # Accepts full URLs, bare hosts, commas, newlines, and stray whitespace;
101
+ # returns bare lowercase hosts, de-duplicated, order preserved.
102
+ #
103
+ # normalize_origins("https://Shop.example/, *.app.example\n x")
104
+ # # => ["shop.example", "*.app.example", "x"]
105
+ #
106
+ # Non-string entries are preserved so validation can report malformed
107
+ # programmatic input instead of silently erasing a requested policy.
108
+ # @param value [String, Array, nil] Raw user input.
109
+ # @return [Array] Normalized origin entries.
110
+ def normalize_origins(value)
111
+ tokenize(value).map do |token|
112
+ next token unless token.is_a?(String)
113
+
114
+ origin_host(token) || token.strip.downcase
115
+ end.uniq
116
+ end
117
+
118
+ # Forgiving parser for IP/CIDR input. String entries are kept verbatim
119
+ # (lowercased) so validation, not the parser, reports malformed ranges.
120
+ # Non-string entries are likewise preserved for validation.
121
+ #
122
+ # @param value [String, Array, nil] Raw user input.
123
+ # @return [Array<String>] Normalized IP entries.
124
+ def normalize_ips(value)
125
+ tokenize(value).map { |entry| entry.is_a?(String) ? entry.downcase : entry }.uniq
126
+ end
127
+
128
+ # Extracts the host the browser claims the request came from: the Origin
129
+ # header when present, the Referer header otherwise. Returns nil when
130
+ # neither is present or parseable, which callers must treat as a refusal.
131
+ #
132
+ # @param request [ActionDispatch::Request, #headers, nil]
133
+ # @return [String, nil] Bare lowercase host.
134
+ def extract_origin_host(request)
135
+ headers = request.headers if request.respond_to?(:headers)
136
+ return nil unless headers.respond_to?(:[])
137
+
138
+ origin = headers["Origin"]
139
+ unless origin.nil? || (origin.is_a?(String) && origin.strip.empty?)
140
+ # Origin has precedence over Referer. A present-but-invalid Origin
141
+ # (including the browser's opaque `null` origin) must not be rescued
142
+ # by a friendlier Referer value.
143
+ return host_from_url(origin)
144
+ end
145
+
146
+ host_from_url(headers["Referer"])
147
+ rescue StandardError
148
+ # A hostile or exotic request object must never take an endpoint down;
149
+ # an unreadable origin is simply an origin that matches nothing.
150
+ nil
151
+ end
152
+
153
+ # Splits raw input into candidate entries without interpreting them.
154
+ #
155
+ # `nil` and blank text are the raw-input spellings of an empty list (a
156
+ # cleared dashboard field). Every other non-string value, including
157
+ # `false` and a `nil` inside an array, is kept as an entry so that
158
+ # validation rejects it: malformed input must never read as "unrestricted".
159
+ # @api private
160
+ def tokenize(value)
161
+ entries = case value
162
+ when nil then []
163
+ when String then value.split(ENTRY_SEPARATOR)
164
+ when Array then value.flat_map { |entry| entry.is_a?(String) ? entry.split(ENTRY_SEPARATOR) : [entry] }
165
+ else [value]
166
+ end
167
+
168
+ entries.each_with_object([]) do |entry, tokens|
169
+ unless entry.is_a?(String)
170
+ tokens << entry
171
+ next
172
+ end
173
+
174
+ trimmed = entry.strip
175
+ tokens << trimmed unless trimmed.empty?
176
+ end
177
+ end
178
+
179
+ # Reduces a single user-supplied entry to a bare lowercase host.
180
+ # Full URLs give up their host; bare hosts keep everything before the
181
+ # first slash, colon, or question mark. Returns nil when nothing is left.
182
+ # @api private
183
+ def origin_host(entry)
184
+ candidate = entry.to_s.strip
185
+ return nil if candidate.empty?
186
+
187
+ if candidate.include?("//")
188
+ host = host_from_url(candidate)
189
+ return host
190
+ end
191
+
192
+ if (address = parse_ip(candidate)) && !candidate.include?("/")
193
+ return address.to_s.downcase
194
+ end
195
+
196
+ if candidate.start_with?("[")
197
+ host = host_from_url("http://#{candidate}")
198
+ return host if host
199
+ end
200
+
201
+ host = candidate.split(%r{[/?#]}).first.to_s
202
+ host = host.sub(/:\d*\z/, "") # Strip a trailing port ("example.com:3000").
203
+ host = host.delete_prefix("[").delete_suffix("]") # IPv6 literals.
204
+ host = host.downcase
205
+ host.empty? ? nil : host
206
+ end
207
+
208
+ # Pulls the host out of a full URL, tolerating garbage.
209
+ # @api private
210
+ def host_from_url(value)
211
+ return nil unless value.is_a?(String)
212
+
213
+ trimmed = value.strip
214
+ return nil if trimmed.empty?
215
+
216
+ host = URI.parse(trimmed).host
217
+ return nil if host.nil? || host.empty?
218
+
219
+ host.delete_prefix("[").delete_suffix("]").downcase
220
+ rescue URI::Error, ArgumentError
221
+ nil
222
+ end
223
+
224
+ # Coerces one present list (storage shape) into an array of entries.
225
+ #
226
+ # Nothing is ever dropped: `false`, `nil`, numbers, hashes, and blank
227
+ # strings inside an array stay as entries, and a scalar that is not a
228
+ # string (`false`, `nil`, `42`) becomes a single entry, so validation
229
+ # rejects them at write time and authentication refuses them at read
230
+ # time. The only spelling of an intentionally empty list is `[]` (or
231
+ # leaving the kind out). A string is accepted as a raw list
232
+ # ("a.com, b.com"), but it must contain at least one entry.
233
+ # @api private
234
+ def coerce_list(value)
235
+ case value
236
+ when Array
237
+ value.map { |entry| coerce_entry(entry) }
238
+ when String
239
+ entries = value.split(ENTRY_SEPARATOR).reject(&:empty?).map { |entry| coerce_entry(entry) }
240
+ entries.empty? ? [value] : entries
241
+ else
242
+ [value]
243
+ end
244
+ rescue ArgumentError
245
+ # A string with invalid encoding cannot be split; keep it as the
246
+ # invalid entry it is.
247
+ [value]
248
+ end
249
+
250
+ # @api private
251
+ def blank_input?(entries)
252
+ entries.nil? || (entries.is_a?(String) && entries.strip.empty?)
253
+ rescue ArgumentError
254
+ false
255
+ end
256
+
257
+ # @api private
258
+ def coerce_entry(entry)
259
+ return entry unless entry.is_a?(String)
260
+
261
+ entry.strip.downcase
262
+ rescue ArgumentError
263
+ entry
264
+ end
265
+
266
+ # Whether a stored origin entry is shaped like a host or `*.host`.
267
+ # @api private
268
+ def valid_origin_entry?(entry)
269
+ return false unless entry.is_a?(String)
270
+ return true if !entry.include?("/") && parse_ip(entry)
271
+
272
+ entry.bytesize <= 253 && entry.match?(ORIGIN_ENTRY_PATTERN)
273
+ rescue ArgumentError
274
+ false
275
+ end
276
+
277
+ # Whether a stored IP entry is a single address or a CIDR range.
278
+ # @api private
279
+ def valid_ip_entry?(entry)
280
+ parse_ip(entry) ? true : false
281
+ end
282
+
283
+ # Parses an address or range with stdlib IPAddr. A bare address is a /32
284
+ # (or /128), so `IPAddr#include?` answers exact matches and range matches
285
+ # through a single code path.
286
+ # @api private
287
+ def parse_ip(value)
288
+ return nil unless value.is_a?(String)
289
+
290
+ trimmed = value.strip
291
+ return nil if trimmed.empty?
292
+
293
+ address = IPAddr.new(trimmed)
294
+ address.ipv6? && address.ipv4_mapped? ? address.native : address
295
+ rescue IPAddr::Error
296
+ nil
297
+ end
298
+ end
299
+
300
+ # @param origins [Array<String>] Already-coerced origin entries.
301
+ # @param ips [Array<String>] Already-coerced IP entries.
302
+ # @param extras [Hash] Unrecognized keys, preserved so validation sees them.
303
+ # @param malformed [Boolean] Whether coercion itself found an invalid shape.
304
+ def initialize(origins: [], ips: [], extras: {}, malformed: false)
305
+ @origins = deep_copy(origins, freeze_copy: true)
306
+ @ips = deep_copy(ips, freeze_copy: true)
307
+ @extras = deep_copy(extras, freeze_copy: true)
308
+ @malformed = malformed || @extras.any? ||
309
+ @origins.any? { |entry| !self.class.valid_origin_entry?(entry) } ||
310
+ @ips.any? { |entry| !self.class.valid_ip_entry?(entry) }
311
+ freeze
312
+ end
313
+
314
+ # Malformed data can only arrive through validation-bypassing writes or a
315
+ # damaged database. Authentication always denies it.
316
+ def malformed?
317
+ @malformed
318
+ end
319
+
320
+ # @return [Boolean] true when this key may be used from anywhere.
321
+ def unrestricted?
322
+ !malformed? && origins.empty? && ips.empty?
323
+ end
324
+
325
+ # @return [Boolean] true when at least one list is locked.
326
+ def restricted?
327
+ !unrestricted?
328
+ end
329
+
330
+ # @return [Array<Symbol>] The restriction kinds actually in use.
331
+ def kinds
332
+ KINDS.reject { |kind| public_send(kind).empty? }
333
+ end
334
+
335
+ # The storage shape: known lists that have entries, plus any unrecognized
336
+ # keys exactly as they were found.
337
+ # @return [Hash]
338
+ def to_h
339
+ hash = {}
340
+ hash["origins"] = deep_copy(origins) unless origins.empty?
341
+ hash["ips"] = deep_copy(ips) unless ips.empty?
342
+ hash.merge(deep_copy(extras))
343
+ end
344
+
345
+ alias as_json to_h
346
+
347
+ # Does this request context satisfy every locked list?
348
+ #
349
+ # @param origin_host [String, nil] Host from Origin/Referer.
350
+ # @param ip [String, nil] Client IP address.
351
+ # @return [Boolean]
352
+ def allows?(origin_host: nil, ip: nil)
353
+ !malformed? && origin_allowed?(origin_host) && ip_allowed?(ip)
354
+ end
355
+
356
+ # @param host [String, nil] Bare host to check.
357
+ # @return [Boolean] true when the origins list is empty or one entry matches.
358
+ # A locked list plus a nil/blank host refuses: fail closed.
359
+ def origin_allowed?(host)
360
+ return false if malformed?
361
+ return true if origins.empty?
362
+
363
+ candidate = host.to_s.strip.downcase
364
+ return false if candidate.empty?
365
+ candidate = self.class.parse_ip(candidate)&.to_s || candidate
366
+
367
+ origins.any? { |entry| origin_entry_matches?(entry, candidate) }
368
+ end
369
+
370
+ # @param ip [String, nil] Client IP address.
371
+ # @return [Boolean] true when the IP list is empty or one entry contains it.
372
+ # A locked list plus an unparseable address refuses: fail closed.
373
+ def ip_allowed?(ip)
374
+ return false if malformed?
375
+ return true if ips.empty?
376
+
377
+ address = self.class.parse_ip(ip.is_a?(String) ? ip : ip.to_s)
378
+ return false unless address
379
+
380
+ ips.any? { |entry| ip_entry_matches?(entry, address) }
381
+ end
382
+
383
+ def ==(other)
384
+ other.is_a?(self.class) && other.to_h == to_h
385
+ end
386
+ alias eql? ==
387
+
388
+ def hash
389
+ to_h.hash
390
+ end
391
+
392
+ def inspect
393
+ "#<#{self.class.name} origins=#{origins.inspect} ips=#{ips.inspect} malformed=#{malformed?.inspect}>"
394
+ end
395
+
396
+ private
397
+
398
+ def deep_copy(value, freeze_copy: false)
399
+ copy = case value
400
+ when Hash
401
+ value.to_h do |key, entry|
402
+ [deep_copy(key, freeze_copy: freeze_copy), deep_copy(entry, freeze_copy: freeze_copy)]
403
+ end
404
+ when Array
405
+ value.map { |entry| deep_copy(entry, freeze_copy: freeze_copy) }
406
+ when String
407
+ value.dup
408
+ else
409
+ value
410
+ end
411
+ copy.freeze if freeze_copy
412
+ copy
413
+ end
414
+
415
+ # `*.example.com` matches any subdomain at any depth, but never the apex —
416
+ # Google's rule. List the apex separately when you want both.
417
+ def origin_entry_matches?(entry, host)
418
+ if entry.start_with?("*.")
419
+ suffix = entry.delete_prefix("*")
420
+ host.end_with?(suffix) && host.length > suffix.length
421
+ else
422
+ entry == host
423
+ end
424
+ end
425
+
426
+ def ip_entry_matches?(entry, address)
427
+ self.class.parse_ip(entry).include?(address)
428
+ end
429
+ end
430
+ end
@@ -5,6 +5,7 @@ require "active_support/core_ext/object/blank"
5
5
  require "digest"
6
6
  require_relative "../models/api_key"
7
7
  require_relative "../services/digestor"
8
+ require_relative "../restrictions"
8
9
  require_relative "../logging"
9
10
 
10
11
  module ApiKeys
@@ -14,6 +15,12 @@ module ApiKeys
14
15
  extend ApiKeys::Logging
15
16
 
16
17
  MAX_TOKEN_BYTESIZE = 512
18
+ # The longest authentication header that can still carry a valid token:
19
+ # the token plus the "Bearer" scheme and generous separator whitespace.
20
+ MAX_CREDENTIAL_HEADER_BYTESIZE = MAX_TOKEN_BYTESIZE + 64
21
+ # Stands in for a header refused before parsing. It is not a String, so
22
+ # it can never match a key and always yields :invalid_token.
23
+ MALFORMED_CREDENTIAL = Object.new.freeze
17
24
  MAX_BCRYPT_CANDIDATES = 32
18
25
  MAX_KNOWN_PREFIXES = 1_024
19
26
  TOKEN_CACHE_NAMESPACE = "api_keys:v2:token"
@@ -25,8 +32,13 @@ module ApiKeys
25
32
  new(success?: true, api_key: api_key)
26
33
  end
27
34
 
28
- def self.failure(error_code:, message:)
29
- new(success?: false, error_code: error_code, message: message)
35
+ # `api_key` is present when the key WAS identified and a policy check
36
+ # refused it (environment isolation, request restrictions): the
37
+ # after_authentication callback then reports WHICH key was refused,
38
+ # exactly as it already does for scope refusals. Lookup failures have
39
+ # no key to name, so they leave it nil.
40
+ def self.failure(error_code:, message:, api_key: nil)
41
+ new(success?: false, error_code: error_code, message: message, api_key: api_key)
30
42
  end
31
43
 
32
44
  # Do not delegate to Struct's default inspection: it recursively inspects
@@ -82,20 +94,25 @@ module ApiKeys
82
94
  elsif api_key&.active?
83
95
  log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"
84
96
 
85
- # Check environment isolation if enabled
97
+ # Check environment isolation, then the key's request restrictions.
98
+ # Both run on every request, including token-cache hits: the
99
+ # cache only shortcuts the lookup, and the row is reloaded fresh.
86
100
  env_check_result = check_environment_isolation(api_key, config)
101
+ restriction_result = env_check_result ? nil : check_request_restrictions(api_key, request, config)
87
102
  if env_check_result
88
103
  env_check_result # Return failure result
104
+ elsif restriction_result
105
+ restriction_result # Return failure result
89
106
  else
90
107
  # TODO: Optionally update last_used_at and requests_count
91
108
  Result.success(api_key)
92
109
  end
93
110
  elsif api_key&.revoked?
94
111
  log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
95
- Result.failure(error_code: :revoked_key, message: "API key has been revoked")
112
+ Result.failure(error_code: :revoked_key, message: "API key has been revoked", api_key: api_key)
96
113
  elsif api_key&.expired?
97
114
  log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}"
98
- Result.failure(error_code: :expired_key, message: "API key has expired")
115
+ Result.failure(error_code: :expired_key, message: "API key has expired", api_key: api_key)
99
116
  else # Not found, mismatch, or inactive
100
117
  log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found."
101
118
  Result.failure(error_code: :invalid_token, message: "API token is invalid")
@@ -114,6 +131,15 @@ module ApiKeys
114
131
  unless header_value.nil?
115
132
  return header_value unless header_value.is_a?(String)
116
133
 
134
+ # Check the raw header before parsing it: a regular expression over
135
+ # invalid UTF-8 raises, and an oversized value should never be
136
+ # scanned. A refused header is not a String, so valid_token?
137
+ # answers with the ordinary invalid_token failure.
138
+ unless acceptable_raw_credential?(header_value)
139
+ log_debug "[ApiKeys Auth] Rejected a malformed authentication header before parsing it."
140
+ return MALFORMED_CREDENTIAL
141
+ end
142
+
117
143
  # Handle "Bearer <token>" scheme
118
144
  match = header_value.match(/\ABearer[ \t]+(.+)\z/i)
119
145
  if match
@@ -331,6 +357,12 @@ module ApiKeys
331
357
  end.uniq
332
358
  end
333
359
 
360
+ # Encoding and size of the raw header, checked without any regular
361
+ # expression so that hostile bytes cannot raise or cost a scan.
362
+ def self.acceptable_raw_credential?(value)
363
+ value.bytesize <= MAX_CREDENTIAL_HEADER_BYTESIZE && value.valid_encoding?
364
+ end
365
+
334
366
  def self.valid_token?(token)
335
367
  return false unless token.is_a?(String)
336
368
  return false if token.empty? || token.bytesize > MAX_TOKEN_BYTESIZE || !token.valid_encoding?
@@ -357,7 +389,7 @@ module ApiKeys
357
389
  return nil if configured
358
390
 
359
391
  log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its key type is not configured."
360
- Result.failure(error_code: :unknown_key_type, message: "API key type is not configured")
392
+ Result.failure(error_code: :unknown_key_type, message: "API key type is not configured", api_key: api_key)
361
393
  end
362
394
 
363
395
  def self.check_environment_configuration(api_key, config)
@@ -371,7 +403,7 @@ module ApiKeys
371
403
  return nil if configured
372
404
 
373
405
  log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its environment is not configured."
374
- Result.failure(error_code: :unknown_environment, message: "API key environment is not configured")
406
+ Result.failure(error_code: :unknown_environment, message: "API key environment is not configured", api_key: api_key)
375
407
  end
376
408
 
377
409
  # Check if the API key's environment matches the current environment
@@ -386,7 +418,8 @@ module ApiKeys
386
418
  if key_env.blank?
387
419
  return Result.failure(
388
420
  error_code: :environment_misconfigured,
389
- message: "API key environment could not be verified"
421
+ message: "API key environment could not be verified",
422
+ api_key: api_key
390
423
  )
391
424
  end
392
425
 
@@ -398,7 +431,8 @@ module ApiKeys
398
431
  log_warn "[ApiKeys Security] Current environment resolution failed (#{error.class})."
399
432
  return Result.failure(
400
433
  error_code: :environment_misconfigured,
401
- message: "API key environment could not be verified"
434
+ message: "API key environment could not be verified",
435
+ api_key: api_key
402
436
  )
403
437
  end
404
438
 
@@ -412,7 +446,8 @@ module ApiKeys
412
446
  log_warn "[ApiKeys Security] Strict environment isolation is enabled, but current_environment resolved to blank."
413
447
  return Result.failure(
414
448
  error_code: :environment_misconfigured,
415
- message: "API key environment could not be verified"
449
+ message: "API key environment could not be verified",
450
+ api_key: api_key
416
451
  )
417
452
  end
418
453
 
@@ -420,13 +455,74 @@ module ApiKeys
420
455
  log_debug "[ApiKeys Auth] Environment mismatch for key ID #{api_key.id}."
421
456
  return Result.failure(
422
457
  error_code: :environment_mismatch,
423
- message: "API key cannot be used in this environment"
458
+ message: "API key cannot be used in this environment",
459
+ api_key: api_key
424
460
  )
425
461
  end
426
462
 
427
463
  nil # Check passed
428
464
  end
429
465
 
466
+ # Enforces the key's request restrictions: which web origins and which IP
467
+ # addresses may present it. Empty lists mean unrestricted, so every key
468
+ # written before this feature existed passes untouched.
469
+ #
470
+ # Every list that is present must pass (AND across kinds); within a list
471
+ # any entry admits the request (OR within a kind). A locked list plus an
472
+ # unreadable request context refuses: these checks fail closed.
473
+ #
474
+ # @return [ApiKeys::Services::Authenticator::Result, nil] Failure, or nil when the check passes.
475
+ def self.check_request_restrictions(api_key, request, config)
476
+ restrictions = api_key.restrictions
477
+ return nil if restrictions.unrestricted?
478
+
479
+ if restrictions.malformed?
480
+ log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its stored request restrictions are malformed."
481
+ return Result.failure(
482
+ error_code: :restriction_misconfigured,
483
+ message: "This API key's request restrictions could not be verified",
484
+ api_key: api_key
485
+ )
486
+ end
487
+
488
+ unless restrictions.origins.empty?
489
+ origin_host = ApiKeys::Restrictions.extract_origin_host(request)
490
+ unless restrictions.origin_allowed?(origin_host)
491
+ log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request origin is not allowed."
492
+ return Result.failure(
493
+ error_code: :origin_not_allowed,
494
+ message: "This API key is restricted to specific web origins, and this request's origin is not allowed",
495
+ api_key: api_key
496
+ )
497
+ end
498
+ end
499
+
500
+ unless restrictions.ips.empty?
501
+ unless restrictions.ip_allowed?(resolve_client_ip(request, config))
502
+ log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because the request IP address is not allowed."
503
+ return Result.failure(
504
+ error_code: :ip_not_allowed,
505
+ message: "This API key is restricted to specific IP addresses, and this request's address is not allowed",
506
+ api_key: api_key
507
+ )
508
+ end
509
+ end
510
+
511
+ nil # Check passed
512
+ end
513
+
514
+ # Resolves the client IP through the configured resolver. A resolver that
515
+ # blows up yields nil, which an IP-locked key treats as a refusal.
516
+ def self.resolve_client_ip(request, config)
517
+ resolver = config.client_ip_resolver
518
+ return nil unless resolver.respond_to?(:call)
519
+
520
+ resolver.call(request)
521
+ rescue StandardError => error
522
+ log_warn "[ApiKeys Security] Client IP resolution failed (#{error.class}); treating the address as unknown."
523
+ nil
524
+ end
525
+
430
526
  private_class_method :extract_token, :find_and_verify_key, :find_sha256_key,
431
527
  :find_bcrypt_key, :find_bcrypt_key_for_prefixes,
432
528
  :find_bcrypt_key_by_last4, :find_verified_bcrypt_candidate,
@@ -435,9 +531,11 @@ module ApiKeys
435
531
  :safe_cache_read, :safe_cache_write,
436
532
  :normalized_cache_ttl, :cache_identifier?,
437
533
  :safe_find_by_id, :sanitize_prefixes, :valid_token?,
534
+ :acceptable_raw_credential?,
438
535
  :production_environment?, :secure_request?,
439
536
  :check_key_type_configuration, :check_environment_configuration,
440
- :check_environment_isolation
537
+ :check_environment_isolation, :check_request_restrictions,
538
+ :resolve_client_ip
441
539
  end
442
540
  end
443
541
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.4.3"
4
+ VERSION = "0.5.1"
5
5
  end
data/lib/api_keys.rb CHANGED
@@ -54,6 +54,7 @@ end
54
54
  require "api_keys/version"
55
55
  require "api_keys/configuration" # Defines the ApiKeys::Configuration class
56
56
  require "api_keys/errors" # Error classes for key types feature
57
+ require "api_keys/restrictions" # Origin/IP request restrictions value object
57
58
 
58
59
  # Files that might depend on ApiKeys.configuration being available
59
60
  require "api_keys/controller" # This can lead to loading jobs, etc.
@@ -35,7 +35,7 @@ module ApiKeys
35
35
  say " publishable: {"
36
36
  say " prefix: 'pk',"
37
37
  say " permissions: %w[read validate],"
38
- say " revocable: false,"
38
+ say " public: true,"
39
39
  say " limit: 1"
40
40
  say " },"
41
41
  say " secret: {"