mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-23 03:05:20 +03:00
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:
73
packages/auth/src/key.ts
Normal file
73
packages/auth/src/key.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Where the issuer's signing and encryption keys live.
|
||||
*
|
||||
* These are the only records here that are meant to outlive everything else:
|
||||
* every token this issuer has ever minted is verifiable only for as long as
|
||||
* the public half is still published, so losing this store invalidates every
|
||||
* session at once. That is the whole reason it is an interface — a store that
|
||||
* a deployment can point at its own database, rather than at whatever
|
||||
* key-value service the runtime happened to offer.
|
||||
*
|
||||
* Writes are append-only and rare: a key is created when no unexpired one of
|
||||
* its kind exists, and retired by being marked expired rather than removed, so
|
||||
* tokens it signed stay verifiable until they age out on their own.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import type { StorageAdapter } from './storage/storage.js';
|
||||
import { Storage } from './storage/storage.js';
|
||||
|
||||
/** Which half of the issuer's key material a record belongs to. */
|
||||
export type KeyKind = 'signing' | 'encryption';
|
||||
|
||||
/**
|
||||
* A key pair as stored: PEM text rather than a live key object.
|
||||
*
|
||||
* Kept serialized because the store is a database and not a process — the
|
||||
* import back into a usable key happens in {@link ./keys.js}, once per issuer
|
||||
* instance.
|
||||
*/
|
||||
export interface StoredKey {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
alg: string;
|
||||
/** Epoch ms. */
|
||||
created: number;
|
||||
/** Epoch ms, set when the key is retired. Absent while it is still in use. */
|
||||
expired?: number;
|
||||
}
|
||||
|
||||
export interface KeyStore {
|
||||
/** Every key of a kind, expired ones included. Order does not matter. */
|
||||
list(kind: KeyKind): Promise<StoredKey[]>;
|
||||
create(kind: KeyKind, key: StoredKey): Promise<void>;
|
||||
}
|
||||
|
||||
/** The storage prefix a kind's keys have always been written under. */
|
||||
function prefix(kind: KeyKind): string {
|
||||
return kind === 'signing' ? 'signing:key' : 'encryption:key';
|
||||
}
|
||||
|
||||
/**
|
||||
* A key store backed by the generic {@link StorageAdapter}.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
export function StorageKeyStore(storage: StorageAdapter): KeyStore {
|
||||
return {
|
||||
async list(kind) {
|
||||
const results: StoredKey[] = [];
|
||||
for await (const [, value] of Storage.scan<StoredKey>(storage, [prefix(kind)])) {
|
||||
results.push(value);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
async create(kind, key) {
|
||||
await Storage.set(storage, [prefix(kind), key.id], key);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user