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

@@ -42,6 +42,23 @@ export interface StoredKey {
export interface KeyStore {
/** Every key of a kind, expired ones included. Order does not matter. */
list(kind: KeyKind): Promise<StoredKey[]>;
/**
* Add a key, unless the kind already has a live one.
*
* A store that lets two live keys of one kind exist at the same time
* breaks things that are hard to see. Two issuers starting against an empty
* store both find nothing, both generate a key, and from then on each signs
* and encrypts with its own: a session cookie written by one is
* undecryptable to the other, and an access token minted by one is rejected
* by the other as invalid — because both reach for a single key rather than
* trying the published set. Losing the second write is the whole point, so
* this is not an error and reports nothing; the caller reads the list again
* and uses whichever key survived.
*
* Retiring a key and creating its replacement therefore have to happen
* together, so that the kind never has two live keys and never has none.
*/
create(kind: KeyKind, key: StoredKey): Promise<void>;
}
@@ -56,6 +73,13 @@ function prefix(kind: KeyKind): string {
* The default, and what every deployment used before `keyStore` existed — the
* keys are read and written under exactly the prefixes they always were, so an
* issuer that does not pass a store keeps finding the keys it already had.
*
* It cannot honour the one-live-key rule `create` asks for: through get and
* set there is no way to make "write unless one exists" a single operation.
* Two issuers bootstrapping against an empty store at the same moment will
* therefore end up with a key each, with the consequences described above. A
* store that can express a conditional write does not have this problem, and
* is what a deployment running more than one instance wants.
*/
export function StorageKeyStore(storage: StorageAdapter): KeyStore {
return {

View File

@@ -37,7 +37,7 @@ async function toKeyPair(kind: KeyKind, stored: StoredKey): Promise<KeyPair> {
if (kind === 'signing') jwk.use = 'sig';
return {
id: stored.id,
alg: alg[kind],
alg: stored.alg,
created: new Date(stored.created),
expired: stored.expired ? new Date(stored.expired) : undefined,
public: publicKey,
@@ -49,17 +49,30 @@ async function toKeyPair(kind: KeyKind, stored: StoredKey): Promise<KeyPair> {
/**
* Every key of a kind, newest first, creating one if none is usable.
*
* Expired keys are returned alongside live ones and sorted after them: the
* first entry is what signs, and the rest are what still verifies. Retiring a
* key therefore does not invalidate the tokens it signed — they age out on
* their own — which is the only way a rotation is not also a mass sign-out.
* Expired keys are returned alongside live ones and sorted after them, so the
* first entry is the one that signs. Publishing the rest is what lets a
* verifier reading the JWKS still check a token signed before a rotation.
*
* A store may refuse the write, and is expected to when another issuer has
* already created a key for this kind — see the note on {@link KeyStore.create}
* about why two live keys of one kind is not a state this can be left in. So
* the created key is never returned directly: the list is read again, and
* whichever key the store actually kept is the one everybody uses.
*/
async function keysOf(store: KeyStore, kind: KeyKind): Promise<KeyPair[]> {
async function keysOf(store: KeyStore, kind: KeyKind, bootstrapped = false): Promise<KeyPair[]> {
const stored = await store.list(kind);
const results = await Promise.all(stored.map((k) => toKeyPair(kind, k)));
results.sort((a, b) => b.created.getTime() - a.created.getTime());
if (results.some((item) => !item.expired)) return results;
// One attempt, and then an error rather than another try. A store that
// accepts neither the write nor another issuer's would otherwise spin here
// forever, and a request that hangs is a worse way to learn about it than
// a request that fails.
if (bootstrapped) {
throw new Error(`Unable to create a ${kind} key: the store reports none after writing one.`);
}
const key = await generateKeyPair(alg[kind], { extractable: true });
const created: StoredKey = {
id: crypto.randomUUID(),
@@ -69,9 +82,7 @@ async function keysOf(store: KeyStore, kind: KeyKind): Promise<KeyPair[]> {
alg: alg[kind]
};
await store.create(kind, created);
// Read back rather than returning what was just built, so that two issuers
// starting at once converge on whichever key the store actually kept.
return keysOf(store, kind);
return keysOf(store, kind, true);
}
export function signingKeys(store: KeyStore): Promise<KeyPair[]> {