fix(auth): keep one live key per kind, and report a key's own algorithm

Two problems found in review, both in the key store.

Nothing stopped a kind from having two live keys, and the bootstrap path
walks straight into it: two workers starting against an empty table both
find no key and both insert one. From then on each signs and encrypts with
its own. That is not the harmless split the comment here claimed — the
issuer reaches for a single key rather than the published set when it
decrypts a session cookie and when it verifies an access token, so a cookie
written by one worker is unreadable to the other and a token minted by one
is rejected by the other. It stays silent until someone cannot sign in.

A partial unique index over the kind, where the key has not been retired,
makes the second insert a dropped write instead. Both workers then read the
table again and use the key that won, which is all that matters. The
conflict clause stops naming a target: both indexes on the table mean the
same thing at this call site, that the row already exists in some form.

Creating a key is now attempted once rather than retried, because a store
declining the write is an expected answer and spinning on it would hang the
request instead of failing it.

Separately, a key pair reported the algorithm the issuer currently uses
rather than the one stored on the key it was built from, so a retained key
would advertise the wrong algorithm in a token header and in the JWKS after
a rotation — which defeats keeping it. The material was already being
imported with the stored value; only what was handed back disagreed.

Retiring a key and creating its replacement now have to happen together, so
that a kind never has two live keys and never has none.
This commit is contained in:
Wanjohi
2026-09-05 14:31:33 +03:00
parent f25c9af545
commit f64f037574
8 changed files with 139 additions and 26 deletions

View File

@@ -14,11 +14,22 @@
-- `update ... where time_used is null returning *`, so exactly one caller is
-- told it went first.
--
-- `auth_key` is different: nothing races for it. It is here because it is the
-- one record whose loss ends every session at once, and a cache is a place
-- things are allowed to be evicted from. Keys are retired by setting
-- `expired_at`, never deleted, so the tokens they signed stay verifiable until
-- they expire on their own.
-- `auth_key` is here for a different reason: it is the one record whose loss
-- ends every session at once, and a cache is a place things are allowed to be
-- evicted from. It gets a conditional write too, though. At most one key of a
-- kind may be live, which `auth_key_one_live_per_kind` enforces rather than
-- leaving to convention — without it, two workers starting against an empty
-- table both find no key, both insert one, and each then signs and encrypts
-- with its own. A session cookie written by one is undecryptable to the other
-- and a token minted by one is rejected by the other, because both reach for a
-- single key rather than trying the whole published set. The split is silent
-- until someone cannot sign in. With the index the second insert is dropped and
-- both workers use the key that won.
--
-- Keys are retired by setting `expired_at`, never deleted, so a verifier
-- reading the published JWKS can still check a token signed before a rotation.
-- Retiring one and creating its replacement have to happen together, so the
-- kind never has two live keys and never has none.
--
-- Both credential tables store a hash and never the credential. An
-- authorization code travels in a query string and a refresh token resumes a
@@ -85,4 +96,5 @@ CREATE UNIQUE INDEX "authorization_code_hash_unique" ON "authorization_code" USI
CREATE UNIQUE INDEX "refresh_token_hash_unique" ON "refresh_token" USING btree ("token_hash");--> statement-breakpoint
CREATE INDEX "refresh_token_subject_idx" ON "refresh_token" USING btree ("subject");--> statement-breakpoint
CREATE UNIQUE INDEX "auth_key_key_id_unique" ON "auth_key" USING btree ("key_id");--> statement-breakpoint
CREATE UNIQUE INDEX "auth_key_one_live_per_kind" ON "auth_key" USING btree ("kind") WHERE "auth_key"."expired_at" is null;--> statement-breakpoint
CREATE UNIQUE INDEX "auth_kv_key_unique" ON "auth_kv" USING btree ("key");

View File

@@ -1,5 +1,5 @@
{
"id": "e2a5e726-9f05-4bf0-9bd9-27cb7501f26e",
"id": "6fdda454-c7ee-42c1-931b-fa595385aac0",
"prevId": "8915fb5b-8f0d-4f6f-a808-27b19bb4604a",
"version": "7",
"dialect": "postgresql",
@@ -528,6 +528,22 @@
"concurrently": false,
"method": "btree",
"with": {}
},
"auth_key_one_live_per_kind": {
"name": "auth_key_one_live_per_kind",
"columns": [
{
"expression": "kind",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"where": "\"auth_key\".\"expired_at\" is null",
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},

View File

@@ -82,7 +82,7 @@
{
"idx": 11,
"version": "7",
"when": 1788605264671,
"when": 1788607804606,
"tag": "0011_auth_state_in_postgres",
"breakpoints": true
}