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.
@@ -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
@@ -31,7 +31,8 @@ module ApiKeys
31
31
  "origin_not_allowed" => "the request came from a web origin the key does not allow",
32
32
  "ip_not_allowed" => "the request came from an IP address the key does not allow",
33
33
  "restriction_misconfigured" => "the key's stored request restrictions could not be verified",
34
- "missing_scope" => "the key lacks a permission the endpoint requires"
34
+ "missing_scope" => "the key lacks a permission the endpoint requires",
35
+ "untrusted_key" => "the person behind the key no longer stands behind it"
35
36
  }.freeze
36
37
  UNKNOWN_REFUSAL_REASON = "the request was refused for an unrecognized reason"
37
38
 
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+ require_relative "authentication"
5
+
6
+ module ApiKeys
7
+ # Writes that run once per Idempotency-Key, for an API controller that
8
+ # authenticates with `authenticate_api_key!`
9
+ # (draft-ietf-httpapi-idempotency-key-header, ApiKeys::IdempotencyKey):
10
+ #
11
+ # class Api::BaseController < ActionController::API
12
+ # include ApiKeys::Controller
13
+ # include ApiKeys::Idempotency
14
+ #
15
+ # before_action :authenticate_api_key!
16
+ # around_action :run_once_per_idempotency_key
17
+ #
18
+ # private
19
+ #
20
+ # # The JSON for a record: the first answer and every retry use it.
21
+ # def resource_json(record, **options)
22
+ # record.as_json(only: %i[id name])
23
+ # end
24
+ #
25
+ # # Whether this API key may read the record now. A retry answers only
26
+ # # records it may read: a record moved to another owner is not found.
27
+ # def idempotent_replay_allowed?(record)
28
+ # record.owner == current_api_owner
29
+ # end
30
+ # end
31
+ #
32
+ # class Api::ProjectsController < Api::BaseController
33
+ # def create
34
+ # render_resource current_api_owner.projects.create!(project_params), status: :created
35
+ # end
36
+ # end
37
+ #
38
+ # The around_action runs outside any transaction the app opens: the key's
39
+ # claim must commit on its own (ApiKeys::IdempotencyKey.hold refuses).
40
+ #
41
+ # Every request but GET and HEAD needs the header. A write answers with
42
+ # `render_resource`, so the key keeps the record it made or changed, and a
43
+ # retry with the same key and the same request answers that record as it
44
+ # is now, with `Idempotent-Replayed: true`. Refusals render through
45
+ # `render_unauthorized`, like the gem's other refusals:
46
+ #
47
+ # 400 idempotency_key_required no header, or longer than 255 characters
48
+ # 409 idempotency_key_in_use a request with this key still runs
49
+ # 422 idempotency_key_reused the key was used for another request
50
+ module Idempotency
51
+ extend ActiveSupport::Concern
52
+ include ApiKeys::Authentication
53
+
54
+ REPLAYED_HEADER = "Idempotent-Replayed"
55
+ # The methods a host defines: the gem cannot know how to render a record,
56
+ # or who may read it.
57
+ HOST_METHODS = %i[resource_json idempotent_replay_allowed?].freeze
58
+
59
+ private
60
+
61
+ # The around_action. A write and its answer commit together; a refused
62
+ # write (any status but 2xx) or an error commits nothing and leaves the
63
+ # key free for the next attempt.
64
+ def run_once_per_idempotency_key
65
+ return yield if reading_request?
66
+ raise ApiKeys::Error, "run authenticate_api_key! before run_once_per_idempotency_key" unless current_api_key
67
+
68
+ require_idempotency_host_methods!
69
+
70
+ key = request.headers[ApiKeys::IdempotencyKey::HEADER].to_s
71
+ if key.blank? || key.length > ApiKeys::IdempotencyKey::MAX_LENGTH
72
+ return render_unauthorized(
73
+ error_code: :idempotency_key_required, status: :bad_request,
74
+ message: "Send an Idempotency-Key header with every write: a unique text of " \
75
+ "1 to #{ApiKeys::IdempotencyKey::MAX_LENGTH} characters, the same when you retry."
76
+ )
77
+ end
78
+
79
+ ApiKeys::IdempotencyKey.hold(current_api_key, key) do |record|
80
+ next replay_idempotent_request(record) if record.answered?
81
+
82
+ record.update!(request_digest: idempotency_request_digest)
83
+ yield
84
+ response.successful? ? record.answer!(response.status, @api_keys_idempotent_resource) : raise(ActiveRecord::Rollback)
85
+ end
86
+ rescue ApiKeys::IdempotencyKey::Busy
87
+ render_unauthorized(error_code: :idempotency_key_in_use, status: :conflict,
88
+ message: "A request with this Idempotency-Key is still running. Retry in a moment.")
89
+ end
90
+
91
+ # A write's answer: the record it made or changed, as `resource_json`
92
+ # renders it. The key keeps the record, so a retry answers the same way.
93
+ # Options go to `resource_json` for this answer only: a secret shown
94
+ # once is never in a retry's answer.
95
+ def render_resource(record, status: :ok, **options)
96
+ @api_keys_idempotent_resource = record
97
+ render json: resource_json(record, **options), status: status
98
+ end
99
+
100
+ # The JSON for a record. Define it in the host controller.
101
+ def resource_json(_record, **_options)
102
+ raise NotImplementedError,
103
+ "#{self.class.name} must define resource_json(record, **options) for ApiKeys::Idempotency"
104
+ end
105
+
106
+ # Whether current_api_key may read +record+ now. Define it in the host
107
+ # controller. A retry runs no action, so this is the only check before a
108
+ # record's current state goes back to the client.
109
+ def idempotent_replay_allowed?(_record)
110
+ raise NotImplementedError,
111
+ "#{self.class.name} must define idempotent_replay_allowed?(record) for ApiKeys::Idempotency: " \
112
+ "whether current_api_key may read the record that a retry answers with"
113
+ end
114
+
115
+ # Both host methods are checked on the first write, not on the first
116
+ # retry, so a missing one shows in development and tests.
117
+ def require_idempotency_host_methods!
118
+ missing = HOST_METHODS.select { |name| method(name).owner == ApiKeys::Idempotency }
119
+ return if missing.empty?
120
+
121
+ raise NotImplementedError, "#{self.class.name} must define #{missing.join(' and ')} for ApiKeys::Idempotency: " \
122
+ "resource_json(record, **options) renders a record, and " \
123
+ "idempotent_replay_allowed?(record) answers whether current_api_key may read it"
124
+ end
125
+
126
+ # The answer to a retry: the record as it is now, never a stored body,
127
+ # so it is current and holds no secret. A record that no longer exists,
128
+ # or that this API key may no longer read, is not found
129
+ # (ActiveRecord::RecordNotFound, which Rails answers 404).
130
+ def replay_idempotent_request(record)
131
+ if record.request_digest != idempotency_request_digest
132
+ return render_unauthorized(error_code: :idempotency_key_reused, status: 422,
133
+ message: "This Idempotency-Key was used for another request. Use a new key.")
134
+ end
135
+
136
+ resource = record.resource
137
+ unless resource && idempotent_replay_allowed?(resource)
138
+ raise ActiveRecord::RecordNotFound, "the record of this Idempotency-Key is not found for this API key"
139
+ end
140
+
141
+ response.headers[REPLAYED_HEADER] = "true"
142
+ render json: resource_json(resource), status: record.response_status
143
+ end
144
+
145
+ # The request a key stands for: the method, the path, the query, and
146
+ # the body. The query and the body count apart, so a name that moves
147
+ # from one to the other makes another request.
148
+ def idempotency_request_digest
149
+ @api_keys_idempotency_request_digest ||= ApiKeys::IdempotencyKey.digest_of(
150
+ request.request_method, request.path,
151
+ { "query" => request.query_parameters, "body" => request.request_parameters }
152
+ )
153
+ end
154
+ end
155
+ end
@@ -10,6 +10,9 @@ module ApiKeys
10
10
  # The core ActiveRecord model representing an API key.
