feat(auth): keep issuer state in Postgres

The issuer kept everything behind one get/set/remove/scan interface, which
is what a library that must run on any provider's cache can offer. Three of
the things kept there could not actually be served by it.

An authorization code must be redeemable once and a refresh token spendable
once, and through get and set the check and the write are separate steps —
so two requests arriving together both read an unspent record, and both mint
a session. In the refresh case that also means the reuse which reveals a
stolen token is never recorded, because recording it is the write that the
second caller overwrites. Each now has a table and an interface of its own:
redeeming is one `delete ... returning`, spending is one
`update ... where time_used is null returning *`, so exactly one caller is
ever told it went first. This is the same argument the device grant already
made, applied to the two records that had it too.

Signing keys move for a different reason. Nothing races for them; they are
the one record whose loss ends every session at once, and a cache is a place
things may be evicted from. They are retired by setting a column rather than
deleted, so the tokens they signed stay verifiable until they expire.

Both credential tables store a hash and never the credential, as the device
grant does. An authorization code travels in a query string and so passes
through history, referrer headers and any log along the redirect; a refresh
token resumes a session outright.

What is left in the generic store is the rate-limit counters — written far
more often than read, meaningless within the hour, and allowed to be
approximate, since a lost increment costs one guess out of ten. Those move
to Postgres too, so the only key-value binding this deploys with is gone and
the control plane's state is one database. That was the point: nothing here
now depends on a primitive a self-hoster cannot run.

The generic scan also gained the separator on its prefix, so scanning `a`
cannot return what is under `ab` — subjects and email addresses are both
prefixes of longer subjects and email addresses.

Deploying this signs everyone out. The signing keys and refresh tokens are
in a store that is being left behind, so the issuer starts with a fresh key
set and every existing token stops verifying.
This commit is contained in:
Wanjohi
2026-09-05 13:56:39 +03:00
parent 349305d0cc
commit f25c9af545
26 changed files with 4241 additions and 185 deletions

View File

@@ -0,0 +1,38 @@
import { jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
/**
* An authorization code, between the redirect that issued it and the exchange
* that spends it.
*
* It lives sixty seconds, which is the same argument the device grant makes:
* short-lived state in a table anyway, because redeeming it has to happen
* exactly once and a store that reads and writes whole records cannot promise
* that. Here the exchange is one `delete ... returning`, so of two requests
* carrying the same code exactly one is answered — and the other is answered
* as though the code never existed, which from the outside it now does not.
*
* `code_hash` and not the code. A code travels to the browser in a query
* string, so it passes through history, referrer headers and any log along the
* redirect. What is kept is enough to recognise one and not enough to present
* it.
*/
export const AuthorizationCodeTable = pgTable(
'authorization_code',
{
...id,
...timestamps,
codeHash: text('code_hash').notNull(),
expiresAt: utc('expires_at').notNull(),
/**
* Who the code stands for and what it may be exchanged under: the
* subject, the client, the redirect it was issued against, the token
* lifetimes, and the PKCE challenge if there was one.
*/
payload: jsonb('payload').$type<Record<string, unknown>>().notNull()
},
(t) => [uniqueIndex('authorization_code_hash_unique').on(t.codeHash)]
);

View File

@@ -0,0 +1,92 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import type { AuthorizationCodeRecord } from '@nestri/auth/authorization-code';
import { testDb } from '../db/test.js';
import { PostgresCodeStore } from './authorization-code.js';
const sql = testDb();
const store = PostgresCodeStore();
let counter = 0;
function hash(): string {
counter += 1;
return `authcode-fixture-${counter}`.padEnd(64, '0');
}
function record(): AuthorizationCodeRecord {
return {
type: 'user',
properties: { userID: 'usr_fixture' },
subject: 'user:authcode-fixture',
clientID: 'desktop',
redirectURI: 'https://example.com/callback',
ttl: { access: 60, refresh: 600 }
};
}
async function cleanup() {
await sql`delete from authorization_code where code_hash like 'authcode-fixture-%'`;
}
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await sql.end();
});
describe('PostgresCodeStore', () => {
test('an unknown code returns null', async () => {
expect(await store.consume(hash())).toBeNull();
});
test('consuming returns the record it was created with', async () => {
const h = hash();
await store.create(h, record(), 60);
const consumed = await store.consume(h);
expect(consumed?.redirectURI).toBe('https://example.com/callback');
expect(consumed?.clientID).toBe('desktop');
});
test('a code is redeemable once', async () => {
const h = hash();
await store.create(h, record(), 60);
expect(await store.consume(h)).not.toBeNull();
expect(await store.consume(h)).toBeNull();
});
/**
* The property the table exists for: two exchanges of one code arriving
* together must not both be answered, because each answer is a full
* session. Started without awaiting in turn so they really do overlap.
*/
test('only one of several simultaneous exchanges is served', async () => {
const h = hash();
await store.create(h, record(), 60);
const results = await Promise.all(Array.from({ length: 5 }, () => store.consume(h)));
expect(results.filter((r) => r !== null)).toHaveLength(1);
});
test('an expired code cannot be redeemed', async () => {
const h = hash();
await store.create(h, record(), 60);
await sql`update authorization_code set expires_at = now() - interval '1 second' where code_hash = ${h}`;
expect(await store.consume(h)).toBeNull();
});
test('creating sweeps codes that have already expired', async () => {
const stale = hash();
await store.create(stale, record(), 60);
await sql`update authorization_code set expires_at = now() - interval '1 second' where code_hash = ${stale}`;
await store.create(hash(), record(), 60);
const [row] = await sql`select count(*)::int as n from authorization_code where code_hash = ${stale}`;
expect(row!.n).toBe(0);
});
});

