Skip to content
Merged
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
12 changes: 10 additions & 2 deletions PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Every other dependency is private: pgstencil owns its version and applications d

Production imports use `pgstencil`, `pgstencil/postgres`, `@pgstencil/auth` and `@pgstencil/stripe`. Docker startup and test fixtures are opt-in imports through `pgstencil/database`, `pgstencil/testing`, and `@pgstencil/stripe/testing`.

The database tooling uses the current working directory as the project root, or `PGSTENCIL_PROJECT_ROOT` when explicitly set. State goes in that project's `.pgstencil`; its Docker Compose project name derives from the project path. A project can supply `compose.yaml`, otherwise the package's bundled default is used. The default SQL directory is `migrations`, overridable by a `pgstencil.json` file containing a `migrations` directory path. Applications composing packages should pass their complete source list explicitly:
The database tooling uses the current working directory as the project root, or `PGSTENCIL_PROJECT_ROOT` when explicitly set. State goes in that project's `.pgstencil`; its Docker Compose project name derives from the project path. A project can supply `compose.yaml`, otherwise the package's bundled default is used. The default SQL directory is `migrations`, overridable by a `pgstencil.json` file containing a `migrations` directory path inside the project root; a path resolving outside it is refused. Applications composing packages should pass their complete source list explicitly:

```ts
import { authMigrations } from '@pgstencil/auth/migrations';
Expand Down Expand Up @@ -78,7 +78,15 @@ retains its old migration source and adds this one; it must not drop checksum
history. Old and new auth tables coexist, but sessions/accounts are independent.

Node hosts use `createAuthApp({databaseUrl, origin, secret, email, ...})` and call
`close()` before returning their database lease. Tests bundle their application
`close()` before returning their database lease. Serve `app.fetch` through
`@hono/node-server`, passing its `env` along: the socket in `env.incoming` is the
client IP that Better Auth's per-IP limiter and pgstencil's per-IP email budget
count, and any client-sent `x-pgstencil-client-ip` is overwritten. Behind a
reverse proxy, opt in with `ipAddressHeaders: ['x-real-ip']`, naming a
single-address header the proxy overwrites on every request; never name one a
client can set. A request with neither shares one bucket. The Workers adapter
always counts `cf-connecting-ip`. Stored limiter keys are HMACs under
`AUTH_SECRET`, never raw addresses. Tests bundle their application
with esbuild's `inject` set to the **actual module file** resolved from
`@pgstencil/auth/better-auth-testing`. Injecting a re-export shim does not work.
Use that module's `deterministicScope.run({time, random, outboundFetch}, action)`
Expand Down
23 changes: 13 additions & 10 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

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.
The application owns everything outside the packages: TLS and its origin gate, the CSP of its own pages, secret and credential storage, database provisioning, provider registration, and — only when it opts in with `ipAddressHeaders` — a proxy that overwrites those headers on every request. `@pgstencil/stripe` and the original code/link `Auth` exports carry no rules here yet.

## Sessions and cookies

Expand All @@ -21,24 +21,26 @@ Pinned by `integration/better-auth.test.ts`: `email policy: secret-keyed codes,
- **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`.

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`.
Pinned by `integration/better-auth.test.ts`: `auth surface: explicit CSRF, exact origin, security headers and disabled unused endpoints`, `auth surface: an allowlisted write with a non-JSON body answers 415`, `auth options reject a non-canonical origin, a short secret and off-origin redirect paths`, `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`, `Better Auth OAuth: callbacks refuse every method but GET and Apple's form-encoded POST`.

