Files
netris-nestri/packages/core/src/auth/signing-key.test.ts
Wanjohi f64f037574 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.
2026-09-05 14:31:33 +03:00

107 lines
3.3 KiB
TypeScript

import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import type { StoredKey } from '@nestri/auth/key';
import { testDb } from '../db/test.js';
import { PostgresKeyStore } from './signing-key.js';
const sql = testDb();
const store = PostgresKeyStore();
let counter = 0;
function key(overrides: Partial<StoredKey> = {}): StoredKey {
counter += 1;
return {
id: `key-fixture-${counter}`,
publicKey: '-----BEGIN PUBLIC KEY-----fixture-----END PUBLIC KEY-----',
privateKey: '-----BEGIN PRIVATE KEY-----fixture-----END PRIVATE KEY-----',
alg: 'ES256',
created: Date.now(),
...overrides
};
}
async function cleanup() {
await sql`delete from auth_key where key_id like 'key-fixture-%'`;
}
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await sql.end();
});
describe('PostgresKeyStore', () => {
test('a kind with no keys lists empty', async () => {
expect(await store.list('encryption')).toEqual([]);
});
test('a stored key comes back with its fields intact', async () => {
const k = key();
await store.create('signing', k);
const [found] = await store.list('signing');
expect(found?.id).toBe(k.id);
expect(found?.alg).toBe('ES256');
expect(found?.privateKey).toBe(k.privateKey);
// Absent rather than null: a key still in use has no expiry.
expect(found?.expired).toBeUndefined();
});
test('the two kinds do not see each other', async () => {
await store.create('signing', key());
await store.create('encryption', key({ alg: 'RSA-OAEP-512' }));
expect(await store.list('signing')).toHaveLength(1);
expect((await store.list('encryption'))[0]?.alg).toBe('RSA-OAEP-512');
});
test('a retired key is still listed, so the tokens it signed still verify', async () => {
const expired = Date.now() - 1000;
await store.create('signing', key({ expired }));
const [found] = await store.list('signing');
expect(found?.expired).toBe(expired);
});
test('writing the same key id twice is not an error and does not duplicate it', async () => {
const k = key();
await store.create('signing', k);
await store.create('signing', k);
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);
});
});