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
@@ -0,0 +1,333 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "digest"
5
+ require "securerandom"
6
+
7
+ module ApiKeys
8
+ # A client's request for an API key on the OAuth 2.0 Device Authorization
9
+ # Grant (RFC 8628, https://www.rfc-editor.org/rfc/rfc8628):
10
+ #
11
+ # 1. The client (an agent, a CLI) asks for a device code and a user code
12
+ # and shows the person one link (`request!`, RFC 8628 §3.1, §3.2).
13
+ # 2. The person opens the link in the host app, compares the user code,
14
+ # sees the client's name, its device and the scopes, and approves or
15
+ # denies (`approve!`, `deny!`, §3.3).
16
+ # 3. The client polls. The first poll after the approval creates an
17
+ # ordinary API key for the owner and answers with its token, once
18
+ # (`poll!`, §3.4, §3.5).
19
+ #
20
+ # Both codes are kept as SHA-256 digests. The token is never stored: the
21
+ # poll that creates the key is the only answer that carries its text.
22
+ class DeviceAuthorization < ActiveRecord::Base
23
+ self.table_name = "api_keys_device_authorizations"
24
+
25
+ LIFETIME = 10.minutes
26
+ INTERVAL = 5 # seconds between polls: the RFC 8628 §3.2 default
27
+ SLOW_DOWN = 5 # "the interval MUST be increased by 5 seconds" (§3.5)
28
+ # Eight letters from twenty consonants: about 34.5 bits, and no word can
29
+ # be spelled (§6.1). Shown as BCDF-GHJK.
30
+ USER_CODE_ALPHABET = "BCDFGHJKLMNPQRSTVWXZ".chars.freeze
31
+ USER_CODE_LENGTH = 8
32
+ MAX_NAME_LENGTH = 60 # for client_id and device_name each
33
+ # `void`: approved, but the app's configuration changed so that the key
34
+ # can no longer be made as the person approved it. The request is closed.
35
+ STATES = %w[pending approved denied completed void].freeze
36
+
37
+ # Raised when a request that was decided already, or that expired, is
38
+ # approved or denied.
39
+ class Closed < ApiKeys::Error; end
40
+
41
+ # Raised by `approve!` when the owner may not have another key. The
42
+ # request stays pending, so the person can revoke a key and approve again
43
+ # before it expires.
44
+ class KeyLimitReached < ApiKeys::Error
45
+ def initialize(message = "this owner has the most API keys it may have: revoke a key, then approve again")
46
+ super
47
+ end
48
+ end
49
+
50
+ # The answer to a void request: RFC 6749 §5.2 "invalid_scope ... exceeds
51
+ # the scope granted by the resource owner". The app can no longer make
52
+ # the key with the scopes and the type the person approved. It is not
53
+ # access_denied: the person denied nothing. Clients stop on it.
54
+ VOID_ERROR = "invalid_scope"
55
+
56
+ # What `create_api_key!` raises when it cannot make the key.
57
+ KEY_CREATION_ERRORS = [
58
+ ActiveRecord::RecordInvalid, ArgumentError, ApiKeys::Error, ApiKeys::Errors::BaseError
59
+ ].freeze
60
+
61
+ # The answer to one poll: the new key, or the RFC 8628 §3.5 error code.
62
+ Poll = Struct.new(:api_key, :error, keyword_init: true)
63
+
64
+ belongs_to :owner, polymorphic: true, optional: true
65
+ belongs_to :decided_by, polymorphic: true, optional: true
66
+ belongs_to :api_key, class_name: "ApiKeys::ApiKey", optional: true
67
+
68
+ # In the model, not in the migration: MySQL refuses a literal default on
69
+ # a JSON column.
70
+ attribute :scopes, default: -> { [] }
71
+
72
+ # The plaintext codes, on the instance `request!` returns only.
73
+ attr_reader :device_code, :user_code
74
+
75
+ validates :client_id, presence: true, length: { maximum: MAX_NAME_LENGTH }
76
+ validates :device_name, length: { maximum: MAX_NAME_LENGTH }
77
+ validates :state, inclusion: { in: STATES }
78
+ validate :key_type_is_configured
79
+ validate :scopes_are_grantable
80
+
81
+ scope :undecided, -> { where(state: "pending").where("expires_at > ?", Time.current) }
82
+
83
+ STATES.each { |state| define_method(:"#{state}?") { self.state == state } }
84
+
85
+ class << self
86
+ # A new request. Its `device_code` and `user_code` hold the plaintext
87
+ # codes on this instance only.
88
+ #
89
+ # +key_type+ is the type of the key the approval makes: a configured
90
+ # type when key types are configured, nil otherwise.
91
+ #
92
+ # @return [ApiKeys::DeviceAuthorization]
93
+ # @raise [ActiveRecord::RecordInvalid] for a missing client_id, a name
94
+ # that is too long, or scopes a key of the type does not grant
95
+ # @raise [ArgumentError] for a key type the configuration does not name
96
+ def request!(client_id:, scopes:, device_name: nil, requested_from: nil, key_type: nil)
97
+ check_key_type!(key_type)
98
+ device_code = SecureRandom.urlsafe_base64(32)
99
+ user_code = Array.new(USER_CODE_LENGTH) { USER_CODE_ALPHABET[SecureRandom.random_number(USER_CODE_ALPHABET.size)] }.join
100
+ authorization = create!(
101
+ device_code_digest: digest(device_code), user_code_digest: digest(user_code),
102
+ client_id: client_id, device_name: device_name, scopes: Array(scopes).uniq, key_type: key_type&.to_s,
103
+ requested_from: requested_from, interval: INTERVAL, expires_at: LIFETIME.from_now
104
+ )
105
+ authorization.instance_variable_set(:@device_code, device_code)
106
+ authorization.instance_variable_set(:@user_code, user_code.scan(/.{4}/).join("-"))
107
+ authorization
108
+ end
109
+
110
+ # The undecided request whose user code a person typed or followed.
111
+ # Case, spaces and dashes do not matter (§6.1).
112
+ def find_undecided_by_user_code(code)
113
+ undecided.find_by(user_code_digest: digest(code.to_s.upcase.gsub(/[^A-Z]/, "")))
114
+ end
115
+
116
+ def find_by_device_code(code)
117
+ find_by(device_code_digest: digest(code.to_s))
118
+ end
119
+
120
+ # The scopes a client may ask for: the app's `config.default_scopes`,
121
+ # the scopes a person can give a key, within the permissions of
122
+ # +key_type+.
123
+ def grantable_scopes(key_type = nil)
124
+ permissions = key_type_config(key_type)&.[](:permissions)
125
+ scopes = ApiKeys.configuration.default_scopes
126
+ permissions.is_a?(Array) ? scopes & permissions.map(&:to_s) : scopes
127
+ end
128
+
129
+ # The configuration of +key_type+, or nil.
130
+ def key_type_config(key_type)
131
+ return nil if key_type.blank?
132
+
133
+ ApiKeys.configuration.key_types.find { |name, _settings| name.to_s == key_type.to_s }&.last
134
+ end
135
+
136
+ # With key types configured, every key has a configured type, so the
137
+ # request must name one. Without them, it must name none. A wrong
138
+ # setting is the app's error, not the client's: it raises.
139
+ def check_key_type!(key_type)
140
+ configured = ApiKeys.configuration.key_types.keys.map(&:to_s)
141
+ if configured.empty?
142
+ return if key_type.blank?
143
+
144
+ raise ArgumentError, "the device flow names key type #{key_type.inspect}, but no key types are configured"
145
+ end
146
+ return if configured.include?(key_type.to_s)
147
+
148
+ raise ArgumentError, "the device flow needs a configured key type: set config.default_key_type, " \
149
+ "or define device_key_type in the controller (got #{key_type.inspect})"
150
+ end
151
+
152
+ def digest(text)
153
+ Digest::SHA256.hexdigest(text)
154
+ end
155
+
156
+ # Deletes the requests that expired more than a day ago.
157
+ # @return [Integer] How many were deleted.
158
+ def prune!
159
+ where(expires_at: ...1.day.ago).delete_all
160
+ end
161
+ end
162
+
163
+ # The poll names the client that asked (RFC 8628 §3.4). A public client
164
+ # ID is not a secret, but a poll from another client must not get the
165
+ # grant of this one.
166
+ def client?(id)
167
+ client_id == id.to_s.strip
168
+ end
169
+
170
+ # The scopes the key would authorize: the approved scopes within the
171
+ # type's permissions, or, for a `scopes: :ceiling` type, the type's whole
172
+ # live ceiling, whatever the key stores.
173
+ def granted_scopes
174
+ type_config = self.class.key_type_config(key_type)
175
+ return Array(scopes) unless type_config
176
+ return type_config[:permissions].map(&:to_s) if ApiKeys::ApiKey.scopes_follow_ceiling_for?(type_config)
177
+
178
+ permissions = type_config[:permissions]
179
+ permissions.is_a?(Array) ? Array(scopes) & permissions.map(&:to_s) : Array(scopes)
180
+ end
181
+
182
+ # Whether the key would authorize more than the person approved: a
183
+ # `scopes: :ceiling` type whose ceiling holds a scope that was not asked.
184
+ def grant_exceeds_approval?
185
+ (granted_scopes - Array(scopes)).any?
186
+ end
187
+
188
+ # "Fictional agent on build-box": the name the key gets. Characters a
189
+ # key name cannot hold become hyphens, so every request can become a key.
190
+ def key_name
191
+ name = [client_id, device_name.presence].compact.join(" on ")
192
+ name.gsub(/[^a-zA-Z0-9_ -]/, "-").squeeze("-").first(ApiKeys::ApiKey::MAX_NAME_LENGTH).strip
193
+ end
194
+
195
+ # The person approves for +owner+, a record with `has_api_keys`. The key
196
+ # is created at the next poll (`poll!`), so its token is never stored.
197
+ #
198
+ # @raise [Closed] if the request was decided already or expired
199
+ # @raise [KeyLimitReached] if the owner may not have another key; the
200
+ # request stays pending
201
+ def approve!(owner, by:)
202
+ unless owner.respond_to?(:create_api_key!) && owner.persisted?
203
+ raise ArgumentError, "approve a device authorization for a saved record that has_api_keys"
204
+ end
205
+
206
+ decide!("approved", by: by, owner: owner) do
207
+ raise KeyLimitReached unless owner.can_create_api_key?(key_type: key_type.presence)
208
+ end
209
+ end
210
+
211
+ # @raise [Closed] if the request was decided already or expired
212
+ def deny!(by:)
213
+ decide!("denied", by: by)
214
+ end
215
+
216
+ # One poll of the client (§3.4, §3.5), under the row's lock. A poll from
217
+ # another client changes nothing. A poll that comes sooner than the
218
+ # interval slows the client down by five seconds. A poll never raises
219
+ # for a key that cannot be made. An owner at its key limit is the one
220
+ # case that may pass: the client waits, as for a pending request. Every
221
+ # other failure closes the request.
222
+ #
223
+ # @return [Poll] the new key once, or the error code
224
+ def poll!(client_id:)
225
+ return Poll.new(error: "invalid_grant") unless client?(client_id)
226
+
227
+ with_row_lock do
228
+ next Poll.new(error: "expired_token") if expires_at.past?
229
+ # An owner deleted since the approval gets no key.
230
+ next Poll.new(error: "access_denied") if denied? || (approved? && owner.nil?)
231
+ next Poll.new(error: "invalid_grant") if completed? # the device code was used
232
+ next Poll.new(error: VOID_ERROR) if void?
233
+
234
+ if approved?
235
+ key, closed = complete!
236
+ next Poll.new(api_key: key) if key
237
+ next Poll.new(error: VOID_ERROR) if closed
238
+ end
239
+
240
+ too_soon = last_polled_at && last_polled_at > interval.seconds.ago
241
+ update_columns(last_polled_at: Time.current, interval: too_soon ? interval + SLOW_DOWN : interval)
242
+ Poll.new(error: too_soon ? "slow_down" : "authorization_pending")
243
+ end
244
+ end
245
+
246
+ private
247
+
248
+ # A decided or expired request cannot be decided again. The row lock
249
+ # makes two decisions, or a decision and a poll, take turns.
250
+ def decide!(state, by:, owner: nil)
251
+ with_row_lock do
252
+ raise Closed, "this device authorization is no longer open" unless pending? && expires_at.future?
253
+
254
+ yield if block_given?
255
+ update!(state: state, owner: owner, decided_by: by, decided_at: Time.current)
256
+ end
257
+ end
258
+
259
+ # Reloads the row under its lock for the rest of a transaction. SELECT
260
+ # ... FOR UPDATE locks the row on PostgreSQL and MySQL. SQLite has no
261
+ # row locks, so there an UPDATE first takes the database's write lock,
262
+ # which Rails before 8.0 does not take at BEGIN.
263
+ def with_row_lock
264
+ transaction do
265
+ self.class.where(id: id).update_all(updated_at: Time.current) if self.class.connection.adapter_name.match?(/sqlite/i)
266
+ lock!
267
+ yield
268
+ end
269
+ end
270
+
271
+ # Whether the configuration changed since the request, so that no key
272
+ # can be made as the person approved it: key types turned on for an
273
+ # untyped request, the request's type removed or renamed, or a ceiling
274
+ # that would authorize more than the approved scopes. The type stored at
275
+ # the request is the type of the key; it is never resolved again.
276
+ def configuration_incompatible?
277
+ configured = ApiKeys.configuration.key_types.keys.map(&:to_s)
278
+ return configured.any? if key_type.blank?
279
+
280
+ !configured.include?(key_type) || grant_exceeds_approval?
281
+ end
282
+
283
+ # Makes the key: [key, false]. An owner at its key limit gives
284
+ # [nil, false], and the request stays approved for a later poll. Any
285
+ # other failure closes the request: [nil, true]. The person who approved
286
+ # stands behind the key (created_by), when the api_keys table has the
287
+ # created_by columns.
288
+ def complete!
289
+ return [nil, void!] if configuration_incompatible?
290
+ return [nil, false] unless room_for_key?
291
+
292
+ person = { created_by: decided_by } if decided_by && ApiKeys::ApiKey.created_by_columns?
293
+ key = transaction(requires_new: true) do
294
+ owner.create_api_key!(name: key_name, scopes: scopes, key_type: key_type.presence, **person.to_h).tap do |made|
295
+ update_columns(state: "completed", api_key_id: made.id, updated_at: Time.current)
296
+ end
297
+ end
298
+ [key, false]
299
+ rescue *KEY_CREATION_ERRORS => error
300
+ ActiveRecord::Base.logger&.warn("[ApiKeys] Device authorization #{id} could not create its key (#{error.class}).")
301
+ reload
302
+ # A key made by another request in the meantime can fill the last
303
+ # place: that is the key limit again, and it passes.
304
+ error.is_a?(ActiveRecord::RecordInvalid) && !room_for_key? ? [nil, false] : [nil, void!]
305
+ end
306
+
307
+ def room_for_key?
308
+ owner.can_create_api_key?(key_type: key_type.presence)
309
+ end
310
+
311
+ def void!
312
+ update_columns(state: "void", updated_at: Time.current)
313
+ true
314
+ end
315
+
316
+ def key_type_is_configured
317
+ configured = ApiKeys.configuration.key_types.keys.map(&:to_s)
318
+ valid = configured.empty? ? key_type.blank? : configured.include?(key_type.to_s)
319
+ errors.add(:key_type, :inclusion) unless valid
320
+ end
321
+
322
+ # The person approves exactly what the key may do: every scope is one a
323
+ # key of the type grants, and the type grants nothing more.
324
+ def scopes_are_grantable
325
+ grantable = self.class.grantable_scopes(key_type)
326
+ unless scopes.is_a?(Array) && scopes.any? && scopes.all? { |scope| grantable.include?(scope) }
327
+ return errors.add(:scopes, :inclusion)
328
+ end
329
+
330
+ errors.add(:scopes, "must hold every permission of a #{key_type} key, which follows its ceiling") if grant_exceeds_approval?
331
+ end
332
+ end
333
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "digest"
5
+ require "json"
6
+
7
+ module ApiKeys
8
+ # One write under one API key's Idempotency-Key header
9
+ # (draft-ietf-httpapi-idempotency-key-header; ApiKeys::Idempotency).
10
+ #
11
+ # The key's row is claimed first, in a commit of its own. The write then
12
+ # runs in one transaction with the row locked, and the write, the rows it
13
+ # adds (an audit entry) and the key's answer (the status and the record the
14
+ # write made or changed) commit together, or nothing does. So an answered
15
+ # key means the write happened, and a key without an answer means it did
16
+ # not:
17
+ #
18
+ # answered, the same request the retry gets the record as it is now
19
+ # and changes nothing
20
+ # answered, another request refused
21
+ # no answer, locked another request runs it now: Busy
22
+ # no answer, free a refused write or a crash: it runs
23
+ #
24
+ # No answer keeps a body, so a secret that was shown once is never stored.
25
+ # Each API key has its own keys, kept for a day.
26
+ class IdempotencyKey < ActiveRecord::Base
27
+ self.table_name = "api_keys_idempotency_keys"
28
+
29
+ HEADER = "Idempotency-Key"
30
+ MAX_LENGTH = 255
31
+ RETENTION = 24.hours
32
+ LOCK_TIMEOUT = 2 # seconds a retry waits for the request that holds the key
33
+
34
+ # Raised while another request holds the key.
35
+ class Busy < ApiKeys::Error; end
36
+
37
+ belongs_to :api_key, class_name: "ApiKeys::ApiKey"
38
+ belongs_to :resource, polymorphic: true, optional: true
39
+
40
+ validates :key, presence: true, length: { maximum: MAX_LENGTH }
41
+
42
+ scope :expired, -> { where(created_at: ...RETENTION.ago) }
43
+
44
+ class << self
45
+ # Deletes the keys older than a day.
46
+ # @return [Integer] How many were deleted.
47
+ def prune!
48
+ expired.delete_all
49
+ end
50
+
51
+ # The digest of a request: its parts (the method and the path, or the
52
+ # name of a tool) and its parameters, with the names in order, so the
53
+ # same request has the same digest.
54
+ def digest_of(*parts, parameters)
55
+ Digest::SHA256.hexdigest([*parts, canonical(parameters).to_json].join("\n"))
56
+ end
57
+
58
+ # Every value becomes a pair of a tag and its content, so values of
59
+ # different kinds never encode alike: an upload is never equal to a
60
+ # text that holds its digest, or to a hash that looks like one. An
61
+ # upload counts by its bytes, its file name and its content type. A
62
+ # hash that names a key twice, as a symbol and as a text, is refused:
63
+ # its order would decide the digest.
64
+ def canonical(value)
65
+ if value.is_a?(Hash)
66
+ pairs = value.map { |name, inner| [name.to_s, canonical(inner)] }
67
+ duplicates = pairs.map(&:first).tally.select { |_name, count| count > 1 }.keys
68
+ raise ArgumentError, "the parameters name #{duplicates.join(', ')} twice" if duplicates.any?
69
+
70
+ ["object", pairs.sort_by(&:first)]
71
+ elsif value.is_a?(Array)
72
+ ["array", value.map { |inner| canonical(inner) }]
73
+ elsif upload?(value)
74
+ ["upload", Digest::SHA256.file(value.tempfile.path).hexdigest, value.original_filename.to_s, value.content_type.to_s]
75
+ else
76
+ ["value", value]
77
+ end
78
+ end
79
+
80
+ # Yields the API key's row of +key+, locked, inside the transaction that
81
+ # the caller's write joins. Raises Busy while another request holds it.
82
+ #
83
+ # It runs outside every open transaction, so that the claim commits on
84
+ # its own: inside one, the claim and the answer are a savepoint, and
85
+ # the outer rollback removes both. Only transactional tests may run it
86
+ # inside a transaction, with `config.idempotency_in_test_transactions`.
87
+ def hold(api_key, key)
88
+ if connection.transaction_open? && !ApiKeys.configuration.idempotency_in_test_transactions?
89
+ raise ApiKeys::Error, "ApiKeys::IdempotencyKey.hold runs outside database transactions, so that the key's " \
90
+ "claim commits on its own: run run_once_per_idempotency_key outside any transaction. " \
91
+ "Transactional tests set config.idempotency_in_test_transactions = true."
92
+ end
93
+
94
+ claim(api_key, key)
95
+ transaction(requires_new: true) { yield locked(api_key, key) }
96
+ end
97
+
98
+ # The row, locked for the rest of the transaction. The wait is at most
99
+ # LOCK_TIMEOUT seconds on PostgreSQL and MySQL. SQLite has no row
100
+ # locks: there an UPDATE takes the database's write lock first, and the
101
+ # wait is the database's busy timeout.
102
+ def locked(api_key, key)
103
+ rows = where(api_key_id: api_key.id, key: key)
104
+ busy_on_lock_wait do
105
+ waiting_at_most_lock_timeout do
106
+ rows.update_all(updated_at: Time.current) if connection.adapter_name.match?(/sqlite/i)
107
+ rows.lock.take || raise(Busy) # deleted since it was claimed: the client retries
108
+ end
109
+ end
110
+ end
111
+
112
+ private
113
+
114
+ # Claims the key's row in a transaction of its own. A key that exists
115
+ # is read without a lock, so a retry never waits here for the request
116
+ # that holds it: it waits in `locked`. A new key is inserted, and two
117
+ # first requests with the same key wait for each other's insert at
118
+ # most LOCK_TIMEOUT. A day-old key is deleted and claimed afresh. The
119
+ # read comes before the transaction, so on MySQL the insert's fallback
120
+ # read sees the row that the other request committed.
121
+ def claim(api_key, key)
122
+ rows = where(api_key_id: api_key.id, key: key)
123
+ existing = busy_on_lock_wait { rows.take }
124
+ return existing if existing && existing.created_at > RETENTION.ago
125
+
126
+ busy_on_lock_wait do
127
+ transaction(requires_new: true) do
128
+ waiting_at_most_lock_timeout do
129
+ rows.expired.delete_all if existing
130
+ # The row keeps the API key's id, never its token.
131
+ create_or_find_by!(api_key_id: api_key.id, key: key)
132
+ end
133
+ end
134
+ end
135
+ end
136
+
137
+ # ActionDispatch::Http::UploadedFile, and the files of Rack::Test.
138
+ def upload?(value)
139
+ %i[tempfile original_filename content_type].all? { |name| value.respond_to?(name) }
140
+ end
141
+
142
+ def busy_on_lock_wait
143
+ yield
144
+ rescue ActiveRecord::LockWaitTimeout, ActiveRecord::StatementTimeout
145
+ raise Busy
146
+ rescue ActiveRecord::StatementInvalid => error
147
+ # Rails before 8.0 does not name SQLite's "database is locked".
148
+ raise Busy if defined?(::SQLite3::BusyException) && error.cause.is_a?(::SQLite3::BusyException)
149
+
150
+ raise
151
+ end
152
+
153
+ # The statements hold no input: LOCK_TIMEOUT is 2 seconds in each.
154
+ def waiting_at_most_lock_timeout
155
+ case connection.adapter_name
156
+ when /postg/i
157
+ # SET LOCAL ends with the transaction, or with a lock wait that
158
+ # fails it. After the lock, the write waits as it always does.
159
+ connection.execute("SET LOCAL lock_timeout = '2s'")
160
+ yield.tap { connection.execute("SET LOCAL lock_timeout TO DEFAULT") }
161
+ when /mysql|trilogy/i
162
+ connection.execute("SET @api_keys_lock_wait = @@SESSION.innodb_lock_wait_timeout, SESSION innodb_lock_wait_timeout = 2")
163
+ begin
164
+ yield
165
+ ensure
166
+ connection.execute("SET SESSION innodb_lock_wait_timeout = @api_keys_lock_wait")
167
+ end
168
+ else
169
+ yield
170
+ end
171
+ end
172
+ end
173
+
174
+ def answered?
175
+ !response_status.nil?
176
+ end
177
+
178
+ def answer!(status, resource)
179
+ raise ArgumentError, "a write under an Idempotency-Key answers with the record it made or changed" unless resource
180
+
181
+ update!(response_status: status, resource: resource)
182
+ end
183
+ end
184
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ApiKeys
4
- VERSION = "0.5.1"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/api_keys.rb CHANGED
@@ -42,6 +42,13 @@ module ApiKeys
42
42
  def reset_configuration!