## 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** a stored rate-limit key — Better Auth's `rateLimit` rows or pgstencil's `pgstencil_auth_limits` — contains a raw email address or IP instead of a secret-keyed HMAC, or a changing client IP resets an address's one-minute resend cooldown, five sends or fifteen verification attempts per fifteen minutes; inspect `rateLimitStorage` and `consume` in `better-auth-security.ts`, `rateLimit` in `better-auth.ts` and `005_hashed_rate_limit_keys.sql`.
- **FAIL IF** Better Auth's per-IP limiter or pgstencil's per-IP email budget counts any address but one trusted client IP — on Node the socket address `@hono/node-server` passes as `env.incoming`, or a forwarded header only when the application names it in `ipAddressHeaders` — or either limiter keys an IPv6 address on anything finer than its /64, or a client-supplied `x-pgstencil-client-ip` reaches either limiter; inspect `protectAuth` and `ipBucket` in `better-auth-security.ts` and `advanced.ipAddress` in `better-auth.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`.
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`, `email policy: fifteen verification attempts per fifteen minutes stop even the right code from any IP`, `IP rate limits: stored limiter keys never contain a raw client IP`, `IP rate limits: the default Node path counts the socket, so rotating x-pgstencil-client-ip cannot escape`, `IP rate limits: concurrent requests from one address are counted atomically`, `IP rate limits: two IPv6 addresses in one /64 share one send:ip budget`, `IP rate limits: naming a victim's IP in x-pgstencil-client-ip spends only the caller's budget`, `IP rate limits: an explicit ipAddressHeaders opt-in counts the configured header`; `unit/ip-bucket.test.ts`: `ipBucket groups addresses by /64 as Better Auth's own normalizeIP does`; `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`.
- **FAIL IF** a provider access, refresh or ID token survives identity verification in the database or a response; inspect `databaseHooks.account`.
- **FAIL IF** a provider's error description, or any provider error code outside the `errorCodes` allowlist in `packages/pgstencil/src/diagnostics.ts`, reaches a redirect URL, a page or a diagnostic record, or an allowlisted code reaches a redirect URL or a page; a diagnostic record may carry an allowlisted code as `errorCode` and Microsoft's bounded numeric `error_codes[0]` as `providerCode`, nothing else from the provider's error; inspect the callback redirect handling in `oauthRequest` and `diagnosticError`.

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>`.
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: <provider> rejects ID tokens whose header names an algorithm other than RS256`, `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>`; `unit/diagnostics.test.ts`: `diagnostics allowlist drops secrets even in unexpected fields and malformed values`, `provider error extraction keeps numeric codes and tolerates hostile getters`.

## Account linking

Expand All @@ -51,18 +53,18 @@ Pinned by `integration/better-auth-oauth.test.ts`: `Better Auth OAuth: email col

## 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 diagnostic record carries anything outside the enumerated events, categories (error codes only from the `errorCodes` allowlist), bounded numbers, booleans and validated correlation identifiers — never an email, sign-in code, token, cookie, header, path, query string, SQL statement, error message, provider error description 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** a normal build injects `@pgstencil/auth/better-auth-testing`, importing that module changes a process global, or a deployed Worker answers a test clock route. The module is a published export that `packages:verify` imports; it is inert unless esbuild's `inject` names it, which only test bundles do. Inspect `better-auth-testing.ts`, `examples/better-auth/src/worker.ts` beside `support/better-auth-worker.ts`, and each bundle's `inject`.
- **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`.
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`, `Better Auth time: 23 hours, expiration boundary, isolated async contexts and unchanged host clock`.

## Packed provenance

Expand Down Expand Up @@ -93,6 +95,7 @@ Report privately through GitHub's [advisory form for diffplug/pgstencil](https:/
- 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.
- A provider that asserts an email it did not verify, and account recovery after a lost provider account or mailbox. Facebook sends no verification claim, so pgstencil treats any address Facebook returns as verified.
- Two applications sharing both a database and `AUTH_SECRET`: they share OAuth state, sessions and limits, and count as one application here.
- 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).
4 changes: 2 additions & 2 deletions examples/better-auth/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const database = await allocateDatabase(betterAuthMigrations);
const email = new EmailDev(new SystemTime());
let app: ReturnType<typeof createEmailApp> | undefined;
const server = await listen(
async (request) => {
async (request, env) => {
if (new URL(request.url).pathname === '/dev/emails')
return new Response(
await html`<!doctype html>
Expand Down Expand Up @@ -39,7 +39,7 @@ const server = await listen(
},
);
return app
? app.app.fetch(request)
? app.app.fetch(request, env)
: new Response('Starting', { status: 503 });
},
Number(process.env.PORT ?? 8082),
Expand Down
23 changes: 11 additions & 12 deletions examples/better-auth/src/node.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
import { createServer } from 'node:http';
import { once } from 'node:events';
import { getRequestListener } from '@hono/node-server';
import {
getRequestListener,
type Http2Bindings,
type HttpBindings,
} from '@hono/node-server';

export async function listen(
fetch: (request: Request) => Response | Promise<Response>,
fetch: (
request: Request,
env: HttpBindings | Http2Bindings,
) => Response | Promise<Response>,
port = 0,
) {
const server = createServer(
getRequestListener((request, env) => {
// Derive from the actual socket, overwriting any caller-supplied value.
request.headers.set(
'x-pgstencil-client-ip',
env.incoming.socket.remoteAddress ?? '127.0.0.1',
);
return fetch(request);
}),
);
// createAuthApp reads the client IP from env.incoming's socket.
const server = createServer(getRequestListener(fetch));
server.listen(port, '127.0.0.1');
await once(server, 'listening');
const address = server.address();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Up Migration
-- Rate-limit keys are now an HMAC of Better Auth's "<ip>|<path>" key. Drop the
-- plaintext rows written before; every window they held is under a minute.
DELETE FROM "rateLimit";
Loading
Loading