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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -0
- data/README.md +195 -27
- data/SECURITY.md +21 -2
- data/app/controllers/api_keys/keys_controller.rb +137 -11
- data/app/views/api_keys/keys/_form.html.erb +80 -17
- data/app/views/api_keys/keys/_key_badges.html.erb +7 -0
- data/app/views/api_keys/keys/_restriction_fields.html.erb +34 -0
- data/app/views/api_keys/security/best_practices.html.erb +1 -1
- data/app/views/layouts/api_keys/application.html.erb +9 -1
- data/docs/reviews/2026-10-18-0-5-1/01-astra-review-of-0-5-0.md +55 -0
- data/docs/reviews/2026-10-18-0-5-1/02-astra-re-review-of-0-5-1.md +36 -0
- data/docs/reviews/2026-10-18-0-5-1/03-opus-review-of-0-5-1.md +5 -0
- data/lib/api_keys/authentication.rb +7 -1
- data/lib/api_keys/configuration.rb +54 -8
- data/lib/api_keys/engine.rb +6 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +40 -3
- data/lib/api_keys/helpers/token_session.rb +92 -3
- data/lib/api_keys/models/api_key.rb +240 -11
- data/lib/api_keys/models/concerns/has_api_keys.rb +67 -10
- data/lib/api_keys/restrictions.rb +430 -0
- data/lib/api_keys/services/authenticator.rb +110 -12
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +1 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +1 -1
- data/lib/generators/api_keys/add_restrictions_generator.rb +60 -0
- data/lib/generators/api_keys/templates/add_restrictions_to_api_keys.rb.erb +54 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +22 -0
- data/lib/generators/api_keys/templates/initializer.rb +49 -6
- 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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
data/lib/api_keys/version.rb
CHANGED
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.
|