View File

@@ -0,0 +1,54 @@
import type { AuthorizationCodeRecord, CodeStore } from '@nestri/auth/authorization-code';
import { and, eq, lt, sql } from 'drizzle-orm';
import { Database } from '../db/index.js';
import { Identifier } from '../id.js';
import { AuthorizationCodeTable } from './authorization-code.sql.js';
/**
* Authorization codes, kept where redeeming one can be a single statement.
*
* `consume` is a delete that returns what it deleted, which is the whole point
* of the table: it is what makes a code redeemable once rather than
* approximately once. A select, a decision in application code and a delete
* would answer two simultaneous exchanges of the same code, and each answer is
* a complete session.
*/
export function PostgresCodeStore(): CodeStore {
return {
async create(codeHash, record, ttl) {
await Database.use(async (tx) => {
// Swept here rather than on a schedule. A code lives a minute
// and this is the only statement that adds one, so the table
// stays bounded by how many sign-ins are mid-redirect.
await tx
.delete(AuthorizationCodeTable)
.where(lt(AuthorizationCodeTable.expiresAt, new Date()));
await tx.insert(AuthorizationCodeTable).values({
id: Identifier.ascending('authorizationCode'),
codeHash,
expiresAt: new Date(Date.now() + ttl * 1000),
payload: record as unknown as Record<string, unknown>
});
});
},
async consume(codeHash) {
return Database.use(async (tx) =>
tx
.delete(AuthorizationCodeTable)
.where(
and(
eq(AuthorizationCodeTable.codeHash, codeHash),
sql`${AuthorizationCodeTable.expiresAt} > now()`
)
)
.returning({ payload: AuthorizationCodeTable.payload })
.then((rows) =>
rows[0] ? (rows[0].payload as unknown as AuthorizationCodeRecord) : null
)
);
}
};
}

View File

