api_keys 0.5.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +402 -13
- data/SECURITY.md +7 -6
- data/app/controllers/api_keys/keys_controller.rb +73 -46
- data/app/views/api_keys/keys/_form.html.erb +56 -10
- data/app/views/api_keys/keys/_key_row.html.erb +4 -2
- data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
- data/app/views/api_keys/keys/_show_token.html.erb +4 -7
- data/app/views/api_keys/keys/edit.html.erb +2 -0
- data/app/views/layouts/api_keys/application.html.erb +6 -0
- data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
- data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
- data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
- data/lib/api_keys/authentication.rb +174 -7
- data/lib/api_keys/configuration.rb +82 -0
- data/lib/api_keys/device_flow.rb +146 -0
- data/lib/api_keys/engine.rb +9 -0
- data/lib/api_keys/errors.rb +11 -0
- data/lib/api_keys/form_builder_extensions.rb +3 -1
- data/lib/api_keys/helpers/token_session.rb +183 -23
- data/lib/api_keys/helpers/view_helpers.rb +34 -0
- data/lib/api_keys/idempotency.rb +155 -0
- data/lib/api_keys/jobs/update_stats_job.rb +45 -1
- data/lib/api_keys/models/api_key.rb +261 -3
- data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
- data/lib/api_keys/models/device_authorization.rb +333 -0
- data/lib/api_keys/models/idempotency_key.rb +184 -0
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +13 -0
- data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
- data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
- data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
- data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
- data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
- data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
- data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
- data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
- data/lib/generators/api_keys/templates/initializer.rb +35 -0
- metadata +17 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "active_support/cache"
|
|
3
4
|
require "active_support/concern"
|
|
4
5
|
require_relative "services/authenticator"
|
|
5
6
|
require_relative "logging"
|
|
@@ -17,6 +18,13 @@ module ApiKeys
|
|
|
17
18
|
# The key itself is fine, so these answer 403 rather than 401 — the same
|
|
18
19
|
# distinction `:missing_scope` already makes.
|
|
19
20
|
FORBIDDEN_ERROR_CODES = %i[origin_not_allowed ip_not_allowed restriction_misconfigured].freeze
|
|
21
|
+
# Names the per-key reservation that lets one stats job per kind be
|
|
22
|
+
# enqueued within a debounce interval (see reserve_stats_enqueue?).
|
|
23
|
+
STATS_ENQUEUE_NAMESPACE = "api_keys:v1:stats_enqueue"
|
|
24
|
+
# The methods that read. Every other method writes. RFC 9110 §9.2.1 also
|
|
25
|
+
# names OPTIONS and TRACE safe, but an API action does not serve them,
|
|
26
|
+
# so they count as writes and need the stricter scope.
|
|
27
|
+
READ_METHODS = %w[GET HEAD].freeze
|
|
20
28
|
|
|
21
29
|
included do
|
|
22
30
|
# Helper methods to access the authenticated key and its owner
|
|
@@ -49,7 +57,13 @@ module ApiKeys
|
|
|
49
57
|
private
|
|
50
58
|
|
|
51
59
|
# The core authentication method.
|
|
60
|
+
#
|
|
61
|
+
# @param scope [String, Array<String>, Hash, nil] The scope(s) the key
|
|
62
|
+
# needs. A Hash names them by HTTP method, in one line:
|
|
63
|
+
# `scope: { read: "read", write: "write" }` asks GET and HEAD for the
|
|
64
|
+
# read scope and every other method for the write scope.
|
|
52
65
|
def authenticate_api_key!(scope: nil)
|
|
66
|
+
scope = scope_for_request_method(scope) if scope.is_a?(Hash)
|
|
53
67
|
@current_api_key = nil
|
|
54
68
|
remove_instance_variable(:@current_api_tenant) if instance_variable_defined?(:@current_api_tenant)
|
|
55
69
|
log_debug "[ApiKeys Auth] authenticate_api_key! started for request: #{request.uuid}"
|
|
@@ -78,6 +92,7 @@ module ApiKeys
|
|
|
78
92
|
after_auth_context[:required_scope_check] = { required: scope, passed: false }
|
|
79
93
|
after_auth_context[:success] = false
|
|
80
94
|
after_auth_context[:error_code] = :missing_scope
|
|
95
|
+
refused_key = @current_api_key
|
|
81
96
|
@current_api_key = nil
|
|
82
97
|
render_unauthorized(
|
|
83
98
|
error_code: :missing_scope,
|
|
@@ -85,6 +100,20 @@ module ApiKeys
|
|
|
85
100
|
status: :forbidden,
|
|
86
101
|
required_scope: scope
|
|
87
102
|
)
|
|
103
|
+
record_key_refusal(refused_key, :missing_scope)
|
|
104
|
+
elsif !api_key_trusted?(@current_api_key)
|
|
105
|
+
log_debug "[ApiKeys Auth] Trust check failed for key ID #{@current_api_key.id}."
|
|
106
|
+
after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
|
|
107
|
+
after_auth_context[:success] = false
|
|
108
|
+
after_auth_context[:error_code] = :untrusted_key
|
|
109
|
+
refused_key = @current_api_key
|
|
110
|
+
@current_api_key = nil
|
|
111
|
+
render_unauthorized(
|
|
112
|
+
error_code: :untrusted_key,
|
|
113
|
+
message: "API key is no longer trusted: the person behind it no longer stands behind it",
|
|
114
|
+
status: :forbidden
|
|
115
|
+
)
|
|
116
|
+
record_key_refusal(refused_key, :untrusted_key)
|
|
88
117
|
else
|
|
89
118
|
after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
|
|
90
119
|
# Authentication and scope check successful, enqueue stats update
|
|
@@ -95,6 +124,8 @@ module ApiKeys
|
|
|
95
124
|
log_debug "[ApiKeys Auth] Authentication failed. Error: #{result.error_code}, Message: #{result.message}"
|
|
96
125
|
status = FORBIDDEN_ERROR_CODES.include?(result.error_code) ? :forbidden : :unauthorized
|
|
97
126
|
render_unauthorized(error_code: result.error_code, message: result.message, status: status)
|
|
127
|
+
# Only identified refusals carry a key; lookup failures have none to stamp.
|
|
128
|
+
record_key_refusal(result.api_key, result.error_code) if result.api_key
|
|
98
129
|
end
|
|
99
130
|
|
|
100
131
|
# Enqueue after_authentication callback asynchronously regardless of success/failure
|
|
@@ -115,7 +146,35 @@ module ApiKeys
|
|
|
115
146
|
end
|
|
116
147
|
end
|
|
117
148
|
|
|
149
|
+
# Whether this request reads (GET or HEAD).
|
|
150
|
+
def reading_request?
|
|
151
|
+
READ_METHODS.include?(request.request_method)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# The scope of `scope: { read:, write: }` for this request's method. Both
|
|
155
|
+
# are required, so a forgotten one never leaves its requests unchecked.
|
|
156
|
+
# A value of nil asks for no scope.
|
|
157
|
+
def scope_for_request_method(scopes)
|
|
158
|
+
scopes = scopes.to_h.transform_keys(&:to_sym)
|
|
159
|
+
unless scopes.keys.sort == %i[read write]
|
|
160
|
+
raise ArgumentError, "scope: by HTTP method takes exactly { read: ..., write: ... }"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
reading_request? ? scopes[:read] : scopes[:write]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Whether the person behind the key still stands behind it, as the host's
|
|
167
|
+
# `config.trusted_key_check` answers. Without a check, every valid key is
|
|
168
|
+
# trusted. An error in the check is not rescued: the request fails, and
|
|
169
|
+
# never passes as trusted.
|
|
170
|
+
def api_key_trusted?(api_key)
|
|
171
|
+
check = ApiKeys.configuration.trusted_key_check
|
|
172
|
+
check.nil? || check.call(api_key) ? true : false
|
|
173
|
+
end
|
|
174
|
+
|
|
118
175
|
# Renders a standard JSON error response for authentication failures.
|
|
176
|
+
# Every refusal of the gem renders here, so a host that overrides it
|
|
177
|
+
# renders them all in its own error shape.
|
|
119
178
|
def render_unauthorized(error_code:, message:, status: :unauthorized, required_scope: nil)
|
|
120
179
|
error_message = I18n.t("api_keys.errors.#{error_code}", default: message) rescue message
|
|
121
180
|
response_body = { error: error_code, message: error_message }
|
|
@@ -129,10 +188,14 @@ module ApiKeys
|
|
|
129
188
|
return unless ApiKeys.configuration.enable_async_operations
|
|
130
189
|
return unless current_api_key
|
|
131
190
|
|
|
132
|
-
if stats_update_debounced?
|
|
191
|
+
if stats_update_debounced?(current_api_key.last_used_at)
|
|
133
192
|
log_debug "[ApiKeys Auth] Skipping a recently recorded last-used update for ApiKey ID: #{current_api_key.id}"
|
|
134
193
|
return
|
|
135
194
|
end
|
|
195
|
+
unless reserve_stats_enqueue?(:used, current_api_key.id)
|
|
196
|
+
log_debug "[ApiKeys Auth] Skipping a last-used update already enqueued for ApiKey ID: #{current_api_key.id}"
|
|
197
|
+
return
|
|
198
|
+
end
|
|
136
199
|
|
|
137
200
|
# Check ActiveJob configuration and warn if using suboptimal adapters
|
|
138
201
|
adapter = ActiveJob::Base.queue_adapter
|
|
@@ -145,22 +208,126 @@ module ApiKeys
|
|
|
145
208
|
begin
|
|
146
209
|
timestamp = Time.current # Capture time once for the job
|
|
147
210
|
log_debug "[ApiKeys Auth] Enqueuing UpdateStatsJob for ApiKey ID: #{current_api_key.id} at #{timestamp}"
|
|
148
|
-
|
|
211
|
+
# Active Job answers false (instead of raising) when the adapter
|
|
212
|
+
# failed to enqueue; either way the reservation is freed.
|
|
213
|
+
unless ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
|
|
214
|
+
release_stats_enqueue(:used, current_api_key.id)
|
|
215
|
+
log_error "[ApiKeys Auth] UpdateStatsJob was not enqueued for key #{current_api_key.id}."
|
|
216
|
+
end
|
|
149
217
|
rescue StandardError => error
|
|
218
|
+
release_stats_enqueue(:used, current_api_key.id)
|
|
150
219
|
log_error "[ApiKeys Auth] Failed to enqueue UpdateStatsJob for key #{current_api_key.id} (#{error.class})."
|
|
151
220
|
end
|
|
152
221
|
end
|
|
153
222
|
|
|
154
|
-
|
|
223
|
+
# Enqueues the refusal half of UpdateStatsJob for a key that presented its
|
|
224
|
+
# real token and was refused by a policy. It moves only last_refused_at,
|
|
225
|
+
# last_refusal_code and (with track_requests_count) refusals_count; a
|
|
226
|
+
# refusal never counts as a use. Debounced exactly like last_used_at, so a
|
|
227
|
+
# client hammering a refused key costs no more writes than a working one.
|
|
228
|
+
# Never raises: the refusal response has already been rendered.
|
|
229
|
+
def record_key_refusal(api_key, error_code)
|
|
230
|
+
return unless ApiKeys.configuration.enable_async_operations
|
|
231
|
+
return unless api_key&.id
|
|
232
|
+
|
|
233
|
+
code = error_code.to_s
|
|
234
|
+
return unless ApiKeys::ApiKey::REFUSAL_CODES.include?(code)
|
|
235
|
+
return unless ApiKeys::ApiKey.refusal_stats_columns?
|
|
236
|
+
|
|
237
|
+
if stats_update_debounced?(api_key.last_refused_at)
|
|
238
|
+
log_debug "[ApiKeys Auth] Skipping a recently recorded refusal update for ApiKey ID: #{api_key.id}"
|
|
239
|
+
return
|
|
240
|
+
end
|
|
241
|
+
unless reserve_stats_enqueue?(:refused, api_key.id)
|
|
242
|
+
log_debug "[ApiKeys Auth] Skipping a refusal update already enqueued for ApiKey ID: #{api_key.id}"
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
begin
|
|
247
|
+
# Active Job answers false (instead of raising) when the adapter
|
|
248
|
+
# failed to enqueue; either way the reservation is freed.
|
|
249
|
+
unless ApiKeys::Jobs::UpdateStatsJob.perform_later(api_key.id, Time.current, refusal_code: code)
|
|
250
|
+
release_stats_enqueue(:refused, api_key.id)
|
|
251
|
+
log_error "[ApiKeys Auth] A refusal stats update was not enqueued for key #{api_key.id}."
|
|
252
|
+
end
|
|
253
|
+
rescue StandardError
|
|
254
|
+
release_stats_enqueue(:refused, api_key.id)
|
|
255
|
+
raise
|
|
256
|
+
end
|
|
257
|
+
rescue StandardError => error
|
|
258
|
+
log_error "[ApiKeys Auth] Failed to enqueue a refusal stats update for key #{api_key&.id} (#{error.class})."
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def stats_update_debounced?(last_recorded_at)
|
|
262
|
+
seconds = stats_debounce_seconds
|
|
263
|
+
return false unless seconds && last_recorded_at
|
|
264
|
+
|
|
265
|
+
last_recorded_at >= Time.current - seconds
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# The debounce interval in seconds, or nil when every request must be
|
|
269
|
+
# recorded (exact counting with track_requests_count, or no interval).
|
|
270
|
+
def stats_debounce_seconds
|
|
155
271
|
config = ApiKeys.configuration
|
|
156
|
-
return
|
|
272
|
+
return nil if config.track_requests_count
|
|
157
273
|
|
|
158
274
|
interval = config.stats_update_interval
|
|
159
275
|
seconds = interval.to_f if interval.respond_to?(:to_f)
|
|
160
|
-
|
|
161
|
-
|
|
276
|
+
seconds&.finite? && seconds.positive? ? seconds : nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# The persisted timestamp only debounces once a worker has written it, so
|
|
280
|
+
# a burst of requests arriving before that would each enqueue a job. With
|
|
281
|
+
# a shared cache, the first request in an interval reserves the enqueue
|
|
282
|
+
# for its key and kind (:used or :refused) atomically, with
|
|
283
|
+
# `write(..., unless_exist: true, expires_in: interval)`, and the rest of
|
|
284
|
+
# the burst is skipped.
|
|
285
|
+
#
|
|
286
|
+
# A request is skipped only when the reservation is confirmed to exist:
|
|
287
|
+
# the write answered false (not stored) and the key is then present.
|
|
288
|
+
# Stores also answer false or nil when the write itself failed (Redis
|
|
289
|
+
# returns nil on a handled error, Memcached false), and suppressing the
|
|
290
|
+
# job then would lose statistics silently. So a nil answer, an absent
|
|
291
|
+
# key, or any error enqueues. It also enqueues when debouncing does not
|
|
292
|
+
# apply or there is no shared cache (`NullStore`, or no Rails.cache):
|
|
293
|
+
# the persisted timestamp is then the only debounce, as before 0.6.0.
|
|
294
|
+
def reserve_stats_enqueue?(kind, api_key_id)
|
|
295
|
+
seconds = stats_debounce_seconds
|
|
296
|
+
return true unless seconds
|
|
297
|
+
|
|
298
|
+
cache = stats_enqueue_cache
|
|
299
|
+
return true unless cache
|
|
300
|
+
|
|
301
|
+
key = stats_enqueue_key(kind, api_key_id)
|
|
302
|
+
written = cache.write(key, true, unless_exist: true, expires_in: seconds)
|
|
303
|
+
return true unless written == false
|
|
304
|
+
|
|
305
|
+
cache.exist?(key) ? false : true
|
|
306
|
+
rescue StandardError => error
|
|
307
|
+
log_debug "[ApiKeys Auth] Stats enqueue reservation unavailable (#{error.class}); relying on the persisted timestamp."
|
|
308
|
+
true
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Frees a reservation whose job could not be enqueued, so the next
|
|
312
|
+
# request can try again.
|
|
313
|
+
def release_stats_enqueue(kind, api_key_id)
|
|
314
|
+
return unless stats_debounce_seconds
|
|
315
|
+
|
|
316
|
+
stats_enqueue_cache&.delete(stats_enqueue_key(kind, api_key_id))
|
|
317
|
+
rescue StandardError
|
|
318
|
+
nil
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def stats_enqueue_key(kind, api_key_id)
|
|
322
|
+
"#{STATS_ENQUEUE_NAMESPACE}:#{kind}:#{api_key_id}"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# A cache every process shares can deduplicate; NullStore remembers nothing.
|
|
326
|
+
def stats_enqueue_cache
|
|
327
|
+
cache = Rails.cache if defined?(Rails) && Rails.respond_to?(:cache)
|
|
328
|
+
return nil if cache.nil? || cache.is_a?(ActiveSupport::Cache::NullStore)
|
|
162
329
|
|
|
163
|
-
|
|
330
|
+
cache
|
|
164
331
|
end
|
|
165
332
|
|
|
166
333
|
# Helper to safely enqueue callback jobs.
|
|
@@ -63,6 +63,31 @@ module ApiKeys
|
|
|
63
63
|
# Tenant Resolution
|
|
64
64
|
attr_reader :tenant_resolver
|
|
65
65
|
|
|
66
|
+
# The person behind a key
|
|
67
|
+
#
|
|
68
|
+
# @!attribute [rw] trusted_key_check
|
|
69
|
+
# @return [#call, nil] Callable receiving the authenticated ApiKey and
|
|
70
|
+
# answering whether the person behind it still stands behind it. When
|
|
71
|
+
# it answers false or nil, `authenticate_api_key!` refuses the request
|
|
72
|
+
# with 403 `untrusted_key`. It runs after the scope check. nil (the
|
|
73
|
+
# default) trusts every valid key.
|
|
74
|
+
# @example A key acts while its creator still manages the organization
|
|
75
|
+
# config.trusted_key_check = ->(api_key) { api_key.owner.managed_by?(api_key.created_by) }
|
|
76
|
+
attr_reader :trusted_key_check
|
|
77
|
+
|
|
78
|
+
# Idempotency-Key, for tests only
|
|
79
|
+
#
|
|
80
|
+
# @!attribute [rw] idempotency_in_test_transactions
|
|
81
|
+
# @return [Boolean] Lets ApiKeys::IdempotencyKey.hold run inside an open
|
|
82
|
+
# database transaction, as transactional tests open one around each
|
|
83
|
+
# test. There the key's claim is a savepoint that the test's rollback
|
|
84
|
+
# removes. It takes true only when Rails.env is test, and it counts
|
|
85
|
+
# only while Rails.env is test.
|
|
86
|
+
# Default: false.
|
|
87
|
+
# @example config/environments/test.rb, or the test helper
|
|
88
|
+
# ApiKeys.configuration.idempotency_in_test_transactions = true
|
|
89
|
+
attr_reader :idempotency_in_test_transactions
|
|
90
|
+
|
|
66
91
|
# Callbacks (Placeholders for future extension)
|
|
67
92
|
attr_reader :before_authentication, :after_authentication
|
|
68
93
|
|
|
@@ -107,6 +132,13 @@ module ApiKeys
|
|
|
107
132
|
# - :restrictions [Array<Symbol>] Which request-restriction kinds keys of this
|
|
108
133
|
# type may carry: any subset of [:origins, :ips]. Omitted means both are
|
|
109
134
|
# allowed; `[]` forbids restrictions entirely for this type.
|
|
135
|
+
# - :scopes [Symbol] Where scope checks for keys of this type look.
|
|
136
|
+
# `:stored` (default) checks the scopes each key stored at creation,
|
|
137
|
+
# capped by the type's permissions. `:ceiling` makes the type's live
|
|
138
|
+
# `permissions` list the contract: every key of the type is authorized
|
|
139
|
+
# for exactly that list, so widening or narrowing it applies to existing
|
|
140
|
+
# keys immediately, and stored scopes become metadata. `:ceiling`
|
|
141
|
+
# requires a finite, non-empty permissions list (never `:all`).
|
|
110
142
|
# @example
|
|
111
143
|
# config.key_types = {
|
|
112
144
|
# publishable: { prefix: "pk", permissions: %w[read], public: true, limit: 1,
|
|
@@ -147,6 +179,9 @@ module ApiKeys
|
|
|
147
179
|
VALID_HASH_STRATEGIES = %i[sha256 bcrypt].freeze
|
|
148
180
|
VALID_TOKEN_ALPHABETS = %i[base58 hex].freeze
|
|
149
181
|
VALID_DASHBOARD_CONTENT_SECURITY_POLICIES = [:default, :strict, false, nil].freeze
|
|
182
|
+
# Where a key type's scope checks look: the scopes each key stored at
|
|
183
|
+
# creation (default), or the type's live permission ceiling.
|
|
184
|
+
KEY_TYPE_SCOPE_SOURCES = %w[stored ceiling].freeze
|
|
150
185
|
TOKEN_LENGTH_RANGE = (16..64)
|
|
151
186
|
MAX_CONFIGURED_SCOPES = 100
|
|
152
187
|
CONFIG_NAME_PATTERN = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
@@ -264,6 +299,27 @@ module ApiKeys
|
|
|
264
299
|
@tenant_resolver = value
|
|
265
300
|
end
|
|
266
301
|
|
|
302
|
+
def idempotency_in_test_transactions=(value)
|
|
303
|
+
raise ArgumentError, "idempotency_in_test_transactions must be true or false" unless [true, false].include?(value)
|
|
304
|
+
if value && !test_environment?
|
|
305
|
+
raise ArgumentError, "idempotency_in_test_transactions is for transactional tests only: Rails.env must be test"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
@idempotency_in_test_transactions = value
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Whether hold may run inside an open transaction: the switch is on, and
|
|
312
|
+
# Rails.env is still test when it is used.
|
|
313
|
+
def idempotency_in_test_transactions?
|
|
314
|
+
@idempotency_in_test_transactions == true && test_environment?
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def trusted_key_check=(value)
|
|
318
|
+
raise ArgumentError, "trusted_key_check must be callable or nil" unless value.nil? || value.respond_to?(:call)
|
|
319
|
+
|
|
320
|
+
@trusted_key_check = value
|
|
321
|
+
end
|
|
322
|
+
|
|
267
323
|
def client_ip_resolver=(value)
|
|
268
324
|
raise ArgumentError, "client_ip_resolver must be callable" unless value.respond_to?(:call)
|
|
269
325
|
|
|
@@ -392,6 +448,10 @@ module ApiKeys
|
|
|
392
448
|
|
|
393
449
|
private
|
|
394
450
|
|
|
451
|
+
def test_environment?
|
|
452
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test? ? true : false
|
|
453
|
+
end
|
|
454
|
+
|
|
395
455
|
def validate_resolved_prefix!(prefix)
|
|
396
456
|
valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
|
|
397
457
|
prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
@@ -434,6 +494,7 @@ module ApiKeys
|
|
|
434
494
|
end
|
|
435
495
|
|
|
436
496
|
validate_restriction_kinds!(name, type_config[:restrictions]) if type_config.key?(:restrictions)
|
|
497
|
+
validate_scope_source!(name, type_config[:scopes], permissions) if type_config.key?(:scopes)
|
|
437
498
|
|
|
438
499
|
next unless type_config[:public] == true
|
|
439
500
|
|
|
@@ -457,6 +518,21 @@ module ApiKeys
|
|
|
457
518
|
"Key type '#{name}' restrictions must be an Array containing any of: #{ApiKeys::Restrictions::KIND_NAMES.join(', ')}"
|
|
458
519
|
end
|
|
459
520
|
|
|
521
|
+
# `scopes: :ceiling` makes the type's permission list authoritative for every
|
|
522
|
+
# existing key of the type. Against `:all` (or an empty list) that would turn
|
|
523
|
+
# each key into an unbounded or useless credential, so only a finite,
|
|
524
|
+
# non-empty ceiling may be the contract.
|
|
525
|
+
def validate_scope_source!(name, source, permissions)
|
|
526
|
+
unless (source.is_a?(Symbol) || source.is_a?(String)) && KEY_TYPE_SCOPE_SOURCES.include?(source.to_s)
|
|
527
|
+
raise ArgumentError, "Key type '#{name}' scopes must be :stored or :ceiling"
|
|
528
|
+
end
|
|
529
|
+
return unless source.to_s == "ceiling"
|
|
530
|
+
return if permissions.is_a?(Array) && permissions.any?
|
|
531
|
+
|
|
532
|
+
raise ArgumentError,
|
|
533
|
+
"Key type '#{name}' uses scopes: :ceiling, so its permissions must be a finite, non-empty list (never :all)"
|
|
534
|
+
end
|
|
535
|
+
|
|
460
536
|
def validate_config_name!(value, label)
|
|
461
537
|
return if valid_config_name?(value)
|
|
462
538
|
|
|
@@ -631,6 +707,12 @@ module ApiKeys
|
|
|
631
707
|
# Tenant Resolution
|
|
632
708
|
@tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
|
|
633
709
|
|
|
710
|
+
# The person behind a key: no check, every valid key is trusted
|
|
711
|
+
@trusted_key_check = nil
|
|
712
|
+
|
|
713
|
+
# Idempotency-Key: refuse every open transaction (tests may opt out)
|
|
714
|
+
@idempotency_in_test_transactions = false
|
|
715
|
+
|
|
634
716
|
# Key Types & Environments (default to empty/disabled for backwards compatibility)
|
|
635
717
|
@key_types = {}.freeze # Empty = feature disabled, legacy behavior
|
|
636
718
|
@environments = {}.freeze # Empty = no environment-based prefixes
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/concern"
|
|
4
|
+
|
|
5
|
+
module ApiKeys
|
|
6
|
+
# The two endpoints of the OAuth 2.0 Device Authorization Grant (RFC 8628)
|
|
7
|
+
# for an API controller of the host app: `create` is the device
|
|
8
|
+
# authorization endpoint (§3.1, §3.2), and `token` is the token endpoint
|
|
9
|
+
# that the client polls (§3.4, §3.5). The host routes them and names the
|
|
10
|
+
# page where a person approves (ApiKeys::DeviceAuthorization#approve!):
|
|
11
|
+
#
|
|
12
|
+
# class Api::DeviceAuthorizationsController < ActionController::API
|
|
13
|
+
# include ApiKeys::DeviceFlow
|
|
14
|
+
#
|
|
15
|
+
# private
|
|
16
|
+
#
|
|
17
|
+
# def device_verification_uri(user_code = nil)
|
|
18
|
+
# device_approval_url(code: user_code)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# post "api/device", to: "api/device_authorizations#create"
|
|
23
|
+
# post "api/token", to: "api/device_authorizations#token"
|
|
24
|
+
#
|
|
25
|
+
# Neither request carries a key. Errors follow RFC 6749 §5.2
|
|
26
|
+
# (`{ error, error_description }`, status 400), as OAuth device clients
|
|
27
|
+
# expect, and no answer may be cached (RFC 6749 §5.1).
|
|
28
|
+
module DeviceFlow
|
|
29
|
+
extend ActiveSupport::Concern
|
|
30
|
+
|
|
31
|
+
GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code" # RFC 8628 §3.4
|
|
32
|
+
|
|
33
|
+
# Override any of them with I18n: api_keys.device_flow.errors.<error>.
|
|
34
|
+
ERROR_DESCRIPTIONS = {
|
|
35
|
+
"authorization_pending" => "The person has not decided yet. Poll again after the interval.",
|
|
36
|
+
"slow_down" => "Polled too soon. Wait 5 seconds longer between polls from now on.",
|
|
37
|
+
"access_denied" => "The person denied the request.",
|
|
38
|
+
"expired_token" => "The codes expired. Ask for new ones.",
|
|
39
|
+
"invalid_grant" => "This device code is unknown, was used already, or belongs to another client_id.",
|
|
40
|
+
"unsupported_grant_type" => "Send grant_type=#{GRANT_TYPE}.",
|
|
41
|
+
"invalid_scope" => "Ask for scopes among: %{scopes}.",
|
|
42
|
+
# The token endpoint's invalid_scope (RFC 6749 §5.2: "exceeds the scope
|
|
43
|
+
# granted by the resource owner").
|
|
44
|
+
"scope_exceeds_approval" => "The app can no longer make the key the person approved. Ask for new codes.",
|
|
45
|
+
"invalid_request" => "Send client_id, the client's name, and device_name, the device's name: " \
|
|
46
|
+
"at most #{ApiKeys::DeviceAuthorization::MAX_NAME_LENGTH} characters each."
|
|
47
|
+
}.freeze
|
|
48
|
+
|
|
49
|
+
# RFC 8628 §3.1 and §3.2. `scope` lists the scopes, space-separated
|
|
50
|
+
# (RFC 6749 §3.3). A client may ask for any of `config.default_scopes`
|
|
51
|
+
# that a key of `device_key_type` grants. A type whose scopes follow
|
|
52
|
+
# its ceiling grants the whole ceiling, so the request asks for all of it.
|
|
53
|
+
def create
|
|
54
|
+
key_type = device_key_type
|
|
55
|
+
ApiKeys::DeviceAuthorization.check_key_type!(key_type)
|
|
56
|
+
scopes = params.fetch(:scope) { device_default_scope }.to_s.split.uniq
|
|
57
|
+
if scopes.empty? || (scopes - ApiKeys::DeviceAuthorization.grantable_scopes(key_type)).any?
|
|
58
|
+
return render_oauth_error("invalid_scope")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
authorization = ApiKeys::DeviceAuthorization.request!(
|
|
62
|
+
client_id: params[:client_id].to_s.strip, device_name: params[:device_name].to_s.strip.presence,
|
|
63
|
+
scopes: scopes, key_type: key_type, requested_from: ApiKeys.configuration.client_ip_resolver.call(request)
|
|
64
|
+
)
|
|
65
|
+
render_without_cache json: {
|
|
66
|
+
device_code: authorization.device_code, user_code: authorization.user_code,
|
|
67
|
+
verification_uri: device_verification_uri,
|
|
68
|
+
verification_uri_complete: device_verification_uri(authorization.user_code),
|
|
69
|
+
expires_in: ApiKeys::DeviceAuthorization::LIFETIME.to_i, interval: authorization.interval
|
|
70
|
+
}
|
|
71
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
72
|
+
render_oauth_error(error.record.errors.include?(:scopes) ? "invalid_scope" : "invalid_request")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# RFC 8628 §3.4 and §3.5. The answer that carries the token is the only
|
|
76
|
+
# one there is. A poll from another client_id changes nothing, so the
|
|
77
|
+
# grant waits for its own client.
|
|
78
|
+
def token
|
|
79
|
+
return render_oauth_error("unsupported_grant_type") unless params[:grant_type] == GRANT_TYPE
|
|
80
|
+
|
|
81
|
+
authorization = ApiKeys::DeviceAuthorization.find_by_device_code(params[:device_code])
|
|
82
|
+
return render_oauth_error("invalid_grant") unless authorization
|
|
83
|
+
|
|
84
|
+
poll = nil
|
|
85
|
+
body = ApiKeys::DeviceAuthorization.transaction do
|
|
86
|
+
poll = authorization.poll!(client_id: params[:client_id])
|
|
87
|
+
device_token_response(authorization, poll.api_key) if poll.api_key
|
|
88
|
+
end
|
|
89
|
+
if poll.error == "invalid_scope"
|
|
90
|
+
return render_oauth_error("invalid_scope", oauth_error_description("scope_exceeds_approval"))
|
|
91
|
+
end
|
|
92
|
+
return render_oauth_error(poll.error) if poll.error
|
|
93
|
+
|
|
94
|
+
render_without_cache json: body
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
# The approval page of the host app, with the user code filled in when
|
|
100
|
+
# one is given (`verification_uri_complete`, RFC 8628 §3.3.1).
|
|
101
|
+
def device_verification_uri(_user_code = nil)
|
|
102
|
+
raise NotImplementedError,
|
|
103
|
+
"#{self.class.name} must define device_verification_uri(user_code = nil): the URL of its approval page"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The type of the keys this flow makes. With key types configured, it
|
|
107
|
+
# must name one; the default is `config.default_key_type`.
|
|
108
|
+
def device_key_type
|
|
109
|
+
ApiKeys.configuration.default_key_type
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The scope a request without `scope` asks for, space-separated. With
|
|
113
|
+
# nil (the default) such a request fails with invalid_scope, as RFC 6749
|
|
114
|
+
# §3.3 allows.
|
|
115
|
+
def device_default_scope
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# The token response (RFC 6749 §5.1). It runs in the transaction that
|
|
120
|
+
# creates the key, so a row it writes (an audit entry) commits with the
|
|
121
|
+
# key, or neither does. Override it to add fields, and call super.
|
|
122
|
+
def device_token_response(_authorization, api_key)
|
|
123
|
+
{ access_token: api_key.token, token_type: "Bearer", scope: api_key.scopes.join(" ") }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# An error response (RFC 6749 §5.2). A rate limit's `with:` can use it:
|
|
127
|
+
# rate_limit to: 20, within: 1.hour, only: :create,
|
|
128
|
+
# with: -> { render_oauth_error("slow_down", "Too many requests.", status: :too_many_requests) }
|
|
129
|
+
def render_oauth_error(error, description = nil, status: :bad_request)
|
|
130
|
+
description ||= oauth_error_description(error)
|
|
131
|
+
render_without_cache json: { error: error, error_description: description.presence }.compact, status: status
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def oauth_error_description(name)
|
|
135
|
+
scopes = ApiKeys::DeviceAuthorization.grantable_scopes(device_key_type).join(" ")
|
|
136
|
+
I18n.t("api_keys.device_flow.errors.#{name}", default: ERROR_DESCRIPTIONS[name].to_s, scopes: scopes)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# RFC 6749 §5.1: an answer that carries a credential is never cached.
|
|
140
|
+
def render_without_cache(**options)
|
|
141
|
+
response.headers["Cache-Control"] = "no-store"
|
|
142
|
+
response.headers["Pragma"] = "no-cache"
|
|
143
|
+
render(**options)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
data/lib/api_keys/engine.rb
CHANGED
|
@@ -14,6 +14,15 @@ module ApiKeys
|
|
|
14
14
|
# value takes precedence over this fallback.
|
|
15
15
|
config.parent_controller = nil
|
|
16
16
|
|
|
17
|
+
# The device flow's codes are in its requests: a device code redeems a
|
|
18
|
+
# key, and a user code lets a person approve one. Request logs show
|
|
19
|
+
# neither, nor a token (RFC 8628 §5.1, RFC 6749 §10.3).
|
|
20
|
+
FILTERED_PARAMETERS = %i[device_code user_code token].freeze
|
|
21
|
+
|
|
22
|
+
initializer "api_keys.filter_parameters" do |app|
|
|
23
|
+
app.config.filter_parameters |= FILTERED_PARAMETERS
|
|
24
|
+
end
|
|
25
|
+
|
|
17
26
|
# Add has_api_keys method to ActiveRecord::Base
|
|
18
27
|
initializer "api_keys.active_record" do
|
|
19
28
|
ActiveSupport.on_load(:active_record) do
|
data/lib/api_keys/errors.rb
CHANGED
|
@@ -80,5 +80,16 @@ module ApiKeys
|
|
|
80
80
|
)
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
|
+
|
|
84
|
+
# Raised when a key is given a `created_by` but the columns are missing
|
|
85
|
+
class CreatedByMigrationRequiredError < BaseError
|
|
86
|
+
def initialize(message = nil)
|
|
87
|
+
super(
|
|
88
|
+
message ||
|
|
89
|
+
"A key was given a created_by, but the created_by columns are missing from api_keys. " \
|
|
90
|
+
"Run: rails generate api_keys:add_created_by && rails db:migrate"
|
|
91
|
+
)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
83
94
|
end
|
|
84
95
|
end
|
|
@@ -71,7 +71,9 @@ module ApiKeys
|
|
|
71
71
|
# scopes while an update would keep the old ones. With the sentinel the
|
|
72
72
|
# server receives `[""]`, which `ApiKey#scopes=` and `create_api_key!`
|
|
73
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
|
|
74
|
+
# render a box for every scope the key may hold, or save it through
|
|
75
|
+
# `ApiKey#scopes_after_form_edit(offered:, checked:)`, which edits only
|
|
76
|
+
# the offered scopes and keeps the rest.
|
|
75
77
|
# - nil (default): render it when there is at least one box and the
|
|
76
78
|
# form's key enforces its scopes (`ApiKey#scopes_enforced?`). Elsewhere
|
|
77
79
|
# an empty list would mean "unrestricted" and clearing the boxes would
|