mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(auth): keep issuer state in Postgres
The issuer kept everything behind one get/set/remove/scan interface, which is what a library that must run on any provider's cache can offer. Three of the things kept there could not actually be served by it. An authorization code must be redeemable once and a refresh token spendable once, and through get and set the check and the write are separate steps — so two requests arriving together both read an unspent record, and both mint a session. In the refresh case that also means the reuse which reveals a stolen token is never recorded, because recording it is the write that the second caller overwrites. Each now has a table and an interface of its own: redeeming is one `delete ... returning`, spending is one `update ... where time_used is null returning *`, so exactly one caller is ever told it went first. This is the same argument the device grant already made, applied to the two records that had it too. Signing keys move for a different reason. Nothing races for them; they are the one record whose loss ends every session at once, and a cache is a place things may be evicted from. They are retired by setting a column rather than deleted, so the tokens they signed stay verifiable until they expire. Both credential tables store a hash and never the credential, as the device grant does. An authorization code travels in a query string and so passes through history, referrer headers and any log along the redirect; a refresh token resumes a session outright. What is left in the generic store is the rate-limit counters — written far more often than read, meaningless within the hour, and allowed to be approximate, since a lost increment costs one guess out of ten. Those move to Postgres too, so the only key-value binding this deploys with is gone and the control plane's state is one database. That was the point: nothing here now depends on a primitive a self-hoster cannot run. The generic scan also gained the separator on its prefix, so scanning `a` cannot return what is under `ab` — subjects and email addresses are both prefixes of longer subjects and email addresses. Deploying this signs everyone out. The signing keys and refresh tokens are in a store that is being left behind, so the issuer starts with a fresh key set and every existing token stops verifying.
This commit is contained in:
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>);
|
||||
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.
|
||||
|
||||
75
packages/auth/src/authorization-code.ts
Normal file
75
packages/auth/src/authorization-code.ts
Normal file
@@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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<AuthorizationCodeRecord | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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<AuthorizationCodeRecord>(storage, key);
|
||||
if (!record) return null;
|
||||
await Storage.remove(storage, key);
|
||||
return record;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<string> {
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
73
packages/auth/src/key.ts
Normal file
73
packages/auth/src/key.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Where the issuer's signing and encryption keys live.
|
||||
*
|
||||
* These are the only records here that are meant to outlive everything else:
|
||||
* every token this issuer has ever minted is verifiable only for as long as
|
||||
* the public half is still published, so losing this store invalidates every
|
||||
* session at once. That is the whole reason it is an interface — a store that
|
||||
* a deployment can point at its own database, rather than at whatever
|
||||
* key-value service the runtime happened to offer.
|
||||
*
|
||||
* Writes are append-only and rare: a key is created when no unexpired one of
|
||||
* its kind exists, and retired by being marked expired rather than removed, so
|
||||
* tokens it signed stay verifiable until they age out on their own.
|
||||
*
|
||||
* @packageDocumentation
|
||||
*/
|
||||
|
||||
import type { StorageAdapter } from './storage/storage.js';
|
||||
import { Storage } from './storage/storage.js';
|
||||
|
||||
/** Which half of the issuer's key material a record belongs to. */
|
||||
export type KeyKind = 'signing' | 'encryption';
|
||||
|
||||
/**
|
||||
* A key pair as stored: PEM text rather than a live key object.
|
||||
*
|
||||
* Kept serialized because the store is a database and not a process — the
|
||||
* import back into a usable key happens in {@link ./keys.js}, once per issuer
|
||||
* instance.
|
||||
*/
|
||||
export interface StoredKey {
|
||||
id: string;
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
alg: string;
|
||||
/** Epoch ms. */
|
||||
created: number;
|
||||
/** Epoch ms, set when the key is retired. Absent while it is still in use. */
|
||||
expired?: number;
|
||||
}
|
||||
|
||||
export interface KeyStore {
|
||||
/** Every key of a kind, expired ones included. Order does not matter. */
|
||||
list(kind: KeyKind): Promise<StoredKey[]>;
|
||||
create(kind: KeyKind, key: StoredKey): Promise<void>;
|
||||
}
|
||||
|
||||
/** The storage prefix a kind's keys have always been written under. */
|
||||
function prefix(kind: KeyKind): string {
|
||||
return kind === 'signing' ? 'signing:key' : 'encryption:key';
|
||||
}
|
||||
|
||||
/**
|
||||
* A key store backed by the generic {@link StorageAdapter}.
|
||||
*
|
||||
* The default, and what every deployment used before `keyStore` existed — the
|
||||
* keys are read and written under exactly the prefixes they always were, so an
|
||||
* issuer that does not pass a store keeps finding the keys it already had.
|
||||
*/
|
||||
export function StorageKeyStore(storage: StorageAdapter): KeyStore {
|
||||
return {
|
||||
async list(kind) {
|
||||
const results: StoredKey[] = [];
|
||||
for await (const [, value] of Storage.scan<StoredKey>(storage, [prefix(kind)])) {
|
||||
results.push(value);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
async create(kind, key) {
|
||||
await Storage.set(storage, [prefix(kind), key.id], key);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<KeyKind, string> = {
|
||||
signing: 'ES256',
|
||||
encryption: 'RSA-OAEP-512'
|
||||
};
|
||||
|
||||
export interface KeyPair {
|
||||
id: string;
|
||||
@@ -33,104 +26,58 @@ export interface KeyPair {
|
||||
jwk: JWK;
|
||||
}
|
||||
|
||||
async function toKeyPair(kind: KeyKind, stored: StoredKey): Promise<KeyPair> {
|
||||
// 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: alg[kind],
|
||||
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: the
|
||||
* first entry is what signs, and the rest are what still verifies. Retiring a
|
||||
* key therefore does not invalidate the tokens it signed — they age out on
|
||||
* their own — which is the only way a rotation is not also a mass sign-out.
|
||||
*/
|
||||
export async function legacySigningKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||
const alg = 'RS512';
|
||||
const results = [] as KeyPair[];
|
||||
const scanner = Storage.scan<SerializedKeyPair>(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<KeyPair[]> {
|
||||
const results = [] as KeyPair[];
|
||||
const scanner = Storage.scan<SerializedKeyPair>(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): Promise<KeyPair[]> {
|
||||
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 = {
|
||||
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);
|
||||
// Read back rather than returning what was just built, so that two issuers
|
||||
// starting at once converge on whichever key the store actually kept.
|
||||
return keysOf(store, kind);
|
||||
}
|
||||
|
||||
export async function encryptionKeys(storage: StorageAdapter): Promise<KeyPair[]> {
|
||||
const results = [] as KeyPair[];
|
||||
const scanner = Storage.scan<SerializedKeyPair>(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
|
||||
});
|
||||
export function signingKeys(store: KeyStore): Promise<KeyPair[]> {
|
||||
return keysOf(store, 'signing');
|
||||
}
|
||||
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 encryptionKeys(store: KeyStore): Promise<KeyPair[]> {
|
||||
return keysOf(store, 'encryption');
|
||||
}
|
||||
|
||||
138
packages/auth/src/refresh.ts
Normal file
138
packages/auth/src/refresh.ts
Normal file
@@ -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<void>;
|
||||
|
||||
/**
|
||||
* 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<RefreshClaim>;
|
||||
|
||||
/** Every token belonging to a subject, for when reuse is detected. */
|
||||
removeSubject(subject: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string> {
|
||||
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<RefreshRecord>(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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -54,3 +54,15 @@ export function lazy<T>(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<string> {
|
||||
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('');
|
||||
}
|
||||
|
||||
88
packages/core/migrations/0011_auth_state_in_postgres.sql
Normal file
88
packages/core/migrations/0011_auth_state_in_postgres.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- 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 different: nothing races for it. It is here because 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. Keys are retired by setting
|
||||
-- `expired_at`, never deleted, so the tokens they signed stay verifiable until
|
||||
-- they expire on their own.
|
||||
--
|
||||
-- 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_kv_key_unique" ON "auth_kv" USING btree ("key");
|
||||
2797
packages/core/migrations/meta/0011_snapshot.json
Normal file
2797
packages/core/migrations/meta/0011_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,13 @@
|
||||
"when": 1788590292860,
|
||||
"tag": "0010_device_authorization_grant",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1788605264671,
|
||||
"tag": "0011_auth_state_in_postgres",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
38
packages/core/src/auth/authorization-code.sql.ts
Normal file
38
packages/core/src/auth/authorization-code.sql.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, utc } from '../db/types.js';
|
||||
|
||||
/**
|
||||
* An authorization code, between the redirect that issued it and the exchange
|
||||
* that spends it.
|
||||
*
|
||||
* It lives sixty seconds, which is the same argument the device grant makes:
|
||||
* short-lived state in a table anyway, because redeeming it has to happen
|
||||
* exactly once and a store that reads and writes whole records cannot promise
|
||||
* that. Here the exchange is one `delete ... returning`, so of two requests
|
||||
* carrying the same code exactly one is answered — and the other is answered
|
||||
* as though the code never existed, which from the outside it now does not.
|
||||
*
|
||||
* `code_hash` and not the code. A code travels to the browser in a query
|
||||
* string, so it passes through history, referrer headers and any log along the
|
||||
* redirect. What is kept is enough to recognise one and not enough to present
|
||||
* it.
|
||||
*/
|
||||
export const AuthorizationCodeTable = pgTable(
|
||||
'authorization_code',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
|
||||
codeHash: text('code_hash').notNull(),
|
||||
expiresAt: utc('expires_at').notNull(),
|
||||
|
||||
/**
|
||||
* Who the code stands for and what it may be exchanged under: the
|
||||
* subject, the client, the redirect it was issued against, the token
|
||||
* lifetimes, and the PKCE challenge if there was one.
|
||||
*/
|
||||
payload: jsonb('payload').$type<Record<string, unknown>>().notNull()
|
||||
},
|
||||
(t) => [uniqueIndex('authorization_code_hash_unique').on(t.codeHash)]
|
||||
);
|
||||
92
packages/core/src/auth/authorization-code.test.ts
Normal file
92
packages/core/src/auth/authorization-code.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { AuthorizationCodeRecord } from '@nestri/auth/authorization-code';
|
||||
|
||||
import { testDb } from '../db/test.js';
|
||||
import { PostgresCodeStore } from './authorization-code.js';
|
||||
|
||||
const sql = testDb();
|
||||
const store = PostgresCodeStore();
|
||||
|
||||
let counter = 0;
|
||||
function hash(): string {
|
||||
counter += 1;
|
||||
return `authcode-fixture-${counter}`.padEnd(64, '0');
|
||||
}
|
||||
|
||||
function record(): AuthorizationCodeRecord {
|
||||
return {
|
||||
type: 'user',
|
||||
properties: { userID: 'usr_fixture' },
|
||||
subject: 'user:authcode-fixture',
|
||||
clientID: 'desktop',
|
||||
redirectURI: 'https://example.com/callback',
|
||||
ttl: { access: 60, refresh: 600 }
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await sql`delete from authorization_code where code_hash like 'authcode-fixture-%'`;
|
||||
}
|
||||
|
||||
beforeEach(cleanup);
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await sql.end();
|
||||
});
|
||||
|
||||
describe('PostgresCodeStore', () => {
|
||||
test('an unknown code returns null', async () => {
|
||||
expect(await store.consume(hash())).toBeNull();
|
||||
});
|
||||
|
||||
test('consuming returns the record it was created with', async () => {
|
||||
const h = hash();
|
||||
await store.create(h, record(), 60);
|
||||
|
||||
const consumed = await store.consume(h);
|
||||
expect(consumed?.redirectURI).toBe('https://example.com/callback');
|
||||
expect(consumed?.clientID).toBe('desktop');
|
||||
});
|
||||
|
||||
test('a code is redeemable once', async () => {
|
||||
const h = hash();
|
||||
await store.create(h, record(), 60);
|
||||
|
||||
expect(await store.consume(h)).not.toBeNull();
|
||||
expect(await store.consume(h)).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The property the table exists for: two exchanges of one code arriving
|
||||
* together must not both be answered, because each answer is a full
|
||||
* session. Started without awaiting in turn so they really do overlap.
|
||||
*/
|
||||
test('only one of several simultaneous exchanges is served', async () => {
|
||||
const h = hash();
|
||||
await store.create(h, record(), 60);
|
||||
|
||||
const results = await Promise.all(Array.from({ length: 5 }, () => store.consume(h)));
|
||||
|
||||
expect(results.filter((r) => r !== null)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('an expired code cannot be redeemed', async () => {
|
||||
const h = hash();
|
||||
await store.create(h, record(), 60);
|
||||
await sql`update authorization_code set expires_at = now() - interval '1 second' where code_hash = ${h}`;
|
||||
|
||||
expect(await store.consume(h)).toBeNull();
|
||||
});
|
||||
|
||||
test('creating sweeps codes that have already expired', async () => {
|
||||
const stale = hash();
|
||||
await store.create(stale, record(), 60);
|
||||
await sql`update authorization_code set expires_at = now() - interval '1 second' where code_hash = ${stale}`;
|
||||
|
||||
await store.create(hash(), record(), 60);
|
||||
|
||||
const [row] = await sql`select count(*)::int as n from authorization_code where code_hash = ${stale}`;
|
||||
expect(row!.n).toBe(0);
|
||||
});
|
||||
});
|
||||
54
packages/core/src/auth/authorization-code.ts
Normal file
54
packages/core/src/auth/authorization-code.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { AuthorizationCodeRecord, CodeStore } from '@nestri/auth/authorization-code';
|
||||
import { and, eq, lt, sql } from 'drizzle-orm';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { AuthorizationCodeTable } from './authorization-code.sql.js';
|
||||
|
||||
/**
|
||||
* Authorization codes, kept where redeeming one can be a single statement.
|
||||
*
|
||||
* `consume` is a delete that returns what it deleted, which is the whole point
|
||||
* of the table: it is what makes a code redeemable once rather than
|
||||
* approximately once. A select, a decision in application code and a delete
|
||||
* would answer two simultaneous exchanges of the same code, and each answer is
|
||||
* a complete session.
|
||||
*/
|
||||
export function PostgresCodeStore(): CodeStore {
|
||||
return {
|
||||
async create(codeHash, record, ttl) {
|
||||
await Database.use(async (tx) => {
|
||||
// Swept here rather than on a schedule. A code lives a minute
|
||||
// and this is the only statement that adds one, so the table
|
||||
// stays bounded by how many sign-ins are mid-redirect.
|
||||
await tx
|
||||
.delete(AuthorizationCodeTable)
|
||||
.where(lt(AuthorizationCodeTable.expiresAt, new Date()));
|
||||
|
||||
await tx.insert(AuthorizationCodeTable).values({
|
||||
id: Identifier.ascending('authorizationCode'),
|
||||
codeHash,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||
payload: record as unknown as Record<string, unknown>
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async consume(codeHash) {
|
||||
return Database.use(async (tx) =>
|
||||
tx
|
||||
.delete(AuthorizationCodeTable)
|
||||
.where(
|
||||
and(
|
||||
eq(AuthorizationCodeTable.codeHash, codeHash),
|
||||
sql`${AuthorizationCodeTable.expiresAt} > now()`
|
||||
)
|
||||
)
|
||||
.returning({ payload: AuthorizationCodeTable.payload })
|
||||
.then((rows) =>
|
||||
rows[0] ? (rows[0].payload as unknown as AuthorizationCodeRecord) : null
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
49
packages/core/src/auth/refresh-token.sql.ts
Normal file
49
packages/core/src/auth/refresh-token.sql.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { index, jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, utc } from '../db/types.js';
|
||||
|
||||
/**
|
||||
* A refresh token: the longest-lived credential the issuer hands out.
|
||||
*
|
||||
* Two things make this a table rather than a cache entry, and neither is
|
||||
* durability.
|
||||
*
|
||||
* The first is `time_used`. Reuse detection works by remembering when a token
|
||||
* was first spent, so the check that it has not been spent and the record that
|
||||
* it now has must be the same operation. Read it, compare, write it back, and
|
||||
* two refreshes arriving together both look like the first one — which is
|
||||
* exactly the case reuse detection exists to catch. Here it is one
|
||||
* `update ... where time_used is null returning *`, so of two callers only one
|
||||
* is ever told it went first.
|
||||
*
|
||||
* The second is that these rows are a person's sessions. Signing out
|
||||
* everywhere, and the mass revocation that follows a detected reuse, are a
|
||||
* query over `subject` — which is a thing to be indexed rather than a prefix
|
||||
* scan over every key in a store.
|
||||
*
|
||||
* `token_hash` and not the token. Whoever holds a refresh token can resume the
|
||||
* session it belongs to, so a readable store would otherwise be a readable set
|
||||
* of every live session.
|
||||
*/
|
||||
export const RefreshTokenTable = pgTable(
|
||||
'refresh_token',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
|
||||
/** The issuer's subject string, e.g. `user:0123456789abcdef`. */
|
||||
subject: text('subject').notNull(),
|
||||
tokenHash: text('token_hash').notNull(),
|
||||
expiresAt: utc('expires_at').notNull(),
|
||||
|
||||
/** Null until the token is spent. Written exactly once, by whoever spends it. */
|
||||
timeUsed: utc('time_used'),
|
||||
|
||||
/** The subject type, properties, client and token lifetimes this stands for. */
|
||||
payload: jsonb('payload').$type<Record<string, unknown>>().notNull()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('refresh_token_hash_unique').on(t.tokenHash),
|
||||
index('refresh_token_subject_idx').on(t.subject)
|
||||
]
|
||||
);
|
||||
137
packages/core/src/auth/refresh-token.test.ts
Normal file
137
packages/core/src/auth/refresh-token.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { RefreshRecord } from '@nestri/auth/refresh';
|
||||
|
||||
import { testDb } from '../db/test.js';
|
||||
import { PostgresRefreshStore } from './refresh-token.js';
|
||||
|
||||
const sql = testDb();
|
||||
const store = PostgresRefreshStore();
|
||||
|
||||
const SUBJECT = 'user:refresh-fixture';
|
||||
|
||||
let counter = 0;
|
||||
function hash(): string {
|
||||
counter += 1;
|
||||
return `refresh-fixture-${counter}`.padEnd(64, '0');
|
||||
}
|
||||
|
||||
function record(overrides: Partial<RefreshRecord> = {}): RefreshRecord {
|
||||
return {
|
||||
type: 'user',
|
||||
properties: { userID: 'usr_fixture' },
|
||||
subject: SUBJECT,
|
||||
clientID: 'desktop',
|
||||
ttl: { access: 60, refresh: 600 },
|
||||
nextToken: 'next',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await sql`delete from refresh_token where token_hash like 'refresh-fixture-%'`;
|
||||
}
|
||||
|
||||
beforeEach(cleanup);
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await sql.end();
|
||||
});
|
||||
|
||||
describe('PostgresRefreshStore', () => {
|
||||
test('an unknown token is missing rather than an error', async () => {
|
||||
const claim = await store.claim(SUBJECT, hash(), Date.now(), 60);
|
||||
expect(claim.status).toBe('missing');
|
||||
});
|
||||
|
||||
test('a token belonging to another subject is not spendable', async () => {
|
||||
const h = hash();
|
||||
await store.create(SUBJECT, h, record(), 600);
|
||||
|
||||
const claim = await store.claim('user:someone-else', h, Date.now(), 60);
|
||||
expect(claim.status).toBe('missing');
|
||||
});
|
||||
|
||||
test('the first claim is fresh and carries the record back', async () => {
|
||||
const h = hash();
|
||||
await store.create(SUBJECT, h, record(), 600);
|
||||
|
||||
const claim = await store.claim(SUBJECT, h, Date.now(), 60);
|
||||
expect(claim.status).toBe('fresh');
|
||||
if (claim.status !== 'fresh') throw new Error('unreachable');
|
||||
expect(claim.record.clientID).toBe('desktop');
|
||||
expect(claim.record.nextToken).toBe('next');
|
||||
});
|
||||
|
||||
test('a second claim reports the reuse and when the token was first spent', async () => {
|
||||
const h = hash();
|
||||
const at = Date.now();
|
||||
await store.create(SUBJECT, h, record(), 600);
|
||||
|
||||
await store.claim(SUBJECT, h, at, 60);
|
||||
const again = await store.claim(SUBJECT, h, at + 1000, 60);
|
||||
|
||||
expect(again.status).toBe('reused');
|
||||
if (again.status !== 'reused') throw new Error('unreachable');
|
||||
// The time the *first* caller spent it, not the time of this attempt —
|
||||
// which is what the reuse window is measured from.
|
||||
expect(again.timeUsed).toBe(at);
|
||||
});
|
||||
|
||||
/**
|
||||
* The property the whole table exists for.
|
||||
*
|
||||
* Five claims of one token, started together and never awaited in turn, so
|
||||
* they genuinely overlap in the database rather than queueing behind each
|
||||
* other. Exactly one may be told it went first; through a store that reads
|
||||
* and writes whole records, all five are.
|
||||
*/
|
||||
test('only one of several simultaneous claims is fresh', async () => {
|
||||
const h = hash();
|
||||
const at = Date.now();
|
||||
await store.create(SUBJECT, h, record(), 600);
|
||||
|
||||
const claims = await Promise.all(
|
||||
Array.from({ length: 5 }, () => store.claim(SUBJECT, h, at, 60))
|
||||
);
|
||||
|
||||
expect(claims.filter((c) => c.status === 'fresh')).toHaveLength(1);
|
||||
expect(claims.filter((c) => c.status === 'reused')).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('with no retention the token is taken away, and later claims find nothing', async () => {
|
||||
const h = hash();
|
||||
await store.create(SUBJECT, h, record(), 600);
|
||||
|
||||
const first = await store.claim(SUBJECT, h, Date.now(), 0);
|
||||
const second = await store.claim(SUBJECT, h, Date.now(), 0);
|
||||
|
||||
expect(first.status).toBe('fresh');
|
||||
// Not `reused`: nothing was retained, so there is nothing left to
|
||||
// recognise. Reuse detection is what retention buys.
|
||||
expect(second.status).toBe('missing');
|
||||
});
|
||||
|
||||
test('an expired token cannot be spent', async () => {
|
||||
const h = hash();
|
||||
await store.create(SUBJECT, h, record(), 600);
|
||||
await sql`update refresh_token set expires_at = now() - interval '1 second' where token_hash = ${h}`;
|
||||
|
||||
const claim = await store.claim(SUBJECT, h, Date.now(), 60);
|
||||
expect(claim.status).toBe('missing');
|
||||
});
|
||||
|
||||
test('removing a subject takes every token it has and leaves other subjects alone', async () => {
|
||||
const mine = [hash(), hash()];
|
||||
const theirs = hash();
|
||||
for (const h of mine) await store.create(SUBJECT, h, record(), 600);
|
||||
await store.create('user:other', theirs, record({ subject: 'user:other' }), 600);
|
||||
|
||||
await store.removeSubject(SUBJECT);
|
||||
|
||||
for (const h of mine) {
|
||||
expect((await store.claim(SUBJECT, h, Date.now(), 60)).status).toBe('missing');
|
||||
}
|
||||
expect((await store.claim('user:other', theirs, Date.now(), 60)).status).toBe('fresh');
|
||||
});
|
||||
});
|
||||
84
packages/core/src/auth/refresh-token.ts
Normal file
84
packages/core/src/auth/refresh-token.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import type { RefreshClaim, RefreshRecord, RefreshStore } from '@nestri/auth/refresh';
|
||||
import { and, eq, isNull, lt, sql } from 'drizzle-orm';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { RefreshTokenTable } from './refresh-token.sql.js';
|
||||
|
||||
/**
|
||||
* Refresh tokens, kept where spending one is a single statement.
|
||||
*
|
||||
* `claim` is the reason this exists. Everything else here is ordinary.
|
||||
*/
|
||||
export function PostgresRefreshStore(): RefreshStore {
|
||||
return {
|
||||
async create(subject, tokenHash, record, ttl) {
|
||||
await Database.use(async (tx) => {
|
||||
// Swept on the statement that adds rows, as everywhere else in
|
||||
// this module. A refresh token lives a year by default, so this
|
||||
// sweep is about the tokens that were spent and retained for
|
||||
// reuse detection rather than about the live ones.
|
||||
await tx.delete(RefreshTokenTable).where(lt(RefreshTokenTable.expiresAt, new Date()));
|
||||
|
||||
await tx.insert(RefreshTokenTable).values({
|
||||
id: Identifier.ascending('refreshToken'),
|
||||
subject,
|
||||
tokenHash,
|
||||
expiresAt: new Date(Date.now() + ttl * 1000),
|
||||
timeUsed: record.timeUsed ? new Date(record.timeUsed) : null,
|
||||
payload: record as unknown as Record<string, unknown>
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async claim(subject, tokenHash, at, retainFor) {
|
||||
const live = and(
|
||||
eq(RefreshTokenTable.tokenHash, tokenHash),
|
||||
eq(RefreshTokenTable.subject, subject),
|
||||
sql`${RefreshTokenTable.expiresAt} > now()`
|
||||
);
|
||||
|
||||
return Database.use(async (tx): Promise<RefreshClaim> => {
|
||||
// Nothing is retained, so spending the token is taking it away.
|
||||
// One caller gets the row; every other attempt reads `missing`,
|
||||
// which is the correct answer once it no longer exists.
|
||||
if (retainFor <= 0) {
|
||||
const [row] = await tx.delete(RefreshTokenTable).where(live).returning();
|
||||
if (!row) return { status: 'missing' };
|
||||
return { status: 'fresh', record: row.payload as unknown as RefreshRecord };
|
||||
}
|
||||
|
||||
// `where time_used is null` is what makes going first happen
|
||||
// once. Two refreshes arriving together both run this; the
|
||||
// second matches no row, because by then `time_used` is set.
|
||||
// The expiry is pushed out to the retention window so the spent
|
||||
// record survives long enough to recognise a reuse.
|
||||
const [claimed] = await tx
|
||||
.update(RefreshTokenTable)
|
||||
.set({ timeUsed: new Date(at), expiresAt: new Date(at + retainFor * 1000) })
|
||||
.where(and(live, isNull(RefreshTokenTable.timeUsed)))
|
||||
.returning();
|
||||
if (claimed) {
|
||||
return { status: 'fresh', record: claimed.payload as unknown as RefreshRecord };
|
||||
}
|
||||
|
||||
// Either it was spent already or it was never here. Reading now
|
||||
// is safe where reading first was not: `time_used` is written
|
||||
// once and never changes, so there is no decision left to race.
|
||||
const [existing] = await tx.select().from(RefreshTokenTable).where(live);
|
||||
if (!existing?.timeUsed) return { status: 'missing' };
|
||||
return {
|
||||
status: 'reused',
|
||||
record: existing.payload as unknown as RefreshRecord,
|
||||
timeUsed: existing.timeUsed.getTime()
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async removeSubject(subject) {
|
||||
await Database.use(async (tx) => {
|
||||
await tx.delete(RefreshTokenTable).where(eq(RefreshTokenTable.subject, subject));
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
40
packages/core/src/auth/signing-key.sql.ts
Normal file
40
packages/core/src/auth/signing-key.sql.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, utc } from '../db/types.js';
|
||||
|
||||
export const AuthKeyKindEnum = pgEnum('auth_key_kind', ['signing', 'encryption']);
|
||||
|
||||
/**
|
||||
* The issuer's own key material.
|
||||
*
|
||||
* The longest-lived thing here by a wide margin, and the only record whose
|
||||
* loss is felt by everyone at once: every access token names the key that
|
||||
* signed it, so a fresh key set means every session ends and every client has
|
||||
* to sign in again. That is the argument for a table rather than a cache —
|
||||
* not query patterns, just that this is the row nobody can afford to have
|
||||
* quietly evicted.
|
||||
*
|
||||
* Retiring a key sets `expired_at` instead of deleting it. The tokens it
|
||||
* signed stay verifiable until they age out on their own, which is what keeps
|
||||
* a rotation from also being a mass sign-out.
|
||||
*/
|
||||
export const AuthKeyTable = pgTable(
|
||||
'auth_key',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
|
||||
/** The issuer's own identifier for the key, and the `kid` on the JWT. */
|
||||
keyId: text('key_id').notNull(),
|
||||
kind: AuthKeyKindEnum('kind').notNull(),
|
||||
|
||||
/** JWA name, on the row rather than assumed, so a rotation may change it. */
|
||||
alg: text('alg').notNull(),
|
||||
publicKey: text('public_key').notNull(),
|
||||
privateKey: text('private_key').notNull(),
|
||||
|
||||
/** Null while the key is still in use. Set when it is retired. */
|
||||
expiredAt: utc('expired_at')
|
||||
},
|
||||
(t) => [uniqueIndex('auth_key_key_id_unique').on(t.keyId)]
|
||||
);
|
||||
74
packages/core/src/auth/signing-key.test.ts
Normal file
74
packages/core/src/auth/signing-key.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import type { StoredKey } from '@nestri/auth/key';
|
||||
|
||||
import { testDb } from '../db/test.js';
|
||||
import { PostgresKeyStore } from './signing-key.js';
|
||||
|
||||
const sql = testDb();
|
||||
const store = PostgresKeyStore();
|
||||
|
||||
let counter = 0;
|
||||
function key(overrides: Partial<StoredKey> = {}): StoredKey {
|
||||
counter += 1;
|
||||
return {
|
||||
id: `key-fixture-${counter}`,
|
||||
publicKey: '-----BEGIN PUBLIC KEY-----fixture-----END PUBLIC KEY-----',
|
||||
privateKey: '-----BEGIN PRIVATE KEY-----fixture-----END PRIVATE KEY-----',
|
||||
alg: 'ES256',
|
||||
created: Date.now(),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await sql`delete from auth_key where key_id like 'key-fixture-%'`;
|
||||
}
|
||||
|
||||
beforeEach(cleanup);
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await sql.end();
|
||||
});
|
||||
|
||||
describe('PostgresKeyStore', () => {
|
||||
test('a kind with no keys lists empty', async () => {
|
||||
expect(await store.list('encryption')).toEqual([]);
|
||||
});
|
||||
|
||||
test('a stored key comes back with its fields intact', async () => {
|
||||
const k = key();
|
||||
await store.create('signing', k);
|
||||
|
||||
const [found] = await store.list('signing');
|
||||
expect(found?.id).toBe(k.id);
|
||||
expect(found?.alg).toBe('ES256');
|
||||
expect(found?.privateKey).toBe(k.privateKey);
|
||||
// Absent rather than null: a key still in use has no expiry.
|
||||
expect(found?.expired).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the two kinds do not see each other', async () => {
|
||||
await store.create('signing', key());
|
||||
await store.create('encryption', key({ alg: 'RSA-OAEP-512' }));
|
||||
|
||||
expect(await store.list('signing')).toHaveLength(1);
|
||||
expect((await store.list('encryption'))[0]?.alg).toBe('RSA-OAEP-512');
|
||||
});
|
||||
|
||||
test('a retired key is still listed, so the tokens it signed still verify', async () => {
|
||||
const expired = Date.now() - 1000;
|
||||
await store.create('signing', key({ expired }));
|
||||
|
||||
const [found] = await store.list('signing');
|
||||
expect(found?.expired).toBe(expired);
|
||||
});
|
||||
|
||||
test('writing the same key id twice is not an error and does not duplicate it', async () => {
|
||||
const k = key();
|
||||
await store.create('signing', k);
|
||||
await store.create('signing', k);
|
||||
|
||||
expect(await store.list('signing')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
59
packages/core/src/auth/signing-key.ts
Normal file
59
packages/core/src/auth/signing-key.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { KeyKind, KeyStore, StoredKey } from '@nestri/auth/key';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { AuthKeyTable } from './signing-key.sql.js';
|
||||
|
||||
type Row = typeof AuthKeyTable.$inferSelect;
|
||||
|
||||
function toStored(row: Row): StoredKey {
|
||||
return {
|
||||
id: row.keyId,
|
||||
publicKey: row.publicKey,
|
||||
privateKey: row.privateKey,
|
||||
alg: row.alg,
|
||||
created: row.timeCreated.getTime(),
|
||||
expired: row.expiredAt?.getTime()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The issuer's signing and encryption keys, in Postgres.
|
||||
*
|
||||
* Two issuers starting against an empty table both generate a key and both
|
||||
* insert one, and that is fine: each is valid, both are published in the JWKS,
|
||||
* so a token signed by either verifies against either issuer. The conflict
|
||||
* clause is not for that race — a key id is a fresh UUID and cannot collide
|
||||
* with another issuer's — it is so that a retried write is not an error.
|
||||
*/
|
||||
export function PostgresKeyStore(): KeyStore {
|
||||
return {
|
||||
async list(kind: KeyKind) {
|
||||
return Database.use(async (tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(AuthKeyTable)
|
||||
.where(eq(AuthKeyTable.kind, kind))
|
||||
.then((rows) => rows.map(toStored))
|
||||
);
|
||||
},
|
||||
|
||||
async create(kind: KeyKind, key: StoredKey) {
|
||||
await Database.use(async (tx) => {
|
||||
await tx
|
||||
.insert(AuthKeyTable)
|
||||
.values({
|
||||
id: Identifier.ascending('authKey'),
|
||||
keyId: key.id,
|
||||
kind,
|
||||
alg: key.alg,
|
||||
publicKey: key.publicKey,
|
||||
privateKey: key.privateKey,
|
||||
expiredAt: key.expired ? new Date(key.expired) : null
|
||||
})
|
||||
.onConflictDoNothing({ target: AuthKeyTable.keyId });
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
39
packages/core/src/auth/storage.sql.ts
Normal file
39
packages/core/src/auth/storage.sql.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { jsonb, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, utc } from '../db/types.js';
|
||||
|
||||
/**
|
||||
* What is left of the issuer's key-value store once everything with a shape
|
||||
* has been given a table of its own.
|
||||
*
|
||||
* Signing keys, authorization codes and refresh tokens each moved out, because
|
||||
* each is a record whose fields are worth naming, whose changes are worth a
|
||||
* migration, and — for the last two — whose transitions have to happen exactly
|
||||
* once. What remains is counters: how many user codes an address has guessed
|
||||
* at, how many times a sign-in code has been retried, when a code was last
|
||||
* sent. They have none of those properties. A counter is written far more often
|
||||
* than it is read, is meaningless an hour later, and is allowed to be
|
||||
* approximate — losing one increment costs an attacker one extra guess out of a
|
||||
* budget of ten.
|
||||
*
|
||||
* So this table stays deliberately generic, and is the one place a `jsonb`
|
||||
* blob with no migration behind it is the right answer rather than a shortcut.
|
||||
*/
|
||||
export const AuthKvTable = pgTable(
|
||||
'auth_kv',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
|
||||
/**
|
||||
* The caller's key array, joined by the unit separator the issuer's
|
||||
* storage interface uses. Stored as one string rather than split into
|
||||
* columns because nothing here ever queries a component of it.
|
||||
*/
|
||||
key: text('key').notNull(),
|
||||
value: jsonb('value').$type<Record<string, unknown>>().notNull(),
|
||||
/** Null for a record with no expiry. */
|
||||
expiresAt: utc('expires_at')
|
||||
},
|
||||
(t) => [uniqueIndex('auth_kv_key_unique').on(t.key)]
|
||||
);
|
||||
99
packages/core/src/auth/storage.test.ts
Normal file
99
packages/core/src/auth/storage.test.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { afterAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { testDb } from '../db/test.js';
|
||||
import { PostgresStorage } from './storage.js';
|
||||
|
||||
const sql = testDb();
|
||||
const storage = PostgresStorage();
|
||||
|
||||
const PREFIX = 'kv-fixture';
|
||||
|
||||
async function cleanup() {
|
||||
await sql`delete from auth_kv where key like ${PREFIX + '%'}`;
|
||||
}
|
||||
|
||||
beforeEach(cleanup);
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await sql.end();
|
||||
});
|
||||
|
||||
describe('PostgresStorage', () => {
|
||||
test('a missing key reads as undefined', async () => {
|
||||
expect(await storage.get([PREFIX, 'absent'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('what was set is what is read back', async () => {
|
||||
await storage.set([PREFIX, 'counter'], { count: 3, resetAt: 12345 });
|
||||
expect(await storage.get([PREFIX, 'counter'])).toEqual({ count: 3, resetAt: 12345 });
|
||||
});
|
||||
|
||||
test('setting the same key again replaces the value', async () => {
|
||||
await storage.set([PREFIX, 'counter'], { count: 1 });
|
||||
await storage.set([PREFIX, 'counter'], { count: 2 });
|
||||
|
||||
expect(await storage.get([PREFIX, 'counter'])).toEqual({ count: 2 });
|
||||
const [row] = await sql`select count(*)::int as n from auth_kv where key like ${PREFIX + '%'}`;
|
||||
expect(row!.n).toBe(1);
|
||||
});
|
||||
|
||||
test('removing a key makes it unreadable', async () => {
|
||||
await storage.set([PREFIX, 'gone'], { a: 1 });
|
||||
await storage.remove([PREFIX, 'gone']);
|
||||
expect(await storage.get([PREFIX, 'gone'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('an expired value reads as absent', async () => {
|
||||
await storage.set([PREFIX, 'stale'], { a: 1 }, new Date(Date.now() - 1000));
|
||||
expect(await storage.get([PREFIX, 'stale'])).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a value with an expiry in the future is still readable', async () => {
|
||||
await storage.set([PREFIX, 'live'], { a: 1 }, new Date(Date.now() + 60_000));
|
||||
expect(await storage.get([PREFIX, 'live'])).toEqual({ a: 1 });
|
||||
});
|
||||
|
||||
test('scan returns everything under a prefix, split back into a key array', async () => {
|
||||
await storage.set([PREFIX, 'scan', 'one'], { n: 1 });
|
||||
await storage.set([PREFIX, 'scan', 'two'], { n: 2 });
|
||||
|
||||
const found = await Array.fromAsync(storage.scan([PREFIX, 'scan']));
|
||||
expect(found).toHaveLength(2);
|
||||
expect(found.map(([key]) => key)).toEqual([
|
||||
[PREFIX, 'scan', 'one'],
|
||||
[PREFIX, 'scan', 'two']
|
||||
]);
|
||||
expect(found.map(([, value]) => value)).toEqual([{ n: 1 }, { n: 2 }]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A prefix match on the bare string would return these too, and the keys
|
||||
* that hit this are real ones — a subject is a prefix of a longer subject.
|
||||
*/
|
||||
test('scan does not reach into a prefix that merely starts the same way', async () => {
|
||||
await storage.set([PREFIX, 'user'], { n: 1 });
|
||||
await storage.set([PREFIX, 'user-extended'], { n: 2 });
|
||||
await storage.set([PREFIX, 'user', 'child'], { n: 3 });
|
||||
|
||||
const found = await Array.fromAsync(storage.scan([PREFIX, 'user']));
|
||||
expect(found.map(([, value]) => value)).toEqual([{ n: 3 }]);
|
||||
});
|
||||
|
||||
/** `%` and `_` are LIKE wildcards, and keys here are built from user input. */
|
||||
test('a key containing LIKE wildcards does not widen a scan', async () => {
|
||||
await storage.set([PREFIX, '%'], { n: 1 });
|
||||
await storage.set([PREFIX, 'literal'], { n: 2 });
|
||||
await storage.set([PREFIX, '%', 'child'], { n: 3 });
|
||||
|
||||
const found = await Array.fromAsync(storage.scan([PREFIX, '%']));
|
||||
expect(found.map(([, value]) => value)).toEqual([{ n: 3 }]);
|
||||
});
|
||||
|
||||
test('scan skips values that have expired', async () => {
|
||||
await storage.set([PREFIX, 'mixed', 'live'], { n: 1 }, new Date(Date.now() + 60_000));
|
||||
await storage.set([PREFIX, 'mixed', 'dead'], { n: 2 }, new Date(Date.now() - 1000));
|
||||
|
||||
const found = await Array.fromAsync(storage.scan([PREFIX, 'mixed']));
|
||||
expect(found.map(([, value]) => value)).toEqual([{ n: 1 }]);
|
||||
});
|
||||
});
|
||||
96
packages/core/src/auth/storage.ts
Normal file
96
packages/core/src/auth/storage.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { StorageAdapter } from '@nestri/auth/storage/storage';
|
||||
import { joinKey, splitKey } from '@nestri/auth/storage/storage';
|
||||
import { and, eq, isNull, lt, or, sql } from 'drizzle-orm';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { AuthKvTable } from './storage.sql.js';
|
||||
|
||||
/** Whether a row is still live, as a SQL fragment. */
|
||||
const unexpired = () => or(isNull(AuthKvTable.expiresAt), sql`${AuthKvTable.expiresAt} > now()`);
|
||||
|
||||
/**
|
||||
* A prefix, escaped so that a key containing `%` or `_` cannot widen the match.
|
||||
*
|
||||
* Postgres treats both as wildcards in `LIKE`, and the keys here are built
|
||||
* from caller-supplied strings — an email address, a caller's own address —
|
||||
* so neither character is hypothetical.
|
||||
*/
|
||||
function escapeLike(value: string): string {
|
||||
return value.replace(/([\\%_])/g, '\\$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* The issuer's remaining key-value state, in Postgres.
|
||||
*
|
||||
* This is the small half of what used to be one store: the counters behind the
|
||||
* device-code guess limit and the sign-in code retry limit. Everything with a
|
||||
* shape moved to a table that names its fields — see `AuthKvTable`.
|
||||
*
|
||||
* There is no sweeper. Expired rows are removed when they are next read and
|
||||
* when a write happens to notice them, which is enough because every key here
|
||||
* is written far more often than the table grows: a counter is rewritten on
|
||||
* every attempt by the same caller, and there are only ever as many rows as
|
||||
* there are callers inside one window.
|
||||
*/
|
||||
export function PostgresStorage(): StorageAdapter {
|
||||
return {
|
||||
async get(key: string[]) {
|
||||
const joined = joinKey(key);
|
||||
return Database.use(async (tx) =>
|
||||
tx
|
||||
.select({ value: AuthKvTable.value })
|
||||
.from(AuthKvTable)
|
||||
.where(and(eq(AuthKvTable.key, joined), unexpired()))
|
||||
.then((rows) => rows[0]?.value)
|
||||
);
|
||||
},
|
||||
|
||||
async set(key: string[], value: any, expiry?: Date) {
|
||||
const joined = joinKey(key);
|
||||
await Database.use(async (tx) => {
|
||||
// Swept opportunistically rather than on a schedule, on the
|
||||
// only statement here that can add a row.
|
||||
await tx.delete(AuthKvTable).where(lt(AuthKvTable.expiresAt, new Date()));
|
||||
|
||||
await tx
|
||||
.insert(AuthKvTable)
|
||||
.values({
|
||||
id: Identifier.ascending('authKv'),
|
||||
key: joined,
|
||||
value,
|
||||
expiresAt: expiry ?? null
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: AuthKvTable.key,
|
||||
set: { value, expiresAt: expiry ?? null, timeUpdated: new Date() }
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
async remove(key: string[]) {
|
||||
const joined = joinKey(key);
|
||||
await Database.use(async (tx) => {
|
||||
await tx.delete(AuthKvTable).where(eq(AuthKvTable.key, joined));
|
||||
});
|
||||
},
|
||||
|
||||
async *scan(prefix: string[]) {
|
||||
// The separator is part of the prefix, so that scanning `['a']`
|
||||
// cannot also return the keys under `['ab']`. Matching on the bare
|
||||
// prefix is a real collision — subjects and email addresses are
|
||||
// both prefixes of longer subjects and email addresses.
|
||||
const pattern = escapeLike(joinKey([...prefix, ''])) + '%';
|
||||
const rows = await Database.use(async (tx) =>
|
||||
tx
|
||||
.select({ key: AuthKvTable.key, value: AuthKvTable.value })
|
||||
.from(AuthKvTable)
|
||||
.where(and(sql`${AuthKvTable.key} LIKE ${pattern}`, unexpired()))
|
||||
.orderBy(AuthKvTable.key)
|
||||
);
|
||||
for (const row of rows) {
|
||||
yield [splitKey(row.key), row.value] as [string[], any];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user