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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8b2979856fd86b34f632b3d738195286ae07a829ac3a74dda56c5d6bf9e8c4f9
4
- data.tar.gz: ee8651a2eaba436ec08bbf6df184d080685fbcbbe3751c4b0dc8980c4a4409eb
3
+ metadata.gz: 62087b06751cfbfe1b08bc1db54179fa1ce72844da86166a5198887d7b834a3f
4
+ data.tar.gz: 51ddeccbaee055eaf9d1cec6677b9514b478bf0ff4764768dee7e40e731c8685
5
5
  SHA512:
6
- metadata.gz: 0b481a5b23ede9c768fe98479d65f568efca51d339228c3541feedbbe4ba26d5df896d537207687237a809504fc3dc655ab31856ad3ac2cc16a5559e08b8c1dc
7
- data.tar.gz: 0a154501522dfcc6e83fc8a6d89b75ed2d94d8c5fcdde5d942ea0e0da0f5f2948f1db117b1332414c4844e7c5bcf544a471609a18cc52584d4c7729f2e7a670a
6
+ metadata.gz: 5b8adba5c3631e272a77bfd600f4667981bf91048e529aeac7b5f6f0c6d9b1967c989edff1ee23ee9d2a2bb9da3d83a9206e7b0a20ca26f741ebb8bae5abc586
7
+ data.tar.gz: e0bbcb9c1bf0be08bdbe9ffe035e7dbe60a2cddc0520474fb94c40ca6cba53c46fc8f4ce5cb9537fd14ab4392149ac294a89d7599a61ba95e66a5f8e1864fd6b
data/CHANGELOG.md CHANGED
@@ -1,3 +1,35 @@
1
+ ## [0.7.0] - 2026-10-24
2
+
3
+ Four features that an invoicing app on the fleet runs in production today, moved into the gem with their tests so that apps on the fleet can delete their own copies. Each one is opt-in: an app that does not use it sees no change.
4
+
5
+ ### Added
6
+
7
+ - The device authorization grant (RFC 8628), so an agent or a CLI can ask for an API key and get it after a person approves in the app, with no key copied by hand. `ApiKeys::DeviceAuthorization` (`request!`, `find_undecided_by_user_code`, `find_by_device_code`, `approve!(owner, by:)`, `deny!(by:)`, `poll!(client_id:)`, `prune!`) keeps both codes as SHA-256 digests, lives ten minutes, uses an eight-letter user code from twenty consonants that is case- and dash-insensitive, takes the row lock for polls and decisions, and never decides a decided or expired request again. Each request names the type of its key (`device_key_type` in the controller, `config.default_key_type` by default); with key types and no type, the request raises as the app's error. `approve!` checks the owner's key limit for that type and raises `KeyLimitReached` at the limit; the request stays pending. The type is stored with the request and is never resolved again: an untyped request stays untyped. A `scopes: :ceiling` type grants its whole ceiling, so a request must ask for all of it. A configuration change that makes the approved key impossible (key types turned on for an untyped request, a type removed or renamed, a ceiling that grew) makes the poll answer `invalid_scope` and closes the request (`void`). The first poll after the approval creates an ordinary key for the owner, named "client on device", and answers with its token once. A poll never raises because the key cannot be made. At the owner's key limit it answers `authorization_pending`, and a later poll makes the key once there is room; any other failure closes the request. `ApiKeys::DeviceFlow` gives an API controller the `create` and `token` actions with the RFC 8628 answers, the RFC 6749 error shape and `Cache-Control: no-store`; the host names its approval page in `device_verification_uri`, and may add fields to the token answer in `device_token_response`, which runs in the transaction that creates the key. The engine adds `device_code`, `user_code` and `token` to `config.filter_parameters`. `rails generate api_keys:add_device_authorizations` creates the table. Its JSON column has no literal default, which MySQL refuses; the model gives the default.
8
+ - The person behind a key, so a key whose owner is an organization stops when the person who created it no longer stands behind it. `created_by`, an optional polymorphic reference on `api_keys`, is set by `create_api_key!(created_by:)` and cannot change after creation: a validation, and `attr_readonly` for `update_column` and saves without validations. `config.trusted_key_check` is a lambda that `authenticate_api_key!` calls with the key after the scope check; a false answer refuses the request with 403 `untrusted_key` through `render_unauthorized`. The device flow names the approving person as `created_by`. The mounted dashboard leaves `created_by` nil; an app that needs it creates keys in its own controller. New installs get the columns; existing installs run `rails generate api_keys:add_created_by`.
9
+ - Idempotency-Key for writes, so a client can retry a write after a timeout without making a second record. `ApiKeys::IdempotencyKey` (`hold(api_key, key)`, `digest_of`, `answer!`, `answered?`, `prune!`, `Busy`) claims the key's row in a commit of its own, runs the write under the row lock, and commits the write with its answer (the status and the record, never a body), or nothing. Keys are per API key and kept a day. `ApiKeys::Idempotency` gives an API controller the `run_once_per_idempotency_key` around_action and `render_resource`; the host defines `resource_json` and `idempotent_replay_allowed?`. A retry answers the record as it is now with `Idempotent-Replayed: true`, and only a record the API key may still read: one moved to another owner is not found. The request digest counts the query and the body apart, and an upload by its bytes, file name and content type, in an encoding that no ordinary value shares. `hold` refuses every open transaction; transactional tests opt in with the test-only `config.idempotency_in_test_transactions`, which takes `true` only when `Rails.env` is test and counts only while it is test. A hash that names a key both as a symbol and as a text is refused. The claim of a new key runs in a transaction of its own under the lock timeout. The refusals are 400 `idempotency_key_required`, 409 `idempotency_key_in_use` and 422 `idempotency_key_reused`, after draft-ietf-httpapi-idempotency-key-header section 2.7. `rails generate api_keys:add_idempotency_keys` creates the table.
10
+ - Scope by HTTP method, so an API asks reads for one scope and writes for another in one line: `authenticate_api_key!(scope: { read: "read", write: "write" })`. GET and HEAD read; every other method writes. Both sides are required.
11
+
12
+ ### Changed
13
+
14
+ - `untrusted_key` joins the refusal codes that are stamped on a key (`last_refusal_code`) and explained by `api_key_refusal_reason`. It is stamped only when `config.trusted_key_check` refuses a key.
15
+ - `created_by_type` and `created_by_id` join the attributes that cannot change after a key is created.
16
+ - `ApiKey::MAX_NAME_LENGTH` and `ApiKey::NAME_FORMAT` name the existing key name rule, which the device flow uses to name its keys.
17
+
18
+ ### Security
19
+
20
+ - Device flow: the key's token is never stored, and the poll that creates the key is the only answer that carries it. A poll with another `client_id` changes nothing. Two simultaneous polls of one approved request make one key; this was checked with concurrent connections on PostgreSQL, MySQL and SQLite.
21
+ - Idempotency-Key: a retry waits at most two seconds for the request that holds the key, and two first requests wait at most as long for each other's claim (PostgreSQL `lock_timeout`, MySQL `innodb_lock_wait_timeout`; on SQLite, the database's busy timeout), then answer 409. SQLite has no row locks, so there an UPDATE takes the database's write lock first. The device flow's row lock does the same. A new CI job runs the generated migrations and these locks on PostgreSQL, MySQL and SQLite with real concurrent connections.
22
+ - `config.trusted_key_check` fails closed: an error in the lambda fails the request and never passes it as trusted.
23
+
24
+ ### Upgrade notes
25
+
26
+ - Nothing changes until an app opts in. Every 0.7.0 migration is optional: `add_created_by` for the person behind a key, `add_device_authorizations` for the device flow, `add_idempotency_keys` for Idempotency-Key. Run them in any order, before or after the deploy.
27
+ - From 0.4.x, the 0.5.0 and 0.6.0 migrations (`add_restrictions`, `add_key_activity`) stay optional too. The calls of a custom key controller keep working: `create_api_key!`, `TokenSession.store(session, api_key)` and `TokenSession.retrieve_once(session)`, and a handoff stored by 0.4.x stays readable. Read the 0.5.1 and 0.6.0 notes: the scope checkboxes render an empty field before the boxes, a token's handoff is one-time across copies of the session, and a retrieval inside an open transaction is refused. The README section "Upgrading to 0.7.0 from 0.4.x or 0.6.x" lists them.
28
+ - A 0.6.0 job worker ignores the new `untrusted_key` refusal code with a warning. A 0.4.x or 0.5.x worker errors on every refusal job of 0.6.0 or later: deploy job workers with the web processes.
29
+ - Keys created before `add_created_by` have no `created_by`. If `config.trusted_key_check` needs one, backfill the columns in a migration with SQL (`update_all`): the columns are read-only for a saved key.
30
+ - `config.filter_parameters` gains `device_code`, `user_code` and `token`.
31
+ - An app whose tests are transactional and use `ApiKeys::Idempotency` sets `ApiKeys.configuration.idempotency_in_test_transactions = true` in its test environment.
32
+
1
33
  ## [0.6.0] - 2026-10-18
2
34
 
3
35
  ### Added
data/README.md CHANGED
@@ -59,6 +59,41 @@ rails db:migrate
59
59
 
60
60
  New installations get these columns from the start too. Until an existing app runs the migration, authentication keeps working exactly as before, refusals are simply not recorded, and the one-time token page relies on the cache marker alone, as in 0.5.1.
61
61
 
62
+ To name the person behind each key (see [The person behind a key](#the-person-behind-a-key)), add the 0.7.0 `created_by` column. New installations get it from the start:
63
+
64
+ ```bash
65
+ rails generate api_keys:add_created_by
66
+ rails db:migrate
67
+ ```
68
+
69
+ The device flow and Idempotency-Key each need a table of their own. New and existing installations run these only if they use the feature:
70
+
71
+ ```bash
72
+ rails generate api_keys:add_device_authorizations # for ApiKeys::DeviceFlow
73
+ rails generate api_keys:add_idempotency_keys # for ApiKeys::Idempotency
74
+ rails db:migrate
75
+ ```
76
+
77
+ #### Upgrading to 0.7.0 from 0.4.x or 0.6.x
78
+
79
+ Change the Gemfile line to `gem "api_keys", "~> 0.7.0"` and run `bundle update api_keys`. No migration is required: every migration since 0.5.0 adds an optional column or table, and the gem works without it. Run the ones for the features you want, in any order.
80
+
81
+ | From | Optional migrations | What they turn on |
82
+ |------|---------------------|-------------------|
83
+ | 0.4.x | `add_restrictions` | Origin and IP locks (0.5.0) |
84
+ | 0.4.x | `add_key_activity` | Refused-request stats and the durable one-time token page (0.6.0) |
85
+ | 0.4.x, 0.6.x | `add_created_by` | The person behind a key (0.7.0) |
86
+ | 0.4.x, 0.6.x | `add_device_authorizations` | The device flow (0.7.0) |
87
+ | 0.4.x, 0.6.x | `add_idempotency_keys` | Idempotency-Key for writes (0.7.0) |
88
+
89
+ On 0.6.x, nothing changes until you opt in to a 0.7.0 feature. A 0.6.0 job worker ignores the new `untrusted_key` refusal code with a warning, so a rolling deploy is safe.
90
+
91
+ On 0.4.x, the calls of a custom key controller keep working unchanged: `create_api_key!(name:, key_type:, expires_at:)`, `ApiKeys::TokenSession.store(session, @api_key)` and `ApiKeys::TokenSession.retrieve_once(session)`. A handoff stored by 0.4.x stays readable after the deploy. Read the [CHANGELOG](CHANGELOG.md) for 0.5.0 to 0.6.0 before you upgrade. These changes are the ones a 0.4.x app sees:
92
+
93
+ - `api_key_scopes_checkboxes` renders an empty `api_key[scopes][]` field before the boxes (0.5.1). A form with every box cleared now saves no scopes, where 0.4.x kept the old ones. If an edit form offers only some scopes, save `ApiKey#scopes_after_form_edit` (see [Form Builder Extensions](#form-builder-extensions-opt-in)).
94
+ - A token's one-time handoff is one-time across copies of the session (0.5.1), and once ever per key after `add_key_activity` (0.6.0). A retrieval inside an open database transaction is refused (0.6.0): exclude the token page from a host `around_action` that wraps requests in a transaction.
95
+ - Deploy job workers with the web processes. A refusal job from 0.6.0 or later errors on a 0.4.x worker.
96
+
62
97
  ## Quick Start
63
98
 
64
99
  Just add `has_api_keys` to your desired model. For example, if you want your `User` records to have API keys, you'd have:
@@ -408,7 +443,8 @@ current_org.can_create_api_key?(key_type: :publishable)
408
443
  environment: :live, # Defaults to current_environment
409
444
  metadata: { team: "backend" }, # Optional JSON metadata
410
445
  allowed_origins: "example.com", # Optional: lock to web origins
411
- allowed_ips: "10.0.0.0/8" # Optional: lock to IP addresses
446
+ allowed_ips: "10.0.0.0/8", # Optional: lock to IP addresses
447
+ created_by: current_user # Optional: the person behind the key
412
448
  )
413
449
  ```
414
450
 
@@ -451,6 +487,9 @@ Methods available on `ApiKeys::ApiKey` instances:
451
487
  @api_key.restricted? # => true if either list has entries
452
488
  @api_key.restrictions # => ApiKeys::Restrictions value object
453
489
 
490
+ # The person behind the key (needs the created_by column)
491
+ @api_key.created_by # => #<User id: 1, ...>, or nil
492
+
454
493
  # Metadata
455
494
  @api_key.name # => "Production Server"
456
495
  @api_key.created_at
@@ -952,6 +991,39 @@ def write_action
952
991
  end
953
992
  ```
954
993
 
994
+ ### Require read or write by HTTP method
995
+
996
+ Most APIs give reads one scope and writes another. Pass both by HTTP method, in one line:
997
+
998
+ ```ruby
999
+ before_action -> { authenticate_api_key!(scope: { read: "read", write: "write" }) }
1000
+ ```
1001
+
1002
+ GET and HEAD requests need the `read` scope. Every other method needs the `write` scope. Each side takes what `scope:` takes: a scope, a list of scopes, or `nil` for none. Both sides are required, so a forgotten one never leaves its requests unchecked.
1003
+
1004
+ An action that needs another scope names it. Put the rule in a method, and let a controller override it:
1005
+
1006
+ ```ruby
1007
+ class Api::BaseController < ActionController::API
1008
+ include ApiKeys::Controller
1009
+ before_action -> { authenticate_api_key!(scope: required_scope) }
1010
+
1011
+ private
1012
+
1013
+ def required_scope
1014
+ { read: "read", write: "write" }
1015
+ end
1016
+ end
1017
+
1018
+ class Api::InvoicesController < Api::BaseController
1019
+ private
1020
+
1021
+ def required_scope
1022
+ action_name == "pdf" ? "downloads" : super
1023
+ end
1024
+ end
1025
+ ```
1026
+
955
1027
  ### Rate limit your API endpoints
956
1028
 
957
1029
  Rails 8 introduced the native, built-in `rate_limit` to easily rate limit your endpoints, so `Rack::Attack` is no longer necessary! While this is not an `api_keys` feature per se, I thought it'd be nice to include an example here because it pairs so well with `api_keys`.
@@ -975,6 +1047,209 @@ This `rate_limit` feature depends on Rails 8+ and an active, well-configured cac
975
1047
  If you're still on early versions of Rails, you can still use `api_keys`! No need to implement `rate_limit` – just an idea if you're already on Rails 8!
976
1048
 
977
1049
 
1050
+ ## The person behind a key
1051
+
1052
+ When keys belong to an organization, a key keeps working after the person who created it has left. Name that person when you create the key:
1053
+
1054
+ ```ruby
1055
+ current_organization.create_api_key!(name: "CI", scopes: %w[read], created_by: current_user)
1056
+ ```
1057
+
1058
+ Then tell the gem when a key stops being trusted:
1059
+
1060
+ ```ruby
1061
+ # config/initializers/api_keys.rb
1062
+ config.trusted_key_check = ->(api_key) { api_key.owner.managed_by?(api_key.created_by) }
1063
+ ```
1064
+
1065
+ After authentication and the scope check, `authenticate_api_key!` calls the lambda with the key. When it answers `false` or `nil`, the request gets `403` with `error: "untrusted_key"`, the key is not set as `current_api_key`, and the refusal is recorded on the key like every other refusal (with the 0.6.0 columns), never as a use. An error in the lambda fails the request; it never passes as trusted. Without a lambda (the default), every valid key is trusted, as before.
1066
+
1067
+ - `created_by` is polymorphic and optional. It cannot change after the key is created (a validation, and `attr_readonly`), so no update can make another person stand behind a key.
1068
+ - The mounted dashboard leaves `created_by` nil: it knows the key's owner, not the person. An app that needs the person behind each key creates its keys in its own controller and passes `created_by:` there (see [Building Custom Integrations](#building-custom-integrations)).
1069
+ - Keys created before you set `created_by` have none. Decide what the lambda does with them, or backfill the columns in a migration with SQL (`update_all`): the columns are read-only for a saved key, so no save, `update_column` or form can change them.
1070
+ - The refusal renders through `render_unauthorized`, so if you override it to render your own error shape, `untrusted_key` uses that shape too. Change the message with I18n: `api_keys.errors.untrusted_key`.
1071
+ - New installs get the columns from the start. Existing installs run `rails generate api_keys:add_created_by`; until then, passing `created_by:` raises `ApiKeys::Errors::CreatedByMigrationRequiredError`.
1072
+
1073
+ ## Idempotent writes (Idempotency-Key)
1074
+
1075
+ A client that retries a write after a timeout must not create a second record. With `ApiKeys::Idempotency`, every write carries an `Idempotency-Key` header and runs once per key ([draft-ietf-httpapi-idempotency-key-header](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/)):
1076
+
1077
+ ```bash
1078
+ rails generate api_keys:add_idempotency_keys
1079
+ rails db:migrate
1080
+ ```
1081
+
1082
+ ```ruby
1083
+ class Api::BaseController < ActionController::API
1084
+ include ApiKeys::Controller
1085
+ include ApiKeys::Idempotency
1086
+
1087
+ before_action :authenticate_api_key!
1088
+ around_action :run_once_per_idempotency_key # after authentication
1089
+
1090
+ private
1091
+
1092
+ # The JSON for a record. The first answer and every retry use it.
1093
+ def resource_json(record, **options)
1094
+ record.as_json(only: %i[id name]).merge(options)
1095
+ end
1096
+
1097
+ # Whether this API key may read the record now. A retry runs no action,
1098
+ # so this is the check before a record's current state goes back.
1099
+ def idempotent_replay_allowed?(record)
1100
+ record.owner == current_api_owner
1101
+ end
1102
+ end
1103
+
1104
+ class Api::WebhooksController < Api::BaseController
1105
+ def create
1106
+ webhook = current_api_owner.webhooks.create!(url: params.require(:url))
1107
+ render_resource webhook, status: :created, secret: webhook.secret # the secret is in this answer only
1108
+ end
1109
+ end
1110
+ ```
1111
+
1112
+ How it works:
1113
+
1114
+ - GET and HEAD run as they are. Every other request needs the header: a text of 1 to 255 characters, the same when the client retries.
1115
+ - A write answers with `render_resource(record, status:, **options)`. The key keeps the status and the record, never the response body, so a secret shown once is never stored.
1116
+ - The write, any rows it adds (an audit entry), and the key's answer commit in one transaction, or nothing does. A write that answers anything but 2xx, or raises, commits nothing and leaves the key free, so the corrected request can use it. That transaction is on the database of the `api_keys` tables: a write to another database does not join it.
1117
+ - A retry with the same key and the same request answers the record **as it is now**, through `resource_json` without the first answer's options, with the header `Idempotent-Replayed: true`. Before that, `idempotent_replay_allowed?(record)` must answer true: a record that moved to another owner, or no longer exists, raises `ActiveRecord::RecordNotFound` (Rails answers 404). Both methods are required, and the first write raises `NotImplementedError` without them.
1118
+ - The request is the method, the path, the query and the body. The query and the body count apart, so a name that moves from one to the other makes another request. The order of names does not matter. An uploaded file counts by its bytes, its file name and its content type, and never encodes like an ordinary value.
1119
+ - The around_action runs outside every open transaction: the key's claim must commit on its own, so `ApiKeys::IdempotencyKey.hold` raises inside one. Transactional tests, which open a transaction around each test, set a test-only switch in `config/environments/test.rb` or the test helper: `ApiKeys.configuration.idempotency_in_test_transactions = true`. It takes `true` only when `Rails.env` is test, and counts only while it is test.
1120
+ - Keys are per API key and kept for a day. Run `ApiKeys::IdempotencyKey.prune!` once a day, for example from a recurring job.
1121
+
1122
+ The refusals follow the draft (section 2.7). They render through `render_unauthorized`, like the gem's other refusals, and their messages change with I18n (`api_keys.errors.<code>`):
1123
+
1124
+ | Status | `error` | When |
1125
+ |--------|---------|------|
1126
+ | 400 | `idempotency_key_required` | A write without the header, or with one longer than 255 characters |
1127
+ | 409 | `idempotency_key_in_use` | A request with this key is still running |
1128
+ | 422 | `idempotency_key_reused` | The key was used for another request |
1129
+
1130
+ A retry waits at most two seconds for the request that holds the key, then answers 409, and two first requests with the same key wait for each other's claim at most as long. On PostgreSQL (`lock_timeout`) and MySQL (`innodb_lock_wait_timeout`) the gem sets that timeout for the claim and the lock, then puts the old value back. SQLite has no row locks: a retry waits for the database's write lock, as long as the database's busy timeout. CI checks this on PostgreSQL, MySQL and SQLite with real concurrent connections (`test/databases/check.rb`). Every successful write must answer with `render_resource`; one that does not raises and commits nothing.
1131
+
1132
+ The model works outside a controller too, for example in a tool that shares the API's keys:
1133
+
1134
+ ```ruby
1135
+ digest = ApiKeys::IdempotencyKey.digest_of("TOOL", "create_webhook", arguments)
1136
+ ApiKeys::IdempotencyKey.hold(api_key, key) do |record|
1137
+ next replay(record) if record.answered? # compare record.request_digest with digest first
1138
+
1139
+ record.update!(request_digest: digest)
1140
+ webhook = create_webhook(arguments)
1141
+ record.answer!(200, webhook)
1142
+ end
1143
+ # raises ApiKeys::IdempotencyKey::Busy while another call holds the key
1144
+ ```
1145
+
1146
+ ## Let an agent ask for a key: the device flow (RFC 8628)
1147
+
1148
+ An agent or a CLI should not ask a person to copy a key into it. With the OAuth 2.0 Device Authorization Grant ([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)), the agent asks for a key, the person approves in your app, and the agent receives an ordinary API key:
1149
+
1150
+ 1. The agent asks for codes and shows the person one link and a user code like `BCDF-GHJK`.
1151
+ 2. The person opens the link in your app, signs in, compares the code, sees the agent's name, its device and the scopes, and approves or denies.
1152
+ 3. The agent polls. The first poll after the approval creates the key for the owner the person chose, named "client on device", and answers with its token, once.
1153
+
1154
+ ```bash
1155
+ rails generate api_keys:add_device_authorizations
1156
+ rails db:migrate
1157
+ ```
1158
+
1159
+ **The agent's two endpoints.** Include `ApiKeys::DeviceFlow` in an API controller, name your approval page, and route the two actions:
1160
+
1161
+ ```ruby
1162
+ class Api::DeviceAuthorizationsController < ActionController::API
1163
+ include ApiKeys::DeviceFlow
1164
+
1165
+ private
1166
+
1167
+ # The approval page, with the user code filled in when one is given
1168
+ def device_verification_uri(user_code = nil)
1169
+ device_approval_url(user_code: user_code)
1170
+ end
1171
+ end
1172
+
1173
+ # config/routes.rb
1174
+ post "api/device", to: "api/device_authorizations#create"
1175
+ post "api/token", to: "api/device_authorizations#token"
1176
+ ```
1177
+
1178
+ The agent sends `client_id` (its name), `device_name` (optional) and `scope` (space-separated), and gets the [RFC 8628 §3.2](https://www.rfc-editor.org/rfc/rfc8628#section-3.2) answer:
1179
+
1180
+ ```json
1181
+ { "device_code": "…", "user_code": "BCDF-GHJK",
1182
+ "verification_uri": "https://example.com/device",
1183
+ "verification_uri_complete": "https://example.com/device?user_code=BCDF-GHJK",
1184
+ "expires_in": 600, "interval": 5 }
1185
+ ```
1186
+
1187
+ It polls `token` with `grant_type=urn:ietf:params:oauth:grant-type:device_code`, the `device_code` and the same `client_id`. Until the person decides, it gets the [§3.5](https://www.rfc-editor.org/rfc/rfc8628#section-3.5) errors as `{ "error": …, "error_description": … }` with status 400: `authorization_pending`, `slow_down` (a poll sooner than the interval, which then grows by 5 seconds), `access_denied`, `expired_token`, and `invalid_grant` for an unknown or used device code or another `client_id`. After the approval it gets the key once:
1188
+
1189
+ ```json
1190
+ { "access_token": "ak_…", "token_type": "Bearer", "scope": "read write" }
1191
+ ```
1192
+
1193
+ No answer may be cached: every answer has `Cache-Control: no-store`. An agent may ask for any scope in `config.default_scopes`. A request without `scope` fails with `invalid_scope`, unless the controller defines `device_default_scope` (for example `"read"`).
1194
+
1195
+ The engine adds `device_code`, `user_code` and `token` to `config.filter_parameters`, so request logs never show the codes or a token. If your approval page takes the user code under another parameter name, add that name too.
1196
+
1197
+ **Key types.** Every request names the type of the key it asks for: `device_key_type` in the controller, `config.default_key_type` by default. With key types configured and no type named, the request raises, because that is the app's error, not the client's. The scopes an agent may ask for are then the ones of `config.default_scopes` that the type's `permissions` allow. A type with `scopes: :ceiling` gives every key its whole ceiling, so a request must ask for all of it. The type is stored with the request and is the type of the key: an untyped request stays untyped, and a new default type never applies to it.
1198
+
1199
+ If the configuration changes after the approval so that the key can no longer be made as the person approved it, the poll answers `invalid_scope` (RFC 6749 §5.2) and closes the request (state `void`): key types turned on for an untyped request, the request's type removed or renamed, or a ceiling that grew. Any other failure to make the key closes it too. An owner at its key limit is the one case that waits: the poll answers `authorization_pending`.
1200
+
1201
+ **The person's page** stays in your app, behind your sign-in. Find the request by the code the person typed or followed, show what the agent asks for, and decide:
1202
+
1203
+ ```ruby
1204
+ class DeviceApprovalsController < ApplicationController
1205
+ before_action :authenticate_user!
1206
+ # Look-ups and approvals share one budget, so a guess sent straight to
1207
+ # update costs the same as one typed on the page (RFC 8628 §5.1).
1208
+ rate_limit to: 10, within: 10.minutes, by: -> { current_user.id }, only: %i[show update]
1209
+
1210
+ def show
1211
+ @authorization = ApiKeys::DeviceAuthorization.find_undecided_by_user_code(params[:user_code]) if params[:user_code].present?
1212
+ # Show @authorization.client_id, .device_name, .scopes, .requested_from and the code,
1213
+ # and ask the person to compare the code with the one the agent shows (RFC 8628 §5.4).
1214
+ end
1215
+
1216
+ def update
1217
+ authorization = ApiKeys::DeviceAuthorization.find_undecided_by_user_code(params[:user_code])
1218
+ return redirect_to(device_approval_path) unless authorization
1219
+
1220
+ if params[:decision] == "approve"
1221
+ authorization.approve!(current_organization, by: current_user)
1222
+ else
1223
+ authorization.deny!(by: current_user)
1224
+ end
1225
+ redirect_to device_approval_path, notice: "Done. Go back to the agent."
1226
+ rescue ApiKeys::DeviceAuthorization::Closed
1227
+ redirect_to device_approval_path, alert: "This request expired or was decided already."
1228
+ rescue ApiKeys::DeviceAuthorization::KeyLimitReached
1229
+ redirect_to device_approval_path(user_code: params[:user_code]), alert: "You have the most API keys you may have. Revoke one, then approve again."
1230
+ end
1231
+ end
1232
+ ```
1233
+
1234
+ The rules the model keeps:
1235
+
1236
+ - Both codes are stored as SHA-256 digests. The key's token is never stored: the poll that creates the key is the only answer that carries it.
1237
+ - A request lives ten minutes. The user code is eight letters from twenty consonants ([§6.1](https://www.rfc-editor.org/rfc/rfc8628#section-6.1)), and case, spaces and dashes do not matter when the person types it.
1238
+ - Polls and decisions take the request's row lock. A decided or expired request cannot be decided again: `approve!` and `deny!` raise `ApiKeys::DeviceAuthorization::Closed`.
1239
+ - `approve!` checks the owner's key limit first (`max_keys`, and the `limit` of the request's key type). At the limit it raises `ApiKeys::DeviceAuthorization::KeyLimitReached` and the request stays pending, so the person can revoke a key and approve again before it expires. It also checks the scopes against the configuration as it is now, and raises `ActiveRecord::RecordInvalid` for scopes that a key of the type no longer grants.
1240
+ - A poll never raises because the key cannot be made. If the owner reached its limit after the approval, the poll answers `authorization_pending`, nothing is kept, and a later poll makes the key once there is room. Every other failure closes the request with `invalid_scope`.
1241
+ - A poll with another `client_id` changes nothing ([§3.4](https://www.rfc-editor.org/rfc/rfc8628#section-3.4)), so the grant waits for its own client.
1242
+ - The approving person becomes the key's `created_by` when the column exists, so `config.trusted_key_check` applies to these keys too. Characters a key name cannot hold become hyphens in the key's name.
1243
+
1244
+ What stays in your app:
1245
+
1246
+ - **Rate limits.** Limit the code look-ups and the approvals on the approval page together, per person, so codes cannot be guessed ([§5.1](https://www.rfc-editor.org/rfc/rfc8628#section-5.1)), and the agent's requests per address. A rate limit's `with:` can answer in the RFC's shape: `with: -> { render_oauth_error("slow_down", "Too many requests.", status: :too_many_requests) }`.
1247
+ - **Who may approve,** and for which owner. When a person can switch between organizations, put the owner the page showed in the form and refuse an approval for another one.
1248
+ - **More fields in the token answer,** or an audit row: override `device_token_response(authorization, api_key)` and call `super`. It runs in the transaction that creates the key, so a row it writes to the same database commits with the key, or neither does.
1249
+ - **Housekeeping:** run `ApiKeys::DeviceAuthorization.prune!` once a day.
1250
+
1251
+ Error descriptions change with I18n: `api_keys.device_flow.errors.<error>`.
1252
+
978
1253
  ## Configuration and settings
979
1254
 
980
1255
  The gem installation creates an initializer at `config/initializers/api_keys.rb`
@@ -21,6 +21,10 @@ module ApiKeys
21
21
  # Names the per-key reservation that lets one stats job per kind be
22
22
  # enqueued within a debounce interval (see reserve_stats_enqueue?).
23
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
24
28
 
25
29
  included do
26
30
  # Helper methods to access the authenticated key and its owner
@@ -53,7 +57,13 @@ module ApiKeys
53
57
  private
54
58
 
55
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.
56
65
  def authenticate_api_key!(scope: nil)
66
+ scope = scope_for_request_method(scope) if scope.is_a?(Hash)
57
67
  @current_api_key = nil
58
68
  remove_instance_variable(:@current_api_tenant) if instance_variable_defined?(:@current_api_tenant)
59
69
  log_debug "[ApiKeys Auth] authenticate_api_key! started for request: #{request.uuid}"
@@ -91,6 +101,19 @@ module ApiKeys
91
101
  required_scope: scope
92
102
  )
93
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)
94
117
  else
95
118
  after_auth_context[:required_scope_check] = { required: scope, passed: true } if scope
96
119
  # Authentication and scope check successful, enqueue stats update
@@ -123,7 +146,35 @@ module ApiKeys
123
146
  end
124
147
  end
125
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
+
126
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.
127
178
  def render_unauthorized(error_code:, message:, status: :unauthorized, required_scope: nil)
128
179
  error_message = I18n.t("api_keys.errors.#{error_code}", default: message) rescue message
129
180
  response_body = { error: error_code, message: error_message }
@@ -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
 
@@ -274,6 +299,27 @@ module ApiKeys
274
299
  @tenant_resolver = value
275
300
  end
276
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
+
277
323
  def client_ip_resolver=(value)
278
324
  raise ArgumentError, "client_ip_resolver must be callable" unless value.respond_to?(:call)
279
325
 
@@ -402,6 +448,10 @@ module ApiKeys
402
448
 
403
449
  private
404
450
 
451
+ def test_environment?
452
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test? ? true : false
453
+ end
454
+
405
455
  def validate_resolved_prefix!(prefix)
406
456
  valid = prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64 &&
407
457
  prefix.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
@@ -657,6 +707,12 @@ module ApiKeys
657
707
  # Tenant Resolution
658
708
  @tenant_resolver = ->(api_key) { api_key.owner if api_key.respond_to?(:owner) }
659
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
+
660
716
  # Key Types & Environments (default to empty/disabled for backwards compatibility)
661
717
  @key_types = {}.freeze # Empty = feature disabled, legacy behavior
662
718
  @environments = {}.freeze # Empty = no environment-based prefixes