43
43
  @configuration = ::ApiKeys::Configuration.new
44
44
  end
45
+
46
+ # Add-only backfill after widening a key type's `permissions:` ceiling, for
47
+ # types that keep stored scopes. See ApiKeys::ApiKey.reconcile_scopes!.
48
+ # @return [Integer] How many keys gained scopes.
49
+ def reconcile_scopes!(key_type:)
50
+ ::ApiKeys::ApiKey.reconcile_scopes!(key_type: key_type)
51
+ end
45
52
  end
46
53
  end
47
54
 
@@ -60,6 +67,12 @@ require "api_keys/restrictions" # Origin/IP request restrictions value object
60
67
  require "api_keys/controller" # This can lead to loading jobs, etc.
61
68
  require "api_keys/models/concerns/has_api_keys"
62
69
  require "api_keys/models/api_key"
70
+ require "api_keys/models/device_authorization"
71
+ require "api_keys/models/idempotency_key"
72
+
73
+ # Controller concerns a host includes in its own API controllers
74
+ require "api_keys/device_flow"
75
+ require "api_keys/idempotency"
63
76
 
64
77
  # Helpers for integrators building custom UIs
65
78
  require "api_keys/helpers/token_session"
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module ApiKeys
7
+ module Generators
8
+ # Rails generator for the column api_keys 0.7.0 adds to the api_keys
9
+ # table: `created_by`, the person behind a key.
10
+ #
11
+ # This generator is for existing installations; new installs get the
12
+ # columns from the install generator, so they never need to run this.
13
+ class AddCreatedByGenerator < Rails::Generators::Base
14
+ include ActiveRecord::Generators::Migration
15
+
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ # Implement the required interface for Rails::Generators::Migration.
19
+ def self.next_migration_number(dirname)
20
+ next_migration_number = current_migration_number(dirname) + 1
21
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
22
+ end
23
+
24
+ # Creates the migration file using the template.
25
+ def create_migration_file
26
+ migration_template "add_created_by_to_api_keys.rb.erb",
27
+ File.join(db_migrate_path, "add_created_by_to_api_keys.rb")
28
+ end
29
+
30
+ # Displays helpful information to the user after installation.
31
+ def display_post_install_message
32
+ say "\n🧑 api_keys created_by migration created!", :green
33
+ say "\nNext steps:"
34
+ say " 1. Run `rails db:migrate` to add created_by_type and created_by_id to api_keys."
35
+ say "\n 2. Name the person when you create a key:"
36
+ say " current_organization.create_api_key!(name: \"CI\", created_by: current_user)"
37
+ say "\n 3. Optional: refuse a key once its person no longer stands behind it:"
38
+ say " config.trusted_key_check = ->(api_key) { api_key.owner.managed_by?(api_key.created_by) }"
39
+ say " Keys made before the migration have no created_by: decide what the check does with them."
40
+ say "\nSee the api_keys README for detailed usage and examples.", :cyan
41
+ end
42
+
43
+ private
44
+
45
+ def migration_version
46
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "rails/generators/active_record"
5
+
6
+ module ApiKeys
7
+ module Generators
8
+ # Rails generator for the device authorizations table (api_keys 0.7.0),
9
+ # which ApiKeys::DeviceFlow needs: a client asks for an API key with the
10
+ # OAuth 2.0 Device Authorization Grant (RFC 8628), and a person approves
11
+ # or denies it in the app. New and existing installations run it alike.
12
+ class AddDeviceAuthorizationsGenerator < Rails::Generators::Base
13
+ include ActiveRecord::Generators::Migration
14
+
15
+ source_root File.expand_path("templates", __dir__)
16
+
17
+ # Implement the required interface for Rails::Generators::Migration.
18
+ def self.next_migration_number(dirname)
19
+ next_migration_number = current_migration_number(dirname) + 1
20
+ ActiveRecord::Migration.next_migration_number(next_migration_number)
21
+ end
22
+
23
+ # Creates the migration file using the template.
24
+ def create_migration_file
25
+ migration_template "create_api_keys_device_authorizations.rb.erb",
26
+ File.join(db_migrate_path, "create_api_keys_device_authorizations.rb")
27
+ end
28
+
29
+ # Displays helpful information to the user after installation.
30
+ def display_post_install_message
31
+ say "\n📟 api_keys device authorizations migration created!", :green
32
+ say "\nNext steps:"
33
+ say " 1. Run `rails db:migrate` to create the api_keys_device_authorizations table."
34
+ say "\n 2. Include ApiKeys::DeviceFlow in an API controller, define"
35
+ say " device_verification_uri(user_code = nil), and route its create and token actions."
36
+ say "\n 3. Build the page where a person approves: find the request with"
37
+ say " ApiKeys::DeviceAuthorization.find_undecided_by_user_code(code), then call"
38
+ say " approve!(owner, by: current_user) or deny!(by: current_user)."
39
+ say "\n 4. Rate-limit the look-ups and the approvals on that page together (RFC 8628 §5.1), and run"
40
+ say " ApiKeys::DeviceAuthorization.prune! once a day."
41
+ say "\nSee the api_keys README for detailed usage and examples.", :cyan
42
+ end
43
+
44
+ private
45
+
46
+ def migration_version
47
+ "[#{ActiveRecord::VERSION::STRING.to_f}]"
48
+ end
49
+ end
50
+ end
51
+ end