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

@@ -1,3 +1,4 @@
import { sql } from 'drizzle-orm';
import { pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
@@ -14,9 +15,17 @@ export const AuthKeyKindEnum = pgEnum('auth_key_kind', ['signing', 'encryption']
* not query patterns, just that this is the row nobody can afford to have
* quietly evicted.
*
* Retiring a key sets `expired_at` instead of deleting it. The tokens it
* signed stay verifiable until they age out on their own, which is what keeps
* a rotation from also being a mass sign-out.
* Retiring a key sets `expired_at` instead of deleting it, so a verifier
* reading the published JWKS can still check a token signed before a rotation.
*
* At most one key of a kind may be live at a time, and the partial unique index
* is what enforces it rather than a 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 — so a session cookie written by one is
* undecryptable to the other, and an access token minted by one is rejected by
* the other. Both reach for a single key rather than trying the whole set, so
* the split is silent until someone cannot sign in. With the index the second
* insert is dropped, both read the table again, and both use the key that won.
*/
export const AuthKeyTable = pgTable(
'auth_key',
@@ -36,5 +45,10 @@ export const AuthKeyTable = pgTable(
/** Null while the key is still in use. Set when it is retired. */
expiredAt: utc('expired_at')
},
(t) => [uniqueIndex('auth_key_key_id_unique').on(t.keyId)]
(t) => [
uniqueIndex('auth_key_key_id_unique').on(t.keyId),
uniqueIndex('auth_key_one_live_per_kind')
.on(t.kind)
.where(sql`${t.expiredAt} is null`)
]
);

View File

@@ -71,4 +71,36 @@ describe('PostgresKeyStore', () => {
expect(await store.list('signing')).toHaveLength(1);
});
test('a kind cannot end up with two live keys', async () => {
await store.create('signing', key());
// Dropped rather than refused: the caller reads the list again and uses
// whichever key is there, so losing this write is the intended outcome.
await store.create('signing', key());
expect(await store.list('signing')).toHaveLength(1);
});
/**
* The bootstrap race, which is what the constraint is for. Two workers
* starting against an empty table would otherwise end up with a key each,
* and from then on neither can read what the other wrote.
*/
test('simultaneous bootstraps converge on one key', async () => {
await Promise.all(Array.from({ length: 5 }, () => store.create('signing', key())));
expect(await store.list('signing')).toHaveLength(1);
});
test('a replacement is allowed once the previous key is retired', async () => {
const first = key();
await store.create('signing', first);
await sql`update auth_key set expired_at = now() where key_id = ${first.id}`;
await store.create('signing', key());
const all = await store.list('signing');
expect(all).toHaveLength(2);
expect(all.filter((k) => !k.expired)).toHaveLength(1);
});
});

View File

@@ -21,11 +21,15 @@ function toStored(row: Row): StoredKey {
/**
* The issuer's signing and encryption keys, in Postgres.
*
* Two issuers starting against an empty table both generate a key and both
* insert one, and that is fine: each is valid, both are published in the JWKS,
* so a token signed by either verifies against either issuer. The conflict
* clause is not for that race — a key id is a fresh UUID and cannot collide
* with another issuer's — it is so that a retried write is not an error.
* `create` drops its write when the kind already has a live key, which is what
* the interface asks for and what keeps two workers bootstrapping at the same
* moment from ending up with a key each. Which of them wins does not matter.
* That they end up agreeing does — a key each means cookies one worker writes
* are unreadable to the other, and tokens one mints are rejected by the other.
*
* The conflict clause names no target on purpose: both unique indexes on this
* table mean the same thing here, that the row we wanted already exists in some
* form, and the answer to either is to keep what is there and read it back.
*/
export function PostgresKeyStore(): KeyStore {
return {
@@ -52,7 +56,7 @@ export function PostgresKeyStore(): KeyStore {
privateKey: key.privateKey,
expiredAt: key.expired ? new Date(key.expired) : null
})
.onConflictDoNothing({ target: AuthKeyTable.keyId });
.onConflictDoNothing();
});
}
};