mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
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:
@@ -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");
|
||||
@@ -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": {},
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1788605264671,
|
||||
"when": 1788607804606,
|
||||
"tag": "0011_auth_state_in_postgres",
|
||||
"breakpoints": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user