api_keys 0.6.0 → 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 +32 -0
- data/README.md +276 -1
- data/lib/api_keys/authentication.rb +51 -0
- data/lib/api_keys/configuration.rb +56 -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/helpers/view_helpers.rb +2 -1
- data/lib/api_keys/idempotency.rb +155 -0
- data/lib/api_keys/models/api_key.rb +30 -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 +6 -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/templates/add_created_by_to_api_keys.rb.erb +33 -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 +4 -0
- data/lib/generators/api_keys/templates/initializer.rb +18 -0
- metadata +11 -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
|
data/lib/api_keys/version.rb
CHANGED
data/lib/api_keys.rb
CHANGED
|
@@ -67,6 +67,12 @@ require "api_keys/restrictions" # Origin/IP request restrictions value object
|
|
|
67
67
|
require "api_keys/controller" # This can lead to loading jobs, etc.
|
|
68
68
|
require "api_keys/models/concerns/has_api_keys"
|
|
69
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"
|
|
70
76
|
|
|
71
77
|
# Helpers for integrators building custom UIs
|
|
72
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
|