mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
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.
107 lines
3.3 KiB
TypeScript
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);
|
|
});
|
|
});
|