diff --git a/alchemy.run.ts b/alchemy.run.ts index 91bf22e6..a9e049ae 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -59,8 +59,6 @@ function mailEnv(stage: string) { }; } -const AuthStorage = Cloudflare.KV.Namespace('auth-storage'); - const Database = Effect.gen(function* () { const { stage } = yield* Alchemy.Stack; const database = stage === 'production' ? 'defaultdb' : 'sandbox'; @@ -95,7 +93,6 @@ export const Auth = Effect.gen(function* () { // the email one. Linking a Steam account is `apps/api`'s job and its // key is bound there. env: { - AuthStorage, HYPERDRIVE: Database, ...mailEnv(stage) }, diff --git a/apps/auth/README.md b/apps/auth/README.md index 5813608c..73942c54 100644 --- a/apps/auth/README.md +++ b/apps/auth/README.md @@ -5,28 +5,38 @@ The authentication worker for Nestri — a Cloudflare Worker built on ## What it does -Hosts the OpenID Connect / OAuth issuer and the login UI: +Hosts the OAuth issuer and the sign-in UI: -- **Steam OAuth** — the primary login flow. After Steam redirects back, the worker fetches the - player's profile, creates (or finds) the `User` + `LinkedAccount` rows in Postgres, auto-creates a - personal team on first login, and issues a JWT `user` subject containing `{ userID, linkedAccountID }`. -- **SSH login** — authenticates a device via its SSH fingerprint (keyed by `SSH_AUTH_KEY`), - resolving the identity through `Steam.resolveSshIdentity` in `@nestri/core`. +- **Email code** — the only provider, on purpose. Verifying an email address is the one thing that + brings an account into existence, so an account is exactly as recoverable as its email. The + `success` callback finds or creates the `User` row, ensures a personal team exists, and issues a + JWT `user` subject containing `{ userID, linkedAccountID }`. +- **Device authorization grant** (RFC 8628) — for programs with no browser. A client starts a grant, + a person approves it in a browser, and the client collects tokens by polling. Connecting a Steam + account is not a sign-in and lives in `apps/api` instead, against a user who already exists. ## Key details -- Signing keys are generated at runtime and persisted in the `AuthStorage` KV namespace. +- **All issuer state is in Postgres.** There is no key-value binding. Signing keys, authorization + codes, refresh tokens and device grants each have a table, because each is either a record whose + loss ends every session (the keys) or one with a transition that must happen exactly once while + two callers are touching it — a code is redeemed once, a refresh token is spent once, a grant is + approved once. A store that reads and writes whole records cannot promise that. What is left in + the generic `auth_kv` table is the rate-limit counters, which are allowed to be approximate. +- Authorization codes, refresh tokens and device codes are stored as hashes. Each is a bearer + credential, so what is kept is enough to recognise one and not enough to present it. - JWT subjects are defined in `@nestri/core/auth/subjects`. -- The API worker calls this worker via a service binding (`AUTH`), verified through `AUTH_ISSUER_URL`. +- The API worker verifies tokens against this issuer through `AUTH_ISSUER_URL`. ## Structure ```text -src/index.ts # Worker entrypoint: issuer config + success callbacks (steam, ssh) +src/index.ts # Worker entrypoint: issuer config, stores, success callback +src/email.ts # Verification code delivery test/ # Worker tests ``` ## Running -Deployed through Alchemy (`apps/auth` worker in `alchemy.run.ts` at the repo root) with bindings -`AuthStorage` (KV), `HYPERDRIVE` (Postgres), `STEAM_API_KEY`, `SSH_AUTH_KEY`. +Deployed through Alchemy (`apps/auth` worker in `alchemy.run.ts` at the repo root). Its only +stateful binding is `HYPERDRIVE` (Postgres), alongside the mail settings. diff --git a/apps/auth/src/index.ts b/apps/auth/src/index.ts index 6a001a7c..38a104b3 100644 --- a/apps/auth/src/index.ts +++ b/apps/auth/src/index.ts @@ -1,10 +1,13 @@ -import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types'; +import type { Hyperdrive } from '@cloudflare/workers-types'; import { issuer } from '@nestri/auth/index'; import { CodeProvider } from '@nestri/auth/provider/code'; -import { CloudflareStorage } from '@nestri/auth/storage/cloudflare'; import { CodeUI } from '@nestri/auth/ui/code'; import { Actor } from '@nestri/core/actor'; +import { PostgresCodeStore } from '@nestri/core/auth/authorization-code'; import { PostgresDeviceStore } from '@nestri/core/auth/device-grant'; +import { PostgresRefreshStore } from '@nestri/core/auth/refresh-token'; +import { PostgresKeyStore } from '@nestri/core/auth/signing-key'; +import { PostgresStorage } from '@nestri/core/auth/storage'; import { subjects } from '@nestri/core/auth/subjects'; import { Env } from '@nestri/core/env'; import { Team } from '@nestri/core/team/index'; @@ -14,7 +17,6 @@ import { LinkedAccount } from '@nestri/core/user/linked-account'; import { sendVerificationCode } from './email.js'; type Env = { - AuthStorage: KVNamespace; HYPERDRIVE: Hyperdrive; EMAIL_SEND_URL?: string; EMAIL_API_KEY?: string; @@ -61,15 +63,21 @@ export default { Env.init(env as unknown as Record); const inner = issuer({ subjects, - storage: CloudflareStorage({ - namespace: env.AuthStorage - }), - // Not the KV store the rest of this uses, and the difference - // matters. A device grant is answered by a browser and collected by - // a program polling at the same time, so approving it and redeeming - // it each have to be one operation that either happens or does not. - // A store that reads and writes whole records lets those two undo - // each other; a conditional update does not. + // One database behind all of it, and nothing that only exists on + // one hosting provider. What is left in the generic store is the + // rate-limit counters — the only records here that are allowed to + // be approximate, and the only ones whose shape is not worth a + // migration. + storage: PostgresStorage(), + // The rest each got an interface of their own because each has a + // transition that must happen exactly once while two parties are + // touching the same record: a code is redeemed once, a refresh + // token is spent once, a grant is approved once. A store that reads + // and writes whole records cannot promise that — the second caller + // overwrites what the first decided. A conditional update can. + keyStore: PostgresKeyStore(), + codeStore: PostgresCodeStore(), + refreshStore: PostgresRefreshStore(), deviceStore: PostgresDeviceStore(), allowDeviceClient: async (clientID) => DEVICE_CLIENTS.has(clientID), // One provider, on purpose. diff --git a/packages/auth/src/authorization-code.ts b/packages/auth/src/authorization-code.ts new file mode 100644 index 00000000..14ee72dc --- /dev/null +++ b/packages/auth/src/authorization-code.ts @@ -0,0 +1,75 @@ +/** + * Where an authorization code lives between the redirect and the exchange. + * + * A code is handed to a browser in a URL and presented back within a minute, + * and it must be redeemable exactly once. That last part is the whole reason + * this is an interface: taking the record away and reading it have to be the + * same operation, because a get, a decision and a remove lets two exchanges + * arriving together both be served — and each of them mints a full session. + * + * @packageDocumentation + */ + +import type { StorageAdapter } from './storage/storage.js'; +import { Storage } from './storage/storage.js'; +import { sha256hex } from './util.js'; + +/** What the code stands for, recorded when it is issued. */ +export interface AuthorizationCodeRecord { + type: string; + properties: any; + subject: string; + clientID: string; + redirectURI: string; + ttl: { access: number; refresh: number }; + pkce?: { challenge: string; method: 'S256' }; +} + +export interface CodeStore { + create(codeHash: string, record: AuthorizationCodeRecord, ttl: number): Promise; + + /** + * Take the record away and return it, or return null. + * + * Removal and reading are one operation on purpose. Two exchanges of the + * same code must not both be answered, and a caller cannot arrange that by + * reading first — so it is not offered a way to. + */ + consume(codeHash: string): Promise; +} + +/** + * The hash a code is stored under. + * + * An authorization code is a bearer credential that travels in a query string, + * which means it lands in browser history, in referrer headers and in whatever + * logs the redirect passed through. What is kept here is enough to recognise + * one and not enough to present it. + */ +export function hashAuthorizationCode(code: string): Promise { + return sha256hex(code); +} + +/** + * A code store backed by the generic {@link StorageAdapter}. + * + * The default, and the behaviour every deployment had before `codeStore` + * existed — including its weakness: `get` and `remove` are two operations, so + * this cannot actually promise single use. It is kept because a store that + * only does get and set cannot do better, and an issuer that wants the promise + * passes one that can. + */ +export function StorageCodeStore(storage: StorageAdapter): CodeStore { + return { + async create(codeHash, record, ttl) { + await Storage.set(storage, ['oauth:code', codeHash], record, ttl); + }, + async consume(codeHash) { + const key = ['oauth:code', codeHash]; + const record = await Storage.get(storage, key); + if (!record) return null; + await Storage.remove(storage, key); + return record; + } + }; +} diff --git a/packages/auth/src/device.ts b/packages/auth/src/device.ts index 65c3b0c3..5e177176 100644 --- a/packages/auth/src/device.ts +++ b/packages/auth/src/device.ts @@ -13,6 +13,8 @@ * @packageDocumentation */ +import { sha256hex } from './util.js'; + /** How far a grant has got. Terminal in both directions once it leaves pending. */ export type DeviceGrantStatus = 'pending' | 'approved' | 'denied'; @@ -93,8 +95,7 @@ export interface DeviceStore { * not enough to present it. */ export async function hashDeviceCode(deviceCode: string): Promise { - const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(deviceCode)); - return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); + return sha256hex(deviceCode); } /** diff --git a/packages/auth/src/issuer.ts b/packages/auth/src/issuer.ts index 48def0e5..0859b6b4 100644 --- a/packages/auth/src/issuer.ts +++ b/packages/auth/src/issuer.ts @@ -205,7 +205,20 @@ import { UnauthorizedClientError, UnknownStateError } from './error.js'; -import { encryptionKeys, legacySigningKeys, signingKeys } from './keys.js'; +import { encryptionKeys, signingKeys } from './keys.js'; +import { type KeyStore, StorageKeyStore } from './key.js'; +import { + type AuthorizationCodeRecord, + type CodeStore, + hashAuthorizationCode, + StorageCodeStore +} from './authorization-code.js'; +import { + hashRefreshToken, + type RefreshRecord, + type RefreshStore, + StorageRefreshStore +} from './refresh.js'; import { type DeviceGrant, type DeviceGrantSubject, @@ -397,6 +410,33 @@ export interface IssuerInput< * store can make each transition a single operation. */ deviceStore?: DeviceStore; + /** + * Where the issuer's signing and encryption keys are kept. + * + * Defaults to the generic {@link storage} adapter, under the prefixes it + * has always used, so an issuer that does not set this keeps the keys it + * already had. Setting it moves the one piece of state here whose loss + * invalidates every session at once into somewhere a deployment controls. + */ + keyStore?: KeyStore; + /** + * Where authorization codes are kept between the redirect and the exchange. + * + * Defaults to the generic {@link storage} adapter, which cannot promise a + * code is redeemable only once — it reads and removes in two steps, so two + * exchanges arriving together are both answered, and each mints a session. + * A store that can delete and return in one operation closes that. + */ + codeStore?: CodeStore; + /** + * Where refresh tokens are kept. + * + * Defaults to the generic {@link storage} adapter, with the same weakness: + * reuse detection depends on recording when a token was first spent, and + * through get and set that record happens after the check rather than as + * part of it, so two refreshes arriving together both look like the first. + */ + refreshStore?: RefreshStore; /** * How hard a caller may guess at user codes before `/device` stops * answering them. @@ -599,10 +639,12 @@ export function issuer< throw new Error( 'Store is not configured. Either set the `storage` option or set `OPENAUTH_STORAGE` environment variable.' ); - const allSigning = lazy(() => - Promise.all([signingKeys(storage), legacySigningKeys(storage)]).then(([a, b]) => [...a, ...b]) - ); - const allEncryption = lazy(() => encryptionKeys(storage)); + const keyStore = input.keyStore ?? StorageKeyStore(storage); + const codeStore = input.codeStore ?? StorageCodeStore(storage); + const refreshStore = input.refreshStore ?? StorageRefreshStore(storage); + + const allSigning = lazy(() => signingKeys(keyStore)); + const allEncryption = lazy(() => encryptionKeys(keyStore)); const signingKey = lazy(() => allSigning().then((all) => all[0])); const encryptionKey = lazy(() => allEncryption().then((all) => all[0])); @@ -684,9 +726,8 @@ export function issuer< } if (authorization.response_type === 'code') { const code = crypto.randomUUID(); - await Storage.set( - storage, - ['oauth:code', code], + await codeStore.create( + await hashAuthorizationCode(code), { type, properties, @@ -762,11 +803,7 @@ export function issuer< deleteCookie(ctx, key); }, async invalidate(subject: string) { - // Resolve the scan in case modifications interfere with iteration - const keys = await Array.fromAsync(Storage.scan(this.storage, ['oauth:refresh', subject])); - for (const [key] of keys) { - await Storage.remove(this.storage, key); - } + await refreshStore.removeSubject(subject); }, storage }; @@ -939,14 +976,14 @@ export function issuer< * Similar treatment should be given to any other values that may have race conditions, * for example if a jti claim was added to the access token. */ - const refreshValue = { + const refreshValue: RefreshRecord = { ...value, nextToken: crypto.randomUUID() }; delete refreshValue.timeUsed; - await Storage.set( - storage!, - ['oauth:refresh', value.subject, refreshToken], + await refreshStore.create( + value.subject, + await hashRefreshToken(refreshToken), refreshValue, value.ttl.refresh ); @@ -1077,19 +1114,17 @@ export function issuer< }, 400 ); - const key = ['oauth:code', code.toString()]; - const payload = await Storage.get<{ - type: string; - properties: any; - clientID: string; - redirectURI: string; - subject: string; - ttl: { - access: number; - refresh: number; - }; - pkce?: AuthorizationState['pkce']; - }>(storage, key); + // Taken away before anything is checked, and deliberately not + // after. A code is redeemable once, so the operation that + // decides which caller gets it has to be the one that removes + // it — checking first and removing at the end lets two + // exchanges of the same code both pass every check. It also + // means a code that fails a check below is spent rather than + // left to be tried again, which is what RFC 6749 §4.1.2 asks + // for. + const payload: AuthorizationCodeRecord | null = await codeStore.consume( + await hashAuthorizationCode(code.toString()) + ); if (!payload) { return c.json( { @@ -1140,7 +1175,6 @@ export function issuer< } } const tokens = await generateTokens(c, payload); - await Storage.remove(storage, key); return c.json({ access_token: tokens.access, expires_in: tokens.expiresIn, @@ -1161,20 +1195,19 @@ export function issuer< const splits = refreshToken.toString().split(':'); const token = splits.pop()!; const subject = splits.join(':'); - const key = ['oauth:refresh', subject, token]; - const payload = await Storage.get<{ - type: string; - properties: any; - clientID: string; - subject: string; - ttl: { - access: number; - refresh: number; - }; - nextToken: string; - timeUsed?: number; - }>(storage, key); - if (!payload) { + const at = Date.now(); + // Spending the token and finding out whether it had already + // been spent are one operation. Split into a read and a write + // they are the race that reuse detection exists to catch: two + // refreshes arriving together both read an unspent token, both + // mint a session, and neither is ever reported. + const claim = await refreshStore.claim( + subject, + await hashRefreshToken(token), + at, + ttlRefreshReuse <= 0 ? 0 : ttlRefreshReuse + ttlRefreshRetention + ); + if (claim.status === 'missing') { return c.json( { error: 'invalid_grant', @@ -1183,15 +1216,12 @@ export function issuer< 400 ); } - const generateRefreshToken = !payload.timeUsed; - if (ttlRefreshReuse <= 0) { - // no reuse interval, remove the refresh token immediately - await Storage.remove(storage, key); - } else if (!payload.timeUsed) { - payload.timeUsed = Date.now(); - await Storage.set(storage, key, payload, ttlRefreshReuse + ttlRefreshRetention); - } else if (Date.now() > payload.timeUsed + ttlRefreshReuse * 1000) { - // token was reused past the allowed interval + // Reuse inside the window is tolerated so that a client that + // fired two refreshes at once gets the same answer twice + // instead of losing its session. Past it, the only explanation + // left is that someone else has the token, so every session the + // subject has goes. + if (claim.status === 'reused' && at > claim.timeUsed + ttlRefreshReuse * 1000) { await auth.invalidate(subject); return c.json( { @@ -1201,8 +1231,16 @@ export function issuer< 400 ); } + // The access token is dated from when the refresh token was + // first spent, not from now — so the second answer inside the + // reuse window is the same session, and not a quietly extended + // one. + const payload: RefreshRecord = { + ...claim.record, + timeUsed: claim.status === 'fresh' ? at : claim.timeUsed + }; const tokens = await generateTokens(c, payload, { - generateRefreshToken + generateRefreshToken: claim.status === 'fresh' }); return c.json({ access_token: tokens.access, diff --git a/packages/auth/src/key.ts b/packages/auth/src/key.ts new file mode 100644 index 00000000..b368cfba --- /dev/null +++ b/packages/auth/src/key.ts @@ -0,0 +1,97 @@ +/** + * 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; + + /** + * 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; +} + +/** 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. + * + * 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 { + async list(kind) { + const results: StoredKey[] = []; + for await (const [, value] of Storage.scan(storage, [prefix(kind)])) { + results.push(value); + } + return results; + }, + async create(kind, key) { + await Storage.set(storage, [prefix(kind), key.id], key); + } + }; +} diff --git a/packages/auth/src/keys.ts b/packages/auth/src/keys.ts index 3b67aed3..b609dbbc 100644 --- a/packages/auth/src/keys.ts +++ b/packages/auth/src/keys.ts @@ -9,19 +9,12 @@ import { KeyLike } from 'jose'; -import { Storage, StorageAdapter } from './storage/storage.js'; +import type { KeyKind, KeyStore, StoredKey } from './key.js'; -const signingAlg = 'ES256'; -const encryptionAlg = 'RSA-OAEP-512'; - -interface SerializedKeyPair { - id: string; - publicKey: string; - privateKey: string; - created: number; - alg: string; - expired?: number; -} +const alg: Record = { + signing: 'ES256', + encryption: 'RSA-OAEP-512' +}; export interface KeyPair { id: string; @@ -33,104 +26,69 @@ export interface KeyPair { jwk: JWK; } +async function toKeyPair(kind: KeyKind, stored: StoredKey): Promise { + // The algorithm is read off the record rather than assumed, because a key + // outlives the decision that produced it: rotating to a new algorithm has + // to leave the old keys verifiable until the tokens they signed expire. + const publicKey = await importSPKI(stored.publicKey, stored.alg, { extractable: true }); + const privateKey = await importPKCS8(stored.privateKey, stored.alg); + const jwk = await exportJWK(publicKey); + jwk.kid = stored.id; + if (kind === 'signing') jwk.use = 'sig'; + return { + id: stored.id, + alg: stored.alg, + created: new Date(stored.created), + expired: stored.expired ? new Date(stored.expired) : undefined, + public: publicKey, + private: privateKey, + jwk + }; +} + /** - * @deprecated use `signingKeys` instead + * Every key of a kind, newest first, creating one if none is usable. + * + * 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. */ -export async function legacySigningKeys(storage: StorageAdapter): Promise { - const alg = 'RS512'; - const results = [] as KeyPair[]; - const scanner = Storage.scan(storage, ['oauth:key']); - for await (const [_key, value] of scanner) { - const publicKey = await importSPKI(value.publicKey, alg, { - extractable: true - }); - const privateKey = await importPKCS8(value.privateKey, alg); - const jwk = await exportJWK(publicKey); - jwk.kid = value.id; - results.push({ - id: value.id, - alg, - created: new Date(value.created), - public: publicKey, - private: privateKey, - expired: new Date(1735858114000), - jwk - }); - } - return results; -} - -export async function signingKeys(storage: StorageAdapter): Promise { - const results = [] as KeyPair[]; - const scanner = Storage.scan(storage, ['signing:key']); - for await (const [_key, value] of scanner) { - const publicKey = await importSPKI(value.publicKey, value.alg, { - extractable: true - }); - const privateKey = await importPKCS8(value.privateKey, value.alg); - const jwk = await exportJWK(publicKey); - jwk.kid = value.id; - jwk.use = 'sig'; - results.push({ - id: value.id, - alg: signingAlg, - created: new Date(value.created), - expired: value.expired ? new Date(value.expired) : undefined, - public: publicKey, - private: privateKey, - jwk - }); - } +async function keysOf(store: KeyStore, kind: KeyKind, bootstrapped = false): Promise { + 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.filter((item) => !item.expired).length) return results; + if (results.some((item) => !item.expired)) return results; - const key = await generateKeyPair(signingAlg, { - extractable: true - }); - const serialized: SerializedKeyPair = { + // 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(), publicKey: await exportSPKI(key.publicKey), privateKey: await exportPKCS8(key.privateKey), created: Date.now(), - alg: signingAlg + alg: alg[kind] }; - await Storage.set(storage, ['signing:key', serialized.id], serialized); - return signingKeys(storage); + await store.create(kind, created); + return keysOf(store, kind, true); } -export async function encryptionKeys(storage: StorageAdapter): Promise { - const results = [] as KeyPair[]; - const scanner = Storage.scan(storage, ['encryption:key']); - for await (const [_key, value] of scanner) { - const publicKey = await importSPKI(value.publicKey, value.alg, { - extractable: true - }); - const privateKey = await importPKCS8(value.privateKey, value.alg); - const jwk = await exportJWK(publicKey); - jwk.kid = value.id; - results.push({ - id: value.id, - alg: encryptionAlg, - created: new Date(value.created), - expired: value.expired ? new Date(value.expired) : undefined, - public: publicKey, - private: privateKey, - jwk - }); - } - results.sort((a, b) => b.created.getTime() - a.created.getTime()); - if (results.filter((item) => !item.expired).length) return results; - - const key = await generateKeyPair(encryptionAlg, { - extractable: true - }); - const serialized: SerializedKeyPair = { - id: crypto.randomUUID(), - publicKey: await exportSPKI(key.publicKey), - privateKey: await exportPKCS8(key.privateKey), - created: Date.now(), - alg: encryptionAlg - }; - await Storage.set(storage, ['encryption:key', serialized.id], serialized); - return encryptionKeys(storage); +export function signingKeys(store: KeyStore): Promise { + return keysOf(store, 'signing'); +} + +export function encryptionKeys(store: KeyStore): Promise { + return keysOf(store, 'encryption'); } diff --git a/packages/auth/src/refresh.ts b/packages/auth/src/refresh.ts new file mode 100644 index 00000000..18039d27 --- /dev/null +++ b/packages/auth/src/refresh.ts @@ -0,0 +1,138 @@ +/** + * Where refresh tokens live, and how one is spent. + * + * A refresh token is the longest-lived credential this issuer hands out, and + * the only one whose record is written once and read months later. Two things + * follow, and both are why this is an interface rather than a pair of get and + * set calls. + * + * The first is that spending a token has to happen exactly once. Reuse + * detection works by remembering *when* a token was first spent, so the moment + * that is recorded must be the same operation as the check that it had not + * been recorded already. Read it, compare, write it back, and two refreshes + * arriving together both look like the first one — which is precisely the case + * reuse detection exists to catch. + * + * The second is that a token is a bearer credential, so the store is asked for + * a hash and never the token itself. See {@link hashRefreshToken}. + * + * @packageDocumentation + */ + +import type { StorageAdapter } from './storage/storage.js'; +import { Storage } from './storage/storage.js'; +import { sha256hex } from './util.js'; + +/** What a refresh token stands for. */ +export interface RefreshRecord { + type: string; + properties: any; + subject: string; + clientID: string; + ttl: { access: number; refresh: number }; + /** + * The token that replaces this one, chosen when this one was issued. + * + * Reserved in advance so that two refreshes inside the reuse window are + * answered with the same token rather than racing to mint different ones. + * + * Note what this means for a store that leaks: the successor is readable + * before it is issued. It is not usable until the holder actually refreshes + * — nothing is stored under it before then — but from that moment the + * successor is known. That is inherited from the token scheme rather than + * from where it is kept, and it is the reason the record's *own* token is + * still only ever stored as a hash. + */ + nextToken?: string; + /** Epoch ms the token was first spent. Absent until it has been. */ + timeUsed?: number; +} + +/** What spending a token turned out to be. */ +export type RefreshClaim = + | { status: 'missing' } + /** It had not been spent before. This caller is the one that spent it. */ + | { status: 'fresh'; record: RefreshRecord } + /** It had been spent already, at `timeUsed`. Whether that is allowed is the caller's arithmetic. */ + | { status: 'reused'; record: RefreshRecord; timeUsed: number }; + +export interface RefreshStore { + create( + subject: string, + tokenHash: string, + record: RefreshRecord, + ttl: number + ): Promise; + + /** + * Spend a token, in one operation. + * + * `retainFor` is how many seconds a spent record should be kept so that + * reuse can be recognised. Zero means reuse is not tolerated at all, and + * the record is taken away instead of marked — so `fresh` is still the only + * answer any one caller can get, and every later attempt reads `missing`. + * + * The caller must not decide any of this by reading first. + */ + claim( + subject: string, + tokenHash: string, + at: number, + retainFor: number + ): Promise; + + /** Every token belonging to a subject, for when reuse is detected. */ + removeSubject(subject: string): Promise; +} + +/** + * The hash a refresh token is stored under. + * + * The token is what its holder presents to be issued a session, so anything + * that can read the store could otherwise resume every session in it. What is + * kept is enough to recognise a token and not enough to present one. + */ +export function hashRefreshToken(token: string): Promise { + return sha256hex(token); +} + +/** + * A refresh store backed by the generic {@link StorageAdapter}. + * + * The default, and what every deployment had before `refreshStore` existed. + * `claim` here is a get followed by a set, which is the race described at the + * top of this file — unavoidable through an interface that offers only whole + * records, and the reason an issuer that cares passes a store that can do it + * in one statement. + */ +export function StorageRefreshStore(storage: StorageAdapter): RefreshStore { + const key = (subject: string, tokenHash: string) => ['oauth:refresh', subject, tokenHash]; + + return { + async create(subject, tokenHash, record, ttl) { + await Storage.set(storage, key(subject, tokenHash), record, ttl); + }, + + async claim(subject, tokenHash, at, retainFor) { + const k = key(subject, tokenHash); + const record = await Storage.get(storage, k); + if (!record) return { status: 'missing' }; + if (record.timeUsed) return { status: 'reused', record, timeUsed: record.timeUsed }; + if (retainFor <= 0) { + await Storage.remove(storage, k); + } else { + await Storage.set(storage, k, { ...record, timeUsed: at }, retainFor); + } + return { status: 'fresh', record }; + }, + + async removeSubject(subject) { + // Resolved before removing, in case modifying the store while + // iterating it interferes with the scan. + const keys = await Array.fromAsync(Storage.scan(storage, ['oauth:refresh', subject])); + for (const [k] of keys) { + await Storage.remove(storage, k); + } + } + }; +} diff --git a/packages/auth/src/util.ts b/packages/auth/src/util.ts index bea92b24..125be2fd 100644 --- a/packages/auth/src/util.ts +++ b/packages/auth/src/util.ts @@ -54,3 +54,15 @@ export function lazy(fn: () => T): () => T { return value; }; } + +/** + * The SHA-256 of a string, hex encoded. + * + * Used wherever a bearer credential has to be recognised later without being + * kept in a form that could be presented. Whoever can read the store learns + * that a token existed and not what it was. + */ +export async function sha256hex(value: string): Promise { + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value)); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/packages/core/migrations/0011_auth_state_in_postgres.sql b/packages/core/migrations/0011_auth_state_in_postgres.sql new file mode 100644 index 00000000..6c50c09d --- /dev/null +++ b/packages/core/migrations/0011_auth_state_in_postgres.sql @@ -0,0 +1,100 @@ +-- The issuer's own state, moved out of a key-value store. +-- +-- It used to live entirely behind one get/set/remove/scan interface, which is +-- what a library that has to run on any hosting provider's cache can offer. +-- Three of the things kept there could not actually be served by it. +-- +-- `authorization_code` and `refresh_token` each have a transition that must +-- happen exactly once while two callers are touching the same record: a code +-- is redeemed once, a refresh token is spent once. Through get and set, the +-- check and the write are separate, so two requests arriving together both +-- read an unspent record and both mint a session — and in the refresh case the +-- reuse that reveals a stolen token is never recorded. Here redeeming is one +-- `delete ... returning` and spending is one +-- `update ... where time_used is null returning *`, so exactly one caller is +-- told it went first. +-- +-- `auth_key` is here for a different reason: it is the one record whose loss +-- ends every session at once, and a cache is a place things are allowed to be +-- evicted from. It gets a conditional write too, though. At most one key of a +-- kind may be live, which `auth_key_one_live_per_kind` enforces rather than +-- leaving to convention — without it, two workers starting against an empty +-- table both find no key, both insert one, and each then signs and encrypts +-- with its own. A session cookie written by one is undecryptable to the other +-- and a token minted by one is rejected by the other, because both reach for a +-- single key rather than trying the whole published set. The split is silent +-- until someone cannot sign in. With the index the second insert is dropped and +-- both workers use the key that won. +-- +-- Keys are retired by setting `expired_at`, never deleted, so a verifier +-- reading the published JWKS can still check a token signed before a rotation. +-- Retiring one and creating its replacement have to happen together, so the +-- kind never has two live keys and never has none. +-- +-- Both credential tables store a hash and never the credential. An +-- authorization code travels in a query string and a refresh token resumes a +-- session, so what is kept is enough to recognise one and not enough to +-- present it. +-- +-- `auth_kv` is what is left, and is meant to stay small: the counters behind +-- the device-code guess limit and the sign-in code retry limit. They are +-- written far more often than read, meaningless within the hour, and allowed +-- to be approximate — a lost increment costs one extra guess out of ten. That +-- is the one case where an unmigrated `jsonb` blob is the right answer rather +-- than a shortcut. +-- +-- No sweeper anywhere. Every table is swept by the statement that adds to it, +-- which is enough because each is bounded by how many sign-ins are in flight. + +CREATE TYPE "public"."auth_key_kind" AS ENUM('signing', 'encryption');--> statement-breakpoint +CREATE TABLE "authorization_code" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "code_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "payload" jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "refresh_token" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "subject" text NOT NULL, + "token_hash" text NOT NULL, + "expires_at" timestamp with time zone NOT NULL, + "time_used" timestamp with time zone, + "payload" jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "auth_key" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "key_id" text NOT NULL, + "kind" "auth_key_kind" NOT NULL, + "alg" text NOT NULL, + "public_key" text NOT NULL, + "private_key" text NOT NULL, + "expired_at" timestamp with time zone +); +--> statement-breakpoint +CREATE TABLE "auth_kv" ( + "id" char(30) PRIMARY KEY NOT NULL, + "time_created" timestamp with time zone DEFAULT now() NOT NULL, + "time_updated" timestamp with time zone DEFAULT now() NOT NULL, + "time_deleted" timestamp with time zone, + "key" text NOT NULL, + "value" jsonb NOT NULL, + "expires_at" timestamp with time zone +); +--> statement-breakpoint +CREATE UNIQUE INDEX "authorization_code_hash_unique" ON "authorization_code" USING btree ("code_hash");--> statement-breakpoint +CREATE UNIQUE INDEX "refresh_token_hash_unique" ON "refresh_token" USING btree ("token_hash");--> statement-breakpoint +CREATE INDEX "refresh_token_subject_idx" ON "refresh_token" USING btree ("subject");--> statement-breakpoint +CREATE UNIQUE INDEX "auth_key_key_id_unique" ON "auth_key" USING btree ("key_id");--> statement-breakpoint +CREATE UNIQUE INDEX "auth_key_one_live_per_kind" ON "auth_key" USING btree ("kind") WHERE "auth_key"."expired_at" is null;--> statement-breakpoint +CREATE UNIQUE INDEX "auth_kv_key_unique" ON "auth_kv" USING btree ("key"); \ No newline at end of file diff --git a/packages/core/migrations/meta/0011_snapshot.json b/packages/core/migrations/meta/0011_snapshot.json new file mode 100644 index 00000000..2b48cb57 --- /dev/null +++ b/packages/core/migrations/meta/0011_snapshot.json @@ -0,0 +1,2813 @@ +{ + "id": "6fdda454-c7ee-42c1-931b-fa595385aac0", + "prevId": "8915fb5b-8f0d-4f6f-a808-27b19bb4604a", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.access_token": { + "name": "access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used": { + "name": "last_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "access_token_hash_unique": { + "name": "access_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_owner_idx": { + "name": "access_token_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "access_token_team_idx": { + "name": "access_token_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "access_token_owner_user_id_user_id_fk": { + "name": "access_token_owner_user_id_user_id_fk", + "tableFrom": "access_token", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "access_token_team_id_team_id_fk": { + "name": "access_token_team_id_team_id_fk", + "tableFrom": "access_token", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.authorization_code": { + "name": "authorization_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "authorization_code_hash_unique": { + "name": "authorization_code_hash_unique", + "columns": [ + { + "expression": "code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_grant": { + "name": "device_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "device_code_hash": { + "name": "device_code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_code": { + "name": "user_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "device_grant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "poll_interval": { + "name": "poll_interval", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "last_polled_at": { + "name": "last_polled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "device_grant_device_code_unique": { + "name": "device_grant_device_code_unique", + "columns": [ + { + "expression": "device_code_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_grant_user_code_unique": { + "name": "device_grant_user_code_unique", + "columns": [ + { + "expression": "user_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.refresh_token": { + "name": "refresh_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "time_used": { + "name": "time_used", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "refresh_token_hash_unique": { + "name": "refresh_token_hash_unique", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "refresh_token_subject_idx": { + "name": "refresh_token_subject_idx", + "columns": [ + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_key": { + "name": "auth_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key_id": { + "name": "key_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "auth_key_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alg": { + "name": "alg", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key": { + "name": "private_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expired_at": { + "name": "expired_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_key_key_id_unique": { + "name": "auth_key_key_id_unique", + "columns": [ + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "auth_key_one_live_per_kind": { + "name": "auth_key_one_live_per_kind", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"auth_key\".\"expired_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_kv": { + "name": "auth_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "auth_kv_key_unique": { + "name": "auth_kv_key_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.box": { + "name": "box", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "machine_id": { + "name": "machine_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tier": { + "name": "tier", + "type": "box_tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sm'" + }, + "state": { + "name": "state", + "type": "box_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'created'" + }, + "stop_reason": { + "name": "stop_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stop_clean": { + "name": "stop_clean", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "box_user_idx": { + "name": "box_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "box_machine_idx": { + "name": "box_machine_idx", + "columns": [ + { + "expression": "machine_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "box_user_id_user_id_fk": { + "name": "box_user_id_user_id_fk", + "tableFrom": "box", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "box_machine_id_machine_id_fk": { + "name": "box_machine_id_machine_id_fk", + "tableFrom": "box", + "tableTo": "machine", + "columnsFrom": [ + "machine_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_depot": { + "name": "game_depot", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "depot_id": { + "name": "depot_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "steam_manifest_id": { + "name": "steam_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_build_id": { + "name": "steam_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "installed_manifest_id": { + "name": "installed_manifest_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "installed_build_id": { + "name": "installed_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "depot_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_depot_unique": { + "name": "game_depot_unique", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "depot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_game_idx": { + "name": "game_depot_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_depot_updates_idx": { + "name": "game_depot_updates_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"game_depot\".\"installed_manifest_id\" is distinct from \"game_depot\".\"steam_manifest_id\"", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_depot_game_id_game_id_fk": { + "name": "game_depot_game_id_game_id_fk", + "tableFrom": "game_depot", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_download": { + "name": "game_download", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "host_id": { + "name": "host_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "game_download_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "progress_bytes": { + "name": "progress_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "total_bytes": { + "name": "total_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_completed": { + "name": "time_completed", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_download_host_game_unique": { + "name": "game_download_host_game_unique", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_game_idx": { + "name": "game_download_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_download_host_status_idx": { + "name": "game_download_host_status_idx", + "columns": [ + { + "expression": "host_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_download_host_id_machine_id_fk": { + "name": "game_download_host_id_machine_id_fk", + "tableFrom": "game_download", + "tableTo": "machine", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_download_game_id_game_id_fk": { + "name": "game_download_game_id_game_id_fk", + "tableFrom": "game_download", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game": { + "name": "game", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "client_icon": { + "name": "client_icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "short_description": { + "name": "short_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developers": { + "name": "developers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "publishers": { + "name": "publishers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "primary_genre": { + "name": "primary_genre", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oslist": { + "name": "oslist", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "size_download": { + "name": "size_download", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size_on_disk": { + "name": "size_on_disk", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "controller_support": { + "name": "controller_support", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "steam_deck_compat": { + "name": "steam_deck_compat", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "review_score_percent": { + "name": "review_score_percent", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "review_count": { + "name": "review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "smallint", + "primaryKey": false, + "notNull": false + }, + "steam_change_number": { + "name": "steam_change_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_build_id": { + "name": "public_build_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "release_date_utc": { + "name": "release_date_utc", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_enriched": { + "name": "time_enriched", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "game_slug_unique": { + "name": "game_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "game_app_id_unique": { + "name": "game_app_id_unique", + "columns": [ + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_steam_app_id_unique": { + "name": "game_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.machine": { + "name": "machine", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret_hash": { + "name": "secret_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "machine_secret_hash_unique": { + "name": "machine_secret_hash_unique", + "columns": [ + { + "expression": "secret_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_owner_idx": { + "name": "machine_owner_idx", + "columns": [ + { + "expression": "owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "machine_team_idx": { + "name": "machine_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "machine_owner_user_id_user_id_fk": { + "name": "machine_owner_user_id_user_id_fk", + "tableFrom": "machine", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "machine_team_id_team_id_fk": { + "name": "machine_team_id_team_id_fk", + "tableFrom": "machine", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pairing_code": { + "name": "pairing_code", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_user_id": { + "name": "target_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_fingerprint": { + "name": "new_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_claimed": { + "name": "is_claimed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "pairing_code_code_unique": { + "name": "pairing_code_code_unique", + "columns": [ + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "pairing_code_target_user_idx": { + "name": "pairing_code_target_user_idx", + "columns": [ + { + "expression": "target_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "box_id": { + "name": "box_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "linked_account_id": { + "name": "linked_account_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "session_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'requested'" + }, + "ticket": { + "name": "ticket", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_started": { + "name": "time_started", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "time_stopped": { + "name": "time_stopped", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claim_token": { + "name": "claim_token", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_box_idx": { + "name": "session_box_idx", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_state_idx": { + "name": "session_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_box_active_unique": { + "name": "session_box_active_unique", + "columns": [ + { + "expression": "box_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "time_stopped is null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_started_idx": { + "name": "session_started_idx", + "columns": [ + { + "expression": "time_started", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_box_id_box_id_fk": { + "name": "session_box_id_box_id_fk", + "tableFrom": "session", + "tableTo": "box", + "columnsFrom": [ + "box_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_game_id_game_id_fk": { + "name": "session_game_id_game_id_fk", + "tableFrom": "session", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "session_linked_account_id_linked_account_id_fk": { + "name": "session_linked_account_id_linked_account_id_fk", + "tableFrom": "session", + "tableTo": "linked_account", + "columnsFrom": [ + "linked_account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_member": { + "name": "team_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "team_id": { + "name": "team_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "team_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + } + }, + "indexes": { + "team_member_team_user_unique": { + "name": "team_member_team_user_unique", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_team_idx": { + "name": "team_member_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_member_user_idx": { + "name": "team_member_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_member_team_id_team_id_fk": { + "name": "team_member_team_id_team_id_fk", + "tableFrom": "team_member", + "tableTo": "team", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_member_user_id_user_id_fk": { + "name": "team_member_user_id_user_id_fk", + "tableFrom": "team_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team": { + "name": "team", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "billing_email": { + "name": "billing_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_status": { + "name": "subscription_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "team_owner_id_user_id_fk": { + "name": "team_owner_id_user_id_fk", + "tableFrom": "team", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "team_slug_unique": { + "name": "team_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_fingerprint": { + "name": "user_fingerprint", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_seen": { + "name": "last_seen", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_fingerprint_fingerprint_unique": { + "name": "user_fingerprint_fingerprint_unique", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_fingerprint_user_idx": { + "name": "user_fingerprint_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_fingerprint_user_id_user_id_fk": { + "name": "user_fingerprint_user_id_user_id_fk", + "tableFrom": "user_fingerprint", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_library": { + "name": "user_library", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "playtime_2w": { + "name": "playtime_2w", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playtime_forever": { + "name": "playtime_forever", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_played": { + "name": "last_played", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_library_user_game_unique": { + "name": "user_library_user_game_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_user_idx": { + "name": "user_library_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_library_game_idx": { + "name": "user_library_game_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_library_user_id_user_id_fk": { + "name": "user_library_user_id_user_id_fk", + "tableFrom": "user_library", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_library_game_id_game_id_fk": { + "name": "user_library_game_id_game_id_fk", + "tableFrom": "user_library", + "tableTo": "game", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.linked_account": { + "name": "linked_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "linked_account_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile": { + "name": "profile", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "linked_account_provider_unique": { + "name": "linked_account_provider_unique", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "linked_account_user_idx": { + "name": "linked_account_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "linked_account_user_id_user_id_fk": { + "name": "linked_account_user_id_user_id_fk", + "tableFrom": "linked_account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "email is not null and time_deleted is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "char(30)", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "verification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "consumed_at": { + "name": "consumed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_user_kind_idx": { + "name": "verification_user_kind_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "verification_user_id_user_id_fk": { + "name": "verification_user_id_user_id_fk", + "tableFrom": "verification", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist_entry": { + "name": "waitlist_entry", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "char(30)", + "primaryKey": true, + "notNull": true + }, + "time_created": { + "name": "time_created", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_updated": { + "name": "time_updated", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "time_deleted": { + "name": "time_deleted", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'machines'" + } + }, + "indexes": { + "waitlist_entry_email_unique": { + "name": "waitlist_entry_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "waitlist_entry_source_idx": { + "name": "waitlist_entry_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.device_grant_status": { + "name": "device_grant_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.auth_key_kind": { + "name": "auth_key_kind", + "schema": "public", + "values": [ + "signing", + "encryption" + ] + }, + "public.box_state": { + "name": "box_state", + "schema": "public", + "values": [ + "created", + "running", + "stopped" + ] + }, + "public.box_tier": { + "name": "box_tier", + "schema": "public", + "values": [ + "xs", + "sm", + "md", + "lg", + "xl" + ] + }, + "public.depot_status": { + "name": "depot_status", + "schema": "public", + "values": [ + "pending", + "downloading", + "complete", + "error", + "deleted" + ] + }, + "public.game_download_status": { + "name": "game_download_status", + "schema": "public", + "values": [ + "pending", + "verifying", + "downloading", + "ready", + "failed" + ] + }, + "public.session_state": { + "name": "session_state", + "schema": "public", + "values": [ + "requested", + "starting", + "live", + "ended", + "failed" + ] + }, + "public.team_member_role": { + "name": "team_member_role", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.linked_account_provider": { + "name": "linked_account_provider", + "schema": "public", + "values": [ + "steam", + "ssh", + "discord" + ] + }, + "public.verification_kind": { + "name": "verification_kind", + "schema": "public", + "values": [ + "email" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/core/migrations/meta/_journal.json b/packages/core/migrations/meta/_journal.json index 57035969..8dc73c15 100644 --- a/packages/core/migrations/meta/_journal.json +++ b/packages/core/migrations/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1788590292860, "tag": "0010_device_authorization_grant", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1788607804606, + "tag": "0011_auth_state_in_postgres", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/core/src/auth/authorization-code.sql.ts b/packages/core/src/auth/authorization-code.sql.ts new file mode 100644 index 00000000..c246da9f --- /dev/null +++ b/packages/core/src/auth/authorization-code.sql.ts @@ -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>().notNull() + }, + (t) => [uniqueIndex('authorization_code_hash_unique').on(t.codeHash)] +); diff --git a/packages/core/src/auth/authorization-code.test.ts b/packages/core/src/auth/authorization-code.test.ts new file mode 100644 index 00000000..9e33df2b --- /dev/null +++ b/packages/core/src/auth/authorization-code.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/auth/authorization-code.ts b/packages/core/src/auth/authorization-code.ts new file mode 100644 index 00000000..dac33cc4 --- /dev/null +++ b/packages/core/src/auth/authorization-code.ts @@ -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 + }); + }); + }, + + 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 + ) + ); + } + }; +} diff --git a/packages/core/src/auth/refresh-token.sql.ts b/packages/core/src/auth/refresh-token.sql.ts new file mode 100644 index 00000000..9f64af43 --- /dev/null +++ b/packages/core/src/auth/refresh-token.sql.ts @@ -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>().notNull() + }, + (t) => [ + uniqueIndex('refresh_token_hash_unique').on(t.tokenHash), + index('refresh_token_subject_idx').on(t.subject) + ] +); diff --git a/packages/core/src/auth/refresh-token.test.ts b/packages/core/src/auth/refresh-token.test.ts new file mode 100644 index 00000000..280adc7e --- /dev/null +++ b/packages/core/src/auth/refresh-token.test.ts @@ -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 { + 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'); + }); +}); diff --git a/packages/core/src/auth/refresh-token.ts b/packages/core/src/auth/refresh-token.ts new file mode 100644 index 00000000..832999fc --- /dev/null +++ b/packages/core/src/auth/refresh-token.ts @@ -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 + }); + }); + }, + + 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 => { + // 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)); + }); + } + }; +} diff --git a/packages/core/src/auth/signing-key.sql.ts b/packages/core/src/auth/signing-key.sql.ts new file mode 100644 index 00000000..141fbd76 --- /dev/null +++ b/packages/core/src/auth/signing-key.sql.ts @@ -0,0 +1,54 @@ +import { sql } from 'drizzle-orm'; +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, so a verifier + * reading the published JWKS can still check a token signed before a rotation. + * + * At most one key of a kind may be live at a time, and the partial unique index + * is what enforces it rather than a convention. Without it, two workers + * starting against an empty table both find no key, both insert one, and each + * then signs and encrypts with its own — so a session cookie written by one is + * undecryptable to the other, and an access token minted by one is rejected by + * the other. Both reach for a single key rather than trying the whole set, so + * the split is silent until someone cannot sign in. With the index the second + * insert is dropped, both read the table again, and both use the key that won. + */ +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), + uniqueIndex('auth_key_one_live_per_kind') + .on(t.kind) + .where(sql`${t.expiredAt} is null`) + ] +); diff --git a/packages/core/src/auth/signing-key.test.ts b/packages/core/src/auth/signing-key.test.ts new file mode 100644 index 00000000..b272dc17 --- /dev/null +++ b/packages/core/src/auth/signing-key.test.ts @@ -0,0 +1,106 @@ +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 { + 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); + }); +}); diff --git a/packages/core/src/auth/signing-key.ts b/packages/core/src/auth/signing-key.ts new file mode 100644 index 00000000..579cb874 --- /dev/null +++ b/packages/core/src/auth/signing-key.ts @@ -0,0 +1,63 @@ +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. + * + * `create` drops its write when the kind already has a live key, which is what + * the interface asks for and what keeps two workers bootstrapping at the same + * moment from ending up with a key each. Which of them wins does not matter. + * That they end up agreeing does — a key each means cookies one worker writes + * are unreadable to the other, and tokens one mints are rejected by the other. + * + * The conflict clause names no target on purpose: both unique indexes on this + * table mean the same thing here, that the row we wanted already exists in some + * form, and the answer to either is to keep what is there and read it back. + */ +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(); + }); + } + }; +} diff --git a/packages/core/src/auth/storage.sql.ts b/packages/core/src/auth/storage.sql.ts new file mode 100644 index 00000000..ce6f32de --- /dev/null +++ b/packages/core/src/auth/storage.sql.ts @@ -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>().notNull(), + /** Null for a record with no expiry. */ + expiresAt: utc('expires_at') + }, + (t) => [uniqueIndex('auth_kv_key_unique').on(t.key)] +); diff --git a/packages/core/src/auth/storage.test.ts b/packages/core/src/auth/storage.test.ts new file mode 100644 index 00000000..cd1f2218 --- /dev/null +++ b/packages/core/src/auth/storage.test.ts @@ -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 }]); + }); +}); diff --git a/packages/core/src/auth/storage.ts b/packages/core/src/auth/storage.ts new file mode 100644 index 00000000..c58163a9 --- /dev/null +++ b/packages/core/src/auth/storage.ts @@ -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]; + } + } + }; +} diff --git a/packages/core/src/id.ts b/packages/core/src/id.ts index 35fd625c..83d000e7 100644 --- a/packages/core/src/id.ts +++ b/packages/core/src/id.ts @@ -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) {