@@ -0,0 +1,49 @@
import { index, jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
/**
* A refresh token: the longest-lived credential the issuer hands out.
*
* Two things make this a table rather than a cache entry, and neither is
* durability.
*
* The first is `time_used`. Reuse detection works by remembering when a token
* was first spent, so the check that it has not been spent and the record that
* it now has must be the same operation. Read it, compare, write it back, and
* two refreshes arriving together both look like the first one — which is
* exactly the case reuse detection exists to catch. Here it is one
* `update ... where time_used is null returning *`, so of two callers only one
* is ever told it went first.
*
* The second is that these rows are a person's sessions. Signing out
* everywhere, and the mass revocation that follows a detected reuse, are a
* query over `subject` — which is a thing to be indexed rather than a prefix
* scan over every key in a store.
*
* `token_hash` and not the token. Whoever holds a refresh token can resume the
* session it belongs to, so a readable store would otherwise be a readable set
* of every live session.
*/
export const RefreshTokenTable = pgTable(
'refresh_token',
{
...id,
...timestamps,
/** The issuer's subject string, e.g. `user:0123456789abcdef`. */
subject: text('subject').notNull(),
tokenHash: text('token_hash').notNull(),
expiresAt: utc('expires_at').notNull(),
/** Null until the token is spent. Written exactly once, by whoever spends it. */
timeUsed: utc('time_used'),
/** The subject type, properties, client and token lifetimes this stands for. */
payload: jsonb('payload').$type<Record<string, unknown>>().notNull()
},
(t) => [
uniqueIndex('refresh_token_hash_unique').on(t.tokenHash),
index('refresh_token_subject_idx').on(t.subject)
]
);

View File

@@ -0,0 +1,137 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import type { RefreshRecord } from '@nestri/auth/refresh';
import { testDb } from '../db/test.js';
import { PostgresRefreshStore } from './refresh-token.js';
const sql = testDb();
const store = PostgresRefreshStore();
const SUBJECT = 'user:refresh-fixture';
let counter = 0;
function hash(): string {
counter += 1;
return `refresh-fixture-${counter}`.padEnd(64, '0');
}
function record(overrides: Partial<RefreshRecord> = {}): RefreshRecord {
return {
type: 'user',
properties: { userID: 'usr_fixture' },
subject: SUBJECT,
clientID: 'desktop',
ttl: { access: 60, refresh: 600 },
nextToken: 'next',
...overrides
};
}
async function cleanup() {
await sql`delete from refresh_token where token_hash like 'refresh-fixture-%'`;
}
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await sql.end();
});
describe('PostgresRefreshStore', () => {
test('an unknown token is missing rather than an error', async () => {
const claim = await store.claim(SUBJECT, hash(), Date.now(), 60);
expect(claim.status).toBe('missing');
});
test('a token belonging to another subject is not spendable', async () => {
const h = hash();
await store.create(SUBJECT, h, record(), 600);
const claim = await store.claim('user:someone-else', h, Date.now(), 60);
expect(claim.status).toBe('missing');
});
test('the first claim is fresh and carries the record back', async () => {
const h = hash();
await store.create(SUBJECT, h, record(), 600);
const claim = await store.claim(SUBJECT, h, Date.now(), 60);
expect(claim.status).toBe('fresh');
if (claim.status !== 'fresh') throw new Error('unreachable');
expect(claim.record.clientID).toBe('desktop');
expect(claim.record.nextToken).toBe('next');
});
test('a second claim reports the reuse and when the token was first spent', async () => {
const h = hash();
const at = Date.now();
await store.create(SUBJECT, h, record(), 600);
await store.claim(SUBJECT, h, at, 60);
const again = await store.claim(SUBJECT, h, at + 1000, 60);
expect(again.status).toBe('reused');
if (again.status !== 'reused') throw new Error('unreachable');
// The time the *first* caller spent it, not the time of this attempt —
// which is what the reuse window is measured from.
expect(again.timeUsed).toBe(at);
});
/**
* The property the whole table exists for.
*
* Five claims of one token, started together and never awaited in turn, so
* they genuinely overlap in the database rather than queueing behind each
* other. Exactly one may be told it went first; through a store that reads
* and writes whole records, all five are.
*/
test('only one of several simultaneous claims is fresh', async () => {
const h = hash();
const at = Date.now();
await store.create(SUBJECT, h, record(), 600);
const claims = await Promise.all(
Array.from({ length: 5 }, () => store.claim(SUBJECT, h, at, 60))
);
expect(claims.filter((c) => c.status === 'fresh')).toHaveLength(1);
expect(claims.filter((c) => c.status === 'reused')).toHaveLength(4);
});
test('with no retention the token is taken away, and later claims find nothing', async () => {
const h = hash();
await store.create(SUBJECT, h, record(), 600);
const first = await store.claim(SUBJECT, h, Date.now(), 0);
const second = await store.claim(SUBJECT, h, Date.now(), 0);
expect(first.status).toBe('fresh');
// Not `reused`: nothing was retained, so there is nothing left to
// recognise. Reuse detection is what retention buys.
expect(second.status).toBe('missing');
});
test('an expired token cannot be spent', async () => {
const h = hash();
await store.create(SUBJECT, h, record(), 600);
await sql`update refresh_token set expires_at = now() - interval '1 second' where token_hash = ${h}`;
const claim = await store.claim(SUBJECT, h, Date.now(), 60);
expect(claim.status).toBe('missing');
});
test('removing a subject takes every token it has and leaves other subjects alone', async () => {
const mine = [hash(), hash()];
const theirs = hash();
for (const h of mine) await store.create(SUBJECT, h, record(), 600);
await store.create('user:other', theirs, record({ subject: 'user:other' }), 600);
await store.removeSubject(SUBJECT);
for (const h of mine) {
expect((await store.claim(SUBJECT, h, Date.now(), 60)).status).toBe('missing');
}
expect((await store.claim('user:other', theirs, Date.now(), 60)).status).toBe('fresh');
});
});

View File

@@ -0,0 +1,84 @@
import type { RefreshClaim, RefreshRecord, RefreshStore } from '@nestri/auth/refresh';
import { and, eq, isNull, lt, sql } from 'drizzle-orm';
import { Database } from '../db/index.js';
import { Identifier } from '../id.js';
import { RefreshTokenTable } from './refresh-token.sql.js';
/**
* Refresh tokens, kept where spending one is a single statement.
*
* `claim` is the reason this exists. Everything else here is ordinary.
*/
export function PostgresRefreshStore(): RefreshStore {
return {
async create(subject, tokenHash, record, ttl) {
await Database.use(async (tx) => {
// Swept on the statement that adds rows, as everywhere else in
// this module. A refresh token lives a year by default, so this
// sweep is about the tokens that were spent and retained for
// reuse detection rather than about the live ones.
await tx.delete(RefreshTokenTable).where(lt(RefreshTokenTable.expiresAt, new Date()));
await tx.insert(RefreshTokenTable).values({
id: Identifier.ascending('refreshToken'),
subject,
tokenHash,
expiresAt: new Date(Date.now() + ttl * 1000),
timeUsed: record.timeUsed ? new Date(record.timeUsed) : null,
payload: record as unknown as Record<string, unknown>
});
});
},
async claim(subject, tokenHash, at, retainFor) {
const live = and(
eq(RefreshTokenTable.tokenHash, tokenHash),
eq(RefreshTokenTable.subject, subject),
sql`${RefreshTokenTable.expiresAt} > now()`
);
return Database.use(async (tx): Promise<RefreshClaim> => {
// Nothing is retained, so spending the token is taking it away.
// One caller gets the row; every other attempt reads `missing`,
// which is the correct answer once it no longer exists.
if (retainFor <= 0) {
const [row] = await tx.delete(RefreshTokenTable).where(live).returning();
if (!row) return { status: 'missing' };
return { status: 'fresh', record: row.payload as unknown as RefreshRecord };
}
// `where time_used is null` is what makes going first happen
// once. Two refreshes arriving together both run this; the
// second matches no row, because by then `time_used` is set.
// The expiry is pushed out to the retention window so the spent
// record survives long enough to recognise a reuse.
const [claimed] = await tx
.update(RefreshTokenTable)
.set({ timeUsed: new Date(at), expiresAt: new Date(at + retainFor * 1000) })
.where(and(live, isNull(RefreshTokenTable.timeUsed)))
.returning();
if (claimed) {
return { status: 'fresh', record: claimed.payload as unknown as RefreshRecord };
}
// Either it was spent already or it was never here. Reading now
// is safe where reading first was not: `time_used` is written
// once and never changes, so there is no decision left to race.
const [existing] = await tx.select().from(RefreshTokenTable).where(live);
if (!existing?.timeUsed) return { status: 'missing' };
return {
status: 'reused',
record: existing.payload as unknown as RefreshRecord,
timeUsed: existing.timeUsed.getTime()
};
});
},
async removeSubject(subject) {
await Database.use(async (tx) => {
await tx.delete(RefreshTokenTable).where(eq(RefreshTokenTable.subject, subject));
});
}
};
}

View File

@@ -0,0 +1,40 @@
import { pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
export const AuthKeyKindEnum = pgEnum('auth_key_kind', ['signing', 'encryption']);
/**
* The issuer's own key material.
*
* The longest-lived thing here by a wide margin, and the only record whose
* loss is felt by everyone at once: every access token names the key that
* signed it, so a fresh key set means every session ends and every client has
* to sign in again. That is the argument for a table rather than a cache —
* 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.
*/
export const AuthKeyTable = pgTable(
'auth_key',
{
...id,
...timestamps,
/** The issuer's own identifier for the key, and the `kid` on the JWT. */
keyId: text('key_id').notNull(),
kind: AuthKeyKindEnum('kind').notNull(),
/** JWA name, on the row rather than assumed, so a rotation may change it. */
alg: text('alg').notNull(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
/** 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)]
);

View File

@@ -0,0 +1,74 @@
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);
});
});

View File

@@ -0,0 +1,59 @@
import type { KeyKind, KeyStore, StoredKey } from '@nestri/auth/key';
import { eq } from 'drizzle-orm';
import { Database } from '../db/index.js';
import { Identifier } from '../id.js';
import { AuthKeyTable } from './signing-key.sql.js';
type Row = typeof AuthKeyTable.$inferSelect;
function toStored(row: Row): StoredKey {
return {
id: row.keyId,
publicKey: row.publicKey,
privateKey: row.privateKey,
alg: row.alg,
created: row.timeCreated.getTime(),
expired: row.expiredAt?.getTime()
};
}
/**
* 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.
*/
export function PostgresKeyStore(): KeyStore {
return {
async list(kind: KeyKind) {
return Database.use(async (tx) =>
tx
.select()
.from(AuthKeyTable)
.where(eq(AuthKeyTable.kind, kind))
.then((rows) => rows.map(toStored))
);
},
async create(kind: KeyKind, key: StoredKey) {
await Database.use(async (tx) => {
await tx
.insert(AuthKeyTable)
.values({
id: Identifier.ascending('authKey'),
keyId: key.id,
kind,
alg: key.alg,
publicKey: key.publicKey,
privateKey: key.privateKey,
expiredAt: key.expired ? new Date(key.expired) : null
})
.onConflictDoNothing({ target: AuthKeyTable.keyId });
});
}
};
}

View File

@@ -0,0 +1,39 @@
import { jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, utc } from '../db/types.js';
/**
* What is left of the issuer's key-value store once everything with a shape
* has been given a table of its own.
*
* Signing keys, authorization codes and refresh tokens each moved out, because
* each is a record whose fields are worth naming, whose changes are worth a
* migration, and — for the last two — whose transitions have to happen exactly
* once. What remains is counters: how many user codes an address has guessed
* at, how many times a sign-in code has been retried, when a code was last
* sent. They have none of those properties. A counter is written far more often
* than it is read, is meaningless an hour later, and is allowed to be
* approximate — losing one increment costs an attacker one extra guess out of a
* budget of ten.
*
* So this table stays deliberately generic, and is the one place a `jsonb`
* blob with no migration behind it is the right answer rather than a shortcut.
*/
export const AuthKvTable = pgTable(
'auth_kv',
{
...id,
...timestamps,
/**
* The caller's key array, joined by the unit separator the issuer's
* storage interface uses. Stored as one string rather than split into
* columns because nothing here ever queries a component of it.
*/
key: text('key').notNull(),
value: jsonb('value').$type<Record<string, unknown>>().notNull(),
/** Null for a record with no expiry. */
expiresAt: utc('expires_at')
},
(t) => [uniqueIndex('auth_kv_key_unique').on(t.key)]
);

View File

@@ -0,0 +1,99 @@
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
import { testDb } from '../db/test.js';
import { PostgresStorage } from './storage.js';
const sql = testDb();
const storage = PostgresStorage();
const PREFIX = 'kv-fixture';
async function cleanup() {
await sql`delete from auth_kv where key like ${PREFIX + '%'}`;
}
beforeEach(cleanup);
afterAll(async () => {
await cleanup();
await sql.end();
});
describe('PostgresStorage', () => {
test('a missing key reads as undefined', async () => {
expect(await storage.get([PREFIX, 'absent'])).toBeUndefined();
});
test('what was set is what is read back', async () => {
await storage.set([PREFIX, 'counter'], { count: 3, resetAt: 12345 });
expect(await storage.get([PREFIX, 'counter'])).toEqual({ count: 3, resetAt: 12345 });
});
test('setting the same key again replaces the value', async () => {
await storage.set([PREFIX, 'counter'], { count: 1 });
await storage.set([PREFIX, 'counter'], { count: 2 });
expect(await storage.get([PREFIX, 'counter'])).toEqual({ count: 2 });
const [row] = await sql`select count(*)::int as n from auth_kv where key like ${PREFIX + '%'}`;
expect(row!.n).toBe(1);
});
test('removing a key makes it unreadable', async () => {
await storage.set([PREFIX, 'gone'], { a: 1 });
await storage.remove([PREFIX, 'gone']);
expect(await storage.get([PREFIX, 'gone'])).toBeUndefined();
});
test('an expired value reads as absent', async () => {
await storage.set([PREFIX, 'stale'], { a: 1 }, new Date(Date.now() - 1000));
expect(await storage.get([PREFIX, 'stale'])).toBeUndefined();
});
test('a value with an expiry in the future is still readable', async () => {
await storage.set([PREFIX, 'live'], { a: 1 }, new Date(Date.now() + 60_000));
expect(await storage.get([PREFIX, 'live'])).toEqual({ a: 1 });
});
test('scan returns everything under a prefix, split back into a key array', async () => {
await storage.set([PREFIX, 'scan', 'one'], { n: 1 });
await storage.set([PREFIX, 'scan', 'two'], { n: 2 });
const found = await Array.fromAsync(storage.scan([PREFIX, 'scan']));
expect(found).toHaveLength(2);
expect(found.map(([key]) => key)).toEqual([
[PREFIX, 'scan', 'one'],
[PREFIX, 'scan', 'two']
]);
expect(found.map(([, value]) => value)).toEqual([{ n: 1 }, { n: 2 }]);
});
/**
* A prefix match on the bare string would return these too, and the keys
* that hit this are real ones — a subject is a prefix of a longer subject.
*/
test('scan does not reach into a prefix that merely starts the same way', async () => {
await storage.set([PREFIX, 'user'], { n: 1 });
await storage.set([PREFIX, 'user-extended'], { n: 2 });
await storage.set([PREFIX, 'user', 'child'], { n: 3 });
const found = await Array.fromAsync(storage.scan([PREFIX, 'user']));
expect(found.map(([, value]) => value)).toEqual([{ n: 3 }]);
});
/** `%` and `_` are LIKE wildcards, and keys here are built from user input. */
test('a key containing LIKE wildcards does not widen a scan', async () => {
await storage.set([PREFIX, '%'], { n: 1 });
await storage.set([PREFIX, 'literal'], { n: 2 });
await storage.set([PREFIX, '%', 'child'], { n: 3 });
const found = await Array.fromAsync(storage.scan([PREFIX, '%']));
expect(found.map(([, value]) => value)).toEqual([{ n: 3 }]);
});
test('scan skips values that have expired', async () => {
await storage.set([PREFIX, 'mixed', 'live'], { n: 1 }, new Date(Date.now() + 60_000));
await storage.set([PREFIX, 'mixed', 'dead'], { n: 2 }, new Date(Date.now() - 1000));
const found = await Array.fromAsync(storage.scan([PREFIX, 'mixed']));
expect(found.map(([, value]) => value)).toEqual([{ n: 1 }]);
});
});

View File

@@ -0,0 +1,96 @@
import type { StorageAdapter } from '@nestri/auth/storage/storage';
import { joinKey, splitKey } from '@nestri/auth/storage/storage';
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
import { Database } from '../db/index.js';
import { Identifier } from '../id.js';
import { AuthKvTable } from './storage.sql.js';
/** Whether a row is still live, as a SQL fragment. */
const unexpired = () => or(isNull(AuthKvTable.expiresAt), sql`${AuthKvTable.expiresAt} > now()`);
/**
* A prefix, escaped so that a key containing `%` or `_` cannot widen the match.
*
* Postgres treats both as wildcards in `LIKE`, and the keys here are built
* from caller-supplied strings — an email address, a caller's own address —
* so neither character is hypothetical.
*/
function escapeLike(value: string): string {
return value.replace(/([\\%_])/g, '\\$1');
}
/**
* The issuer's remaining key-value state, in Postgres.
*
* This is the small half of what used to be one store: the counters behind the
* device-code guess limit and the sign-in code retry limit. Everything with a
* shape moved to a table that names its fields — see `AuthKvTable`.
*
* There is no sweeper. Expired rows are removed when they are next read and
* when a write happens to notice them, which is enough because every key here
* is written far more often than the table grows: a counter is rewritten on
* every attempt by the same caller, and there are only ever as many rows as
* there are callers inside one window.
*/
export function PostgresStorage(): StorageAdapter {
return {
async get(key: string[]) {
const joined = joinKey(key);
return Database.use(async (tx) =>
tx
.select({ value: AuthKvTable.value })
.from(AuthKvTable)
.where(and(eq(AuthKvTable.key, joined), unexpired()))
.then((rows) => rows[0]?.value)
);
},
async set(key: string[], value: any, expiry?: Date) {
const joined = joinKey(key);
await Database.use(async (tx) => {
// Swept opportunistically rather than on a schedule, on the
// only statement here that can add a row.
await tx.delete(AuthKvTable).where(lt(AuthKvTable.expiresAt, new Date()));
await tx
.insert(AuthKvTable)
.values({
id: Identifier.ascending('authKv'),
key: joined,
value,
expiresAt: expiry ?? null
})
.onConflictDoUpdate({
target: AuthKvTable.key,
set: { value, expiresAt: expiry ?? null, timeUpdated: new Date() }
});
});
},
async remove(key: string[]) {
const joined = joinKey(key);
await Database.use(async (tx) => {
await tx.delete(AuthKvTable).where(eq(AuthKvTable.key, joined));
});
},
async *scan(prefix: string[]) {
// The separator is part of the prefix, so that scanning `['a']`
// cannot also return the keys under `['ab']`. Matching on the bare
// prefix is a real collision — subjects and email addresses are
// both prefixes of longer subjects and email addresses.
const pattern = escapeLike(joinKey([...prefix, ''])) + '%';
const rows = await Database.use(async (tx) =>
tx
.select({ key: AuthKvTable.key, value: AuthKvTable.value })
.from(AuthKvTable)
.where(and(sql`${AuthKvTable.key} LIKE ${pattern}`, unexpired()))
.orderBy(AuthKvTable.key)
);
for (const row of rows) {
yield [splitKey(row.key), row.value] as [string[], any];
}
}
};
}

View File

@@ -20,7 +20,11 @@ export namespace Identifier {
gameDepot: 'gdp',
gameDownload: 'gdl',
waitlistEntry: 'wle',
deviceGrant: 'dvg'
deviceGrant: 'dvg',
authKv: 'akv',
authKey: 'aky',
authorizationCode: 'acd',
refreshToken: 'rft'
} as const;
export function schema(prefix: keyof typeof prefixes) {