11
11
  class ApiKey < ActiveRecord::Base
12
12
  MAX_SCOPES = 100
13
+ # A key name: letters, numbers, underscores, hyphens and spaces.
14
+ MAX_NAME_LENGTH = 60
15
+ NAME_FORMAT = /\A[a-zA-Z0-9_ -]+\z/
13
16
  MAX_SCOPE_BYTESIZE = 128
14
17
  MAX_METADATA_BYTESIZE = 16_384
15
18
  MAX_RESTRICTION_ENTRIES = 100
@@ -29,11 +32,17 @@ module ApiKeys
29
32
  revoked_key expired_key unknown_key_type unknown_environment
30
33
  environment_misconfigured environment_mismatch
31
34
  origin_not_allowed ip_not_allowed restriction_misconfigured missing_scope
35
+ untrusted_key
32
36
  ].freeze
37
+ # The person who created the key (0.7.0). Optional columns.
38
+ CREATED_BY_COLUMNS = %w[created_by_type created_by_id].freeze
33
39
  # Deliberately excludes `restrictions`: owners must be able to tighten a
34
- # request policy even when the key itself is non-revocable.
40
+ # request policy even when the key itself is non-revocable. Includes the
41
+ # person who created the key: `config.trusted_key_check` may trust a key
42
+ # because of that person, so no update can name another one.
35
43
  IMMUTABLE_IDENTITY_ATTRIBUTES = %w[
36
44
  token_digest digest_algorithm prefix last4 owner_type owner_id key_type environment
45
+ created_by_type created_by_id
37
46
  ].freeze
38
47
 
39
48
  self.table_name = "api_keys"
@@ -43,6 +52,10 @@ module ApiKeys
43
52
 
44
53
  # == Associations ==
45
54
  belongs_to :owner, polymorphic: true, optional: true
55
+ # The person behind the key, when the host names one at creation:
56
+ # `create_api_key!(created_by: current_user)`. Needs the created_by
57
+ # columns (`rails generate api_keys:add_created_by`).
58
+ belongs_to :created_by, polymorphic: true, optional: true
46
59
 
47
60
  # == Attributes & Serialization ==
48
61
  # Expose the plaintext token only immediately after creation
@@ -52,6 +65,11 @@ module ApiKeys
52
65
  # saves can never set or clear it, so no update path (a form, mass
53
66
  # assignment, a stray `update`) can reopen a handoff that was claimed.
54
67
  attr_readonly :token_shown_at
68
+ # The person behind the key is fixed at creation. `config.trusted_key_check`
69
+ # may trust a key because of that person, so no save (with or without
70
+ # validations) and no update_column can name another one. A backfill of
71
+ # keys made before the columns existed writes them with SQL (update_all).
72
+ attr_readonly :created_by_type, :created_by_id
55
73
  attr_accessor :expires_at_preset
56
74
 
57
75
  # JSON attributes (:scopes, :metadata) are defined in the engine initializer
@@ -138,9 +156,9 @@ module ApiKeys
138
156
  # validates :scopes, presence: true # Default handled by attribute def
139
157
  # validates :metadata, presence: true # Default handled by attribute def
140
158
  validates :name,
141
- length: { maximum: 60 },
159
+ length: { maximum: MAX_NAME_LENGTH },
142
160
  # Allow letters, numbers, underscores, hyphens, and spaces. No leading/trailing spaces.
143
- format: { with: /\A[a-zA-Z0-9_ -]+\z/, message: "can only contain letters, numbers, underscores, hyphens, and spaces (no leading / trailing spaces)" },
161
+ format: { with: NAME_FORMAT, message: "can only contain letters, numbers, underscores, hyphens, and spaces (no leading / trailing spaces)" },
144
162
  allow_blank: true # Apply length and format only if name is present
145
163
 
146
164
  validates :name, presence: true, if: :name_required? # Only require presence conditionally
@@ -564,6 +582,15 @@ module ApiKeys
564
582
  false
565
583
  end
566
584
 
585
+ # Whether the `created_by` columns exist. Installations that predate
586
+ # v0.7.0 keep working untouched until they run the generator.
587
+ # @return [Boolean]
588
+ def self.created_by_columns?
589
+ (CREATED_BY_COLUMNS - column_names).empty?
590
+ rescue StandardError
591
+ false
592
+ end
593
+
567
594
  # Whether the refused-traffic columns exist. Installations that predate
568
595
  # v0.6.0 keep authenticating untouched until they run the generator.
569
596
  # @return [Boolean]
@@ -225,13 +225,20 @@ module ApiKeys
225
225
  # Accepts the raw string a form field submits ("example.com, *.example.com").
226
226
  # @param allowed_ips [String, Array, nil] Convenience form of the IP list
227
227
  # ("203.0.113.7, 10.0.0.0/8").
228
+ # @param created_by [ActiveRecord::Base, nil] The person behind the key, for
229
+ # `config.trusted_key_check`. Needs the created_by columns.
228
230
  # @return [ApiKeys::ApiKey] The newly created ApiKey instance. The plaintext token
229
231
  # is available via the `#token` attribute on this instance
230
232
  # *only until it's reloaded*.
231
233
  def create_api_key!(name: nil, scopes: nil, expires_at: nil, expires_at_preset: nil, metadata: nil,
232
- key_type: nil, environment: nil, restrictions: nil, allowed_origins: nil, allowed_ips: nil)
234
+ key_type: nil, environment: nil, restrictions: nil, allowed_origins: nil, allowed_ips: nil,
235
+ created_by: nil)
233
236
  config = ApiKeys.configuration
234
237
 
238
+ if created_by && !ApiKeys::ApiKey.created_by_columns?
239
+ raise ApiKeys::Errors::CreatedByMigrationRequiredError
240
+ end
241
+
235
242
  # Parse expires_at_preset if provided (takes precedence over expires_at)
236
243
  if expires_at_preset.present?
237
244
  expires_at = ApiKeys::Helpers::ExpirationOptions.parse(expires_at_preset)
@@ -302,6 +309,7 @@ module ApiKeys
302
309
  # prefix, token_digest, digest_algorithm are set by ApiKey callbacks
303
310
  }
304
311
  attributes[:restrictions] = requested_restrictions if requested_restrictions
312
+ attributes[:created_by] = created_by if created_by
305
313
 
306
314
  api_key = self.class.transaction do
307
315
  self.api_keys.create!(**attributes)