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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +402 -13
  4. data/SECURITY.md +7 -6
  5. data/app/controllers/api_keys/keys_controller.rb +73 -46
  6. data/app/views/api_keys/keys/_form.html.erb +56 -10
  7. data/app/views/api_keys/keys/_key_row.html.erb +4 -2
  8. data/app/views/api_keys/keys/_refusal_notice.html.erb +24 -0
  9. data/app/views/api_keys/keys/_show_token.html.erb +4 -7
  10. data/app/views/api_keys/keys/edit.html.erb +2 -0
  11. data/app/views/layouts/api_keys/application.html.erb +6 -0
  12. data/docs/reviews/2026-10-18-0-6-0/01-astra-review-of-0-6-0.md +36 -0
  13. data/docs/reviews/2026-10-18-0-6-0/02-astra-re-review-of-0-6-0.md +34 -0
  14. data/docs/reviews/2026-10-18-0-6-0/03-sol-re-check-of-0-6-0.md +41 -0
  15. data/lib/api_keys/authentication.rb +174 -7
  16. data/lib/api_keys/configuration.rb +82 -0
  17. data/lib/api_keys/device_flow.rb +146 -0
  18. data/lib/api_keys/engine.rb +9 -0
  19. data/lib/api_keys/errors.rb +11 -0
  20. data/lib/api_keys/form_builder_extensions.rb +3 -1
  21. data/lib/api_keys/helpers/token_session.rb +183 -23
  22. data/lib/api_keys/helpers/view_helpers.rb +34 -0
  23. data/lib/api_keys/idempotency.rb +155 -0
  24. data/lib/api_keys/jobs/update_stats_job.rb +45 -1
  25. data/lib/api_keys/models/api_key.rb +261 -3
  26. data/lib/api_keys/models/concerns/has_api_keys.rb +9 -1
  27. data/lib/api_keys/models/device_authorization.rb +333 -0
  28. data/lib/api_keys/models/idempotency_key.rb +184 -0
  29. data/lib/api_keys/version.rb +1 -1
  30. data/lib/api_keys.rb +13 -0
  31. data/lib/generators/api_keys/add_created_by_generator.rb +50 -0
  32. data/lib/generators/api_keys/add_device_authorizations_generator.rb +51 -0
  33. data/lib/generators/api_keys/add_idempotency_keys_generator.rb +52 -0
  34. data/lib/generators/api_keys/add_key_activity_generator.rb +59 -0
  35. data/lib/generators/api_keys/templates/add_created_by_to_api_keys.rb.erb +33 -0
  36. data/lib/generators/api_keys/templates/add_key_activity_to_api_keys.rb.erb +37 -0
  37. data/lib/generators/api_keys/templates/create_api_keys_device_authorizations.rb.erb +70 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_idempotency_keys.rb.erb +52 -0
  39. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +16 -0
  40. data/lib/generators/api_keys/templates/initializer.rb +35 -0
  41. 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
- ApiKeys::Jobs::UpdateStatsJob.perform_later(current_api_key.id, timestamp)
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
- def stats_update_debounced?
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 false if config.track_requests_count
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
- return false unless seconds&.finite? && seconds.positive?
161
- return false unless current_api_key.last_used_at
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
- current_api_key.last_used_at >= Time.current - seconds
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
@@ -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
@@ -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