Repository navigation
api_keys 0.7.0: device flow, the person behind a key, Idempotency-Key, scope by method
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_typein the controller,config.default_key_typeby 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 raisesKeyLimitReachedat the limit; the request stays pending. The type is stored with the request and is never resolved again: an untyped request stays untyped. Ascopes: :ceilingtype 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 answerinvalid_scopeand 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 answersauthorization_pending, and a later poll makes the key once there is room; any other failure closes the request.ApiKeys::DeviceFlowgives an API controller thecreateandtokenactions with the RFC 8628 answers, the RFC 6749 error shape andCache-Control: no-store; the host names its approval page indevice_verification_uri, and may add fields to the token answer indevice_token_response, which runs in the transaction that creates the key. The engine addsdevice_code,user_codeandtokentoconfig.filter_parameters.rails generate api_keys:add_device_authorizationscreates 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 onapi_keys, is set bycreate_api_key!(created_by:)and cannot change after creation: a validation, andattr_readonlyforupdate_columnand saves without validations.config.trusted_key_checkis a lambda thatauthenticate_api_key!calls with the key after the scope check; a false answer refuses the request with 403untrusted_keythroughrender_unauthorized. The device flow names the approving person ascreated_by. The mounted dashboard leavescreated_bynil; an app that needs it creates keys in its own controller. New installs get the columns; existing installs runrails 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::Idempotencygives an API controller therun_once_per_idempotency_keyaround_action andrender_resource; the host definesresource_jsonandidempotent_replay_allowed?. A retry answers the record as it is now withIdempotent-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.holdrefuses every open transaction; transactional tests opt in with the test-onlyconfig.idempotency_in_test_transactions, which takestrueonly whenRails.envis 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 400idempotency_key_required, 409idempotency_key_in_useand 422idempotency_key_reused, after draft-ietf-httpapi-idempotency-key-header section 2.7.rails generate api_keys:add_idempotency_keyscreates 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_keyjoins the refusal codes that are stamped on a key (last_refusal_code) and explained byapi_key_refusal_reason. It is stamped only whenconfig.trusted_key_checkrefuses a key.created_by_typeandcreated_by_idjoin the attributes that cannot change after a key is created.ApiKey::MAX_NAME_LENGTHandApiKey::NAME_FORMATname 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_idchanges 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, MySQLinnodb_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_checkfails 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_byfor the person behind a key,add_device_authorizationsfor the device flow,add_idempotency_keysfor 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)andTokenSession.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_keyrefusal 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_byhave nocreated_by. Ifconfig.trusted_key_checkneeds one, backfill the columns in a migration with SQL (update_all): the columns are read-only for a saved key. config.filter_parametersgainsdevice_code,user_codeandtoken.- An app whose tests are transactional and use
ApiKeys::IdempotencysetsApiKeys.configuration.idempotency_in_test_transactions = truein 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