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
|
@@ -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
|
|
85
|
-
#
|
|
86
|
-
#
|
|
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],
|
|
90
|
-
#
|
|
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
|
data/lib/api_keys/engine.rb
CHANGED
|
@@ -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
|
data/lib/api_keys/errors.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|