Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ Three packages share version 0.1.0: `pgstencil` (infrastructure and test primiti

Run `pnpm packages:pack` to produce the three archives in `dist/packages`. They contain ESM JavaScript, TypeScript declarations, the MIT license, and required SQL/Compose assets. Workspace development uses source exports; packing switches the exports to compiled files. Nothing runs migrations during install.

Copy the tarballs to a consumer's `vendor/` directory, depend on them using `file:` paths, and override all three package names to those same paths in the consumer's pnpm configuration. The override for `pgstencil` ensures auth and billing's peer also resolves locally. Pack a committed revision in a clean checkout, because an untracked file under `migrations` would otherwise ship in the archive. Commit the archives and lockfile together for a reproducible temporary distribution. Once public npm is configured, replace these paths with exact registry versions and remove the overrides.
Copy the tarballs to a consumer's `vendor/` directory, depend on them using `file:` paths, and override all three package names to those same paths in the consumer's pnpm configuration. The override for `pgstencil` ensures auth and billing's peer also resolves locally. `packages:pack` refuses a modified build input tree, because an untracked file under `migrations` would otherwise ship in the archive; `--allow-dirty` overrides that for a local experiment. Commit the archives and lockfile together for a reproducible temporary distribution. Once public npm is configured, replace these paths with exact registry versions and remove the overrides.

Every archive carries `package/dist/provenance.json`, holding the 40-character `commit` it was packed from plus `"dirty": true` when `--allow-dirty` packed a modified tree. Read it without unpacking the archive: `tar -xOf vendor/pgstencil-0.1.0.tgz package/dist/provenance.json`. That path is the contract; nothing else in the archive identifies its source, since npm's `gitHead` is absent from a `pnpm pack` of a private package. A consumer should record the commit it vendored, re-derive it from the archive on every build, and refuse an archive whose commit differs or that is marked dirty. `pnpm packages:verify` makes the same assertion against this checkout's `HEAD`, so a stale archive fails CI here.

`pnpm packages:verify` builds, packs, installs into an unrelated temporary pnpm project, checks TypeScript declarations, and runs an email login and required-card trial against a real cloned database. The project declares each peer at the version this workspace tests, and fails on an unmet peer. It shares this repository's Docker service state, but loads all code and SQL from installed archives. The consumer directory is printed for inspection.

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ This is an example authentication application; email sign-in remains an availabl

GitHub Actions installs with the frozen lockfile on Node 24/Linux, verifies formatting, generated schema/types, typechecks, and runs the complete Docker suite and verifies an independent consumer of the packed packages before removing that job's containers and volumes. Local editor typechecking works without Docker because generated types are committed.

[SECURITY.md](SECURITY.md) states what the packages guarantee, the test that pins each rule, and how to report a vulnerability privately.

See [PLAN.md](PLAN.md) for architectural decisions and remaining expansion work, and [LOGIN_FLOW.md](LOGIN_FLOW.md) for the login contract.

## Billing
Expand Down
90 changes: 90 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# pgstencil security

pgstencil ships `pgstencil`, `@pgstencil/auth` and `@pgstencil/stripe` as packed tarballs ([PACKAGES.md](PACKAGES.md)). This file states what the Better Auth integration in `@pgstencil/auth` and `pgstencil` itself guarantee to a consuming application, as conditions an auditor can check at one commit; each section ends with the tests that pin them. Bare names are under `packages/auth/src/`, `packages/auth/better-auth-migrations/` or `tests/`.

The application owns everything outside the packages: TLS and its origin gate, the CSP of its own pages, secret and credential storage, database provisioning, and provider registration. `@pgstencil/stripe` and the original code/link `Auth` exports carry no rules here yet.

## Sessions and cookies

- **FAIL IF** an HTTPS deployment's auth cookies lack `__Host-`, `Secure`, `HttpOnly`, `Path=/` or `SameSite=Lax`, or carry `Domain`; inspect `better-auth.ts` and `better-auth-security.ts`.
- **FAIL IF** a session token, a provider access/refresh/ID token or the internal `singleSession`/`emailAuthenticated` fields reach browser JSON, a stored session `token` authenticates without the server's cookie signature, or a bearer or API-key plugin is enabled; inspect `publicAuthResponse` and `plugins`.
- **FAIL IF** sessions outlive 24 hours, refresh on use, come from a cookie cache instead of the database, or session creation stops being serialized per user; inspect `session` in `better-auth.ts` and `002_security_policy.sql`.
- **FAIL IF** `sessionPolicy: 'single'` leaves another device signed in, `'multiple'` (the default) revokes one, or sign-out revokes another session; inspect `databaseHooks.session`.
- **FAIL IF** the readable `rememberLoginMethod` cookie grants authority, records a failed attempt or an explicit link, or is written to the database; inspect `lastLoginMethod` in `better-auth.ts`.

Pinned by `integration/better-auth.test.ts`: `email policy: secret-keyed codes, cross-browser redemption, concurrent single use and no token exposure`, `Better Auth time: 23 hours, expiration boundary, isolated async contexts and unchanged host clock`, `session policy: single`, `session policy: multiple`; `integration/better-auth-oauth.test.ts`: `Last login method remembers only successful sign-ins and survives logout`.

## CSRF and origin

- **FAIL IF** a state-changing POST is accepted without an exact `Origin` match, a signature-verified CSRF cookie, a matching `X-CSRF-Token` and an `application/json` body; inspect `protectAuth` in `better-auth-security.ts`.
- **FAIL IF** an upstream Better Auth route outside the read/write allowlist answers with anything but 404, or a provider callback accepts a method other than GET and Apple's `form_post` relay; inspect `protectAuth`.
- **FAIL IF** a response leaves without `Cache-Control: no-store`, `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` and a CSP denying framing, inline script and third-party sources; inspect `protectAuth`.
- **FAIL IF** the app accepts a non-canonical origin, a secret under 32 characters, or a success/error path off the application origin; inspect `authOptions` in `better-auth.ts`. `audit`.

Pinned by `integration/better-auth.test.ts`: `auth surface: explicit CSRF, exact origin, security headers and disabled unused endpoints`, `Better Auth email rejects expired codes and cross-origin sign-in`; `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: Apple form_post relay, wrong browser, mismatched provider and expired state`.

## Email codes

- **FAIL IF** a stored email code is recoverable without the application secret, is not separated by purpose from other keyed values, lives longer than ten minutes, survives more than three failed guesses, or is redeemed twice; inspect `emailOTP` in `better-auth.ts` and `keyed` in `better-auth-security.ts`.
- **FAIL IF** a rate-limit key contains a raw email address or IP, or a changing client IP resets an address's one-minute resend cooldown, five sends or fifteen verification attempts per fifteen minutes; inspect `consume` in `better-auth-security.ts`.
- **FAIL IF** the Workers adapter counts a client-IP header other than `cf-connecting-ip`, or an address in the reserved `identity.pgstencil.invalid` namespace reaches an email route or the email sender; inspect `better-auth-workers.ts` and `isIdentityEmail`.

Pinned by `integration/better-auth.test.ts`: `email policy: secret-keyed codes, cross-browser redemption, concurrent single use and no token exposure`, `email policy: distributed IPs cannot bypass cooldown, send quota or attempt budget`; `integration/better-auth-workers.test.ts`: `Better Auth in workerd: deterministic replay, separate clocks, shared database rate limits`; `integration/better-auth-oauth.test.ts`: `Optional email: <provider> signs in by stable identity without a mailbox`.

## OAuth

- **FAIL IF** a callback is processed without a signed browser-bound state cookie, an unexpired state row for the provider it names, and an atomic single-use claim taken in Postgres before the code exchange; inspect `oauthRequest` in `better-auth-oauth.ts` and `003_oauth_claims.sql`.
- **FAIL IF** a caller can choose the callback destination, the scopes or any OAuth parameter beyond the provider name, or can sign in by presenting a provider ID or access token directly; inspect `oauthRequest` and `protectAuth`.
- **FAIL IF** a Google, Apple or Microsoft ID token is accepted without signature, issuer, audience, expiry and nonce verification or under an algorithm other than RS256, a Microsoft identity is keyed on anything but a verified tenant and object ID, or GitHub signs in without a verified primary email; inspect `verifiedOidc`, `socialProviders` and `providerSubject` in `better-auth-email.ts`.
- **FAIL IF** a provider access, refresh or ID token survives identity verification in the database or a response, or a provider's error code or description reaches a redirect URL, a page or a diagnostic record; inspect `databaseHooks.account` and the callback redirect handling in `oauthRequest`.

Pinned by `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: <provider> login, safe token storage and callback replay`, `Better Auth OAuth: <provider> rejects invalid signed claims`, `Better Auth OAuth: concurrent callbacks exchange once and errors contain no provider details`, `Better Auth OAuth: caller cannot override callback origin or use direct provider tokens`, `Microsoft tenant identity and unverified email cannot capture another account`; `integration/better-auth-workers.test.ts`: `Better Auth OAuth in workerd: <provider>`.

## Account linking

- **FAIL IF** `accountLinking` defaults to anything but `'explicit'`, matching email addresses merge two identities under that default, or an explicit link starts without a session younger than ten minutes and a callback still carrying that same session; inspect `account.accountLinking` in `better-auth.ts` and `oauthRequest`.
- **FAIL IF** one provider identity ends up owned by two users, or an unverified or missing provider email establishes a match; inspect `validateUserInfo` in `better-auth.ts` and `account_provider_identity` in `002_security_policy.sql`.
- **FAIL IF** `accountLinking: 'same-email'` joins a non-authoritative provider email to an account without a live session for that same address that an email code created less than ten minutes earlier; inspect `validateUserInfo` and `004_email_session_proof.sql`.
- **FAIL IF** an `allowMissingEmail` provider-only account publishes its reserved address as an email, or a returning provider subject moves onto another account because the provider later supplied that account's address; inspect `socialProviders` and `identityEmail`.

Pinned by `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: email collision requires explicit linking; linking binds to live session`, `Better Auth OAuth: missing/unverified email is rejected and provider identities cannot be stolen by linking`, `Same-email linking: <provider> requires a fresh email session for non-authoritative email`, `Same-email linking: wrong-email, OAuth-only, expired and revoked sessions cannot supply mailbox proof`, `Optional email: <provider> signs in by stable identity without a mailbox`.

## Diagnostics

- **FAIL IF** a diagnostic record carries anything outside the enumerated events, categories, bounded numbers, booleans and validated correlation identifiers — never an email, code, token, cookie, header, path, query string, SQL statement, error message or stack; inspect `diagnostic` and `diagnosticError` in `packages/pgstencil/src/diagnostics.ts`.
- **FAIL IF** a failing or hostile sink changes a response, leaks exception text, or lets one request's context reach another's record; inspect `diagnostic` and `observeRequest`.
- **FAIL IF** a caller-supplied request ID is trusted, or an expected 4xx auth rejection is recorded as a server failure; inspect `observeRequest` and `onAPIError` in `better-auth.ts`.

Pinned by `unit/diagnostics.test.ts`: `diagnostics allowlist drops secrets even in unexpected fields and malformed values`, `concurrent request logs preserve their own context and do not log arbitrary routes`, `a broken sink never changes successful responses or reveals thrown exception text`, `provider error extraction keeps numeric codes and tolerates hostile getters`; `integration/better-auth-oauth.test.ts`: `auth diagnostics identify Microsoft token failures without recording credentials or identity`.

## Build and test isolation

- **FAIL IF** a normal build reaches `@pgstencil/auth/better-auth-testing`, or a deployed Worker answers a test clock route; inspect `examples/better-auth/src/worker.ts` beside `support/better-auth-worker.ts`, and the esbuild `inject` that only test bundles carry.
- **FAIL IF** Better Auth introspects or migrates the schema during a request, or the committed migrations stop matching Better Auth's generated plan; inspect `advanced.database` in `better-auth.ts` and `schemaChanges` in `examples/better-auth/src/schema.ts`.

Pinned by `integration/better-auth-workers.test.ts`: `normal Workers build uses real time and randomness and contains no test clock controls`; `integration/better-auth.test.ts`: `Better Auth email: repeatable cookies, database and email snapshots across parallel apps`.

## Packed provenance

- **FAIL IF** `packages:pack` builds from a modified `packages`, `scripts`, `tsconfig.json`, `tsconfig.build.json`, `pnpm-lock.yaml`, `compose.yaml`, `LICENSE` or `package.json` without `--allow-dirty`; inspect `scripts/build-packages.ts`.
- **FAIL IF** a tarball omits `package/dist/provenance.json` — the contract path a consumer reads out of the archive — that file omits the 40-character packing commit, or a pack of a modified tree is not marked `"dirty": true`; inspect `scripts/build-packages.ts` and [PACKAGES.md](PACKAGES.md).
- **FAIL IF** `packages:verify` accepts an installed package whose provenance commit is not this checkout's `HEAD`, or that is marked dirty; inspect `scripts/verify-packages.ts`.

Pinned by `pnpm packages:verify` in `.github/workflows/check.yml`.

## Continuous checks

- **FAIL IF** `.github/workflows/check.yml` stops running `db:verify`, `test` and `packages:verify` on pushes to `main` and on every pull request, drops `persist-credentials: false`, or grants any permission beyond `contents: read`; inspect `.github/workflows/check.yml`. `audit`.

## Reporting a vulnerability

Report privately through GitHub's [advisory form for diffplug/pgstencil](https://github.com/diffplug/pgstencil/security/advisories/new); never a public issue. pgstencil is pre-1.0 and unpublished to npm, so a fix lands on `main` and reaches a consumer through a re-vendored tarball; there is no backport branch.

## What is not defended

- The consumer-owned controls listed above.
- An attacker holding both the database contents and `AUTH_SECRET`. Better Auth stores native session tokens, so that pair mints a session cookie; either alone does not.
- Better Auth behavior beyond what these tests pin; its private range admits patches only, and an upgrade must rerun the security and snapshot suites.
- A provider that asserts an email it did not verify, and account recovery after a lost provider account or mailbox.
- Multi-factor authentication and passkeys, and abuse beyond the budgets above.
- `@pgstencil/stripe`, the original code/link `Auth` exports, and the example applications; see [BILLING.md](BILLING.md), [OAUTH.md](OAUTH.md) and [LOGIN_FLOW.md](LOGIN_FLOW.md).
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"db:stop": "tsx scripts/db.ts stop",
"db:gc": "tsx scripts/db.ts gc",
"packages:build": "node --import tsx scripts/build-packages.ts",
"packages:pack": "pnpm packages:build && pnpm --filter pgstencil --filter @pgstencil/auth --filter @pgstencil/stripe -r pack --pack-destination dist/packages",
"packages:pack": "node --import tsx scripts/pack-packages.ts",
"packages:verify": "pnpm packages:pack && node --import tsx scripts/verify-packages.ts"
},
"devDependencies": {
Expand Down
29 changes: 28 additions & 1 deletion scripts/build-packages.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
import { execFileSync } from 'node:child_process';
import { cp, rm, mkdir } from 'node:fs/promises';
import { cp, rm, mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { projectRoot } from '../packages/pgstencil/src/paths.ts';

const git = (...args: string[]) =>
execFileSync('git', args, { cwd: projectRoot, encoding: 'utf8' }).trim();
// Only the inputs that reach a tarball; an untracked editor file elsewhere is not dirt.
const buildInputs = [
'packages',
'scripts',
'tsconfig.json',
'tsconfig.build.json',
'pnpm-lock.yaml',
'compose.yaml',
'LICENSE',
'package.json',
];
const allowDirty = process.argv.includes('--allow-dirty');
const dirty = git('status', '--porcelain', '--', ...buildInputs);
if (dirty && !allowDirty)
throw new Error(
`Refusing to build packages from a modified tree; commit, stash or pass --allow-dirty:\n${dirty}`,
);
const commit = git('rev-parse', 'HEAD');
if (!/^[0-9a-f]{40}$/.test(commit))
throw new Error(`Expected a 40-character commit, got ${commit}`);
// No timestamp: the archive stays byte-identical for one commit.
const provenance =
JSON.stringify({ commit, ...(dirty ? { dirty: true } : {}) }, null, 2) + '\n';

await rm(join(projectRoot, '.build'), { recursive: true, force: true });
execFileSync(
process.execPath,
Expand All @@ -24,6 +50,7 @@ for (const name of ['pgstencil', 'auth', 'stripe']) {
await cp(join(projectRoot, '.build', name, 'src'), destination, {
recursive: true,
});
await writeFile(join(destination, 'provenance.json'), provenance);
}
await cp(
join(projectRoot, 'compose.yaml'),
Expand Down
22 changes: 22 additions & 0 deletions scripts/pack-packages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { execFileSync } from 'node:child_process';
import { projectRoot } from '../packages/pgstencil/src/paths.ts';

// One process so `pnpm packages:pack -- --allow-dirty` reaches the build's argv;
// pnpm appends script arguments to the end of a compound shell command instead.
await import('./build-packages.ts');
execFileSync(
'pnpm',
[
'--filter',
'pgstencil',
'--filter',
'@pgstencil/auth',
'--filter',
'@pgstencil/stripe',
'-r',
'pack',
'--pack-destination',
'dist/packages',
],
{ cwd: projectRoot, stdio: 'inherit' },
);
18 changes: 18 additions & 0 deletions scripts/verify-packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,24 @@ execFileSync('pnpm', ['install', '--ignore-scripts'], {
cwd: directory,
stdio: 'inherit',
});
// A consumer proves which source it runs from this file alone; prove it here too.
const head = execFileSync('git', ['rev-parse', 'HEAD'], {
cwd: projectRoot,
encoding: 'utf8',
}).trim();
for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) {
const file = join(directory, 'node_modules', name, 'dist/provenance.json');
const provenance = JSON.parse(await readFile(file, 'utf8')) as {
commit?: string;
dirty?: boolean;
};
if (provenance.commit !== head)
throw new Error(
`${name} was packed from ${provenance.commit}, not ${head} (${file})`,
);
if (provenance.dirty)
throw new Error(`${name} was packed from a modified tree (${file})`);
}
execFileSync('pnpm', ['exec', 'tsc'], { cwd: directory, stdio: 'inherit' });
// Share only Docker service state, never workspace code or module resolution.
execFileSync(process.execPath, ['out/verify.js'], {
Expand Down
Loading