Skip to content

api_keys 0.7.0: device flow, the person behind a key, Idempotency-Key, scope by method

Choose a tag to compare

@rameerez rameerez released this 24 Oct 23:34
· 2 commits to main since this release
Immutable release. Only release title and notes can be modified.
a8aabb8

Four opt-in features ported from an app in production: the RFC 8628 device authorization grant, the person behind a key, Idempotency-Key for writes, and scope by HTTP method. Three upgrade generators; nothing changes for an app that sets none of it.

Why

Agents and CLIs need their own API key without a person copying a secret from a browser; a key owned by an organization must stop when the person who approved it leaves; a client must be able to retry a write after a timeout without creating a second record; and an API wants reads and writes on different scopes in one line. One app built all four and ran them; this release moves them into the gem with their tests.

What changed

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.

Added

  • 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.
  • 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.
  • 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.
  • 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.

Changed

  • 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.
  • created_by_type and created_by_id join the attributes that cannot change after a key is created.
  • ApiKey::MAX_NAME_LENGTH and ApiKey::NAME_FORMAT name the existing key name rule, which the device flow uses to name its keys.

Security

  • 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.
  • 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.
  • config.trusted_key_check fails closed: an error in the lambda fails the request and never passes it as trusted.

Upgrade notes

  • 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.
  • 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.
  • 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.
  • 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.
  • config.filter_parameters gains device_code, user_code and token.
  • An app whose tests are transactional and use ApiKeys::Idempotency sets ApiKeys.configuration.idempotency_in_test_transactions = true in its test environment.

Review

Three adversarial review passes before merge (PR #76), fifteen findings fixed in total. Among them: a replay that skipped the resource's ownership check (now a required host hook), a device approval that could grant more than approved with ceiling key types (the key type is stored with the request and revalidated at issuance; an impossible key voids the request with invalid_scope), device codes missing from filter_parameters, a JSON literal default that MySQL 8 refuses, unbounded lock waits on the idempotency claim and a test-only transaction exemption that could leak into other environments (now refused outside the test environment at assignment and at use), request fingerprints that merged query over body and conflated uploads, and created_by immutability that update_column bypassed. The third pass signed off.

Known limitation

The original install migration fails on MySQL 8 because of a JSON literal default; tracked in #77. The three new generators avoid it.

Automatically generated notes

What's Changed

  • Bump the codeql-action group with 2 updates by @dependabot[bot] in #63
  • 0.7.0: device authorization grant, the person behind a key, Idempotency-Key, scope by method by @rameerez in #76

Full Changelog: v0.6.0...v0.7.0