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
1 change: 1 addition & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jobs:
- run: pnpm db:verify
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pgstencil/example-workers build
- run: pnpm packages:verify
- name: Remove this job's containers and volumes
if: always()
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,6 @@ coverage/
!.env.example
# Package copies are generated from the root license during build.
packages/*/LICENSE

.wrangler/
.dev.vars*
2 changes: 2 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,5 @@ pnpm-lock.yaml
**/snapshots/**
**/db.generated.ts
examples/login/schema.sql

.vscode/
20 changes: 15 additions & 5 deletions OAUTH.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Google and GitHub login
# OAuth login

The login example supports email, Google OpenID Connect, and GitHub OAuth. Each configured provider adds a native **Continue with…** form to `/login`. Successful authentication creates the same fixed 24-hour application session as email login. Providers are disabled until configured; the normal test suite needs no credentials or provider network access.
The login example supports email, Google and Apple OpenID Connect, and Facebook and GitHub OAuth. Each configured provider adds a native **Continue with…** form to `/login`. Successful authentication creates the same fixed 24-hour application session as email login. Providers are disabled until configured; the normal test suite needs no credentials or provider network access.

## Local setup

```sh
cp .env.example .env
# Fill in credentials for Google, GitHub, or both.
# Fill in credentials for the providers you want to enable.
pnpm dev
```

Expand All @@ -21,11 +21,15 @@ For Google, create a **Web application** OAuth client, configure the consent scr

For GitHub, create an **OAuth App**, set its homepage to the public origin, and set its authorization callback URL to the GitHub callback above. The application requests `read:user user:email`, including permission to read a private verified email address. Use separate registrations for local and deployed environments. See [GitHub's OAuth web application flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps) and [authenticated email API](https://docs.github.com/en/rest/users/emails).

For Apple, configure a web Services ID and register `https://your-app.example/oauth/apple/callback` as its return URL. Set `APPLE_CLIENT_ID` to that Services ID and `APPLE_CLIENT_SECRET` to an ES256 JWT signed with your Apple key (Team ID as issuer, Services ID as subject, `https://appleid.apple.com` as audience, key ID in the header). Apple client secrets expire; generate and replace them before their deadline (maximum six months). Apple's real web flow requires a registered HTTPS domain, not a localhost URL. Register your sending domain/address with Apple's private email relay when supporting Hide My Email. See [Apple client-secret setup](https://developer.apple.com/documentation/signinwithapplerestapi/creating-a-client-secret).

For Facebook, enable Facebook Login for your Meta app, request `email`, and register `https://your-app.example/oauth/facebook/callback`. Set `FACEBOOK_CLIENT_ID` and `FACEBOOK_CLIENT_SECRET`. Review the app's configured Graph API version; our endpoints use that app version. App development mode restricts login to permitted test accounts/roles. Facebook's authenticated primary email is trusted, matching [Supabase's Facebook provider](https://github.com/supabase/auth/blob/master/internal/api/provider/facebook.go); an absent email or declined permission fails closed. We do not interpret a profile's `verified` field as email verification. No matching-email account merging occurs.

Both credentials are required for each enabled provider. `.env` files are ignored by Git; `.env.example` contains no credentials. A configured development server also requires `PUBLIC_ORIGIN`. Configuration errors fail startup without printing secrets.

## Accounts and linking

A provider identity is `(provider, subject)`: Google's `sub` or GitHub's numeric user ID. Usernames and email addresses are not identity keys. A changed provider email continues to sign into the same linked account and does not silently change its local recovery email.
A provider identity is `(provider, subject)`: the OIDC `sub` or the provider's stable user ID. Usernames and email addresses are not identity keys. A changed provider email continues to sign into the same linked account and does not silently change its local recovery email.

A new, verified provider email creates an account. If the email already belongs to a local account, sign-in stops with instructions to use an existing method, then connect the provider from `/account`. Matching emails never automatically merge accounts.

Expand All @@ -37,6 +41,10 @@ Email sign-in remains available for the account's stored address, so account sec

[`openid-client`](https://github.com/panva/openid-client) owns code exchange and protocol validation. Google uses discovery and signed ID tokens, with signature, issuer, audience, expiry, nonce, state, and PKCE validation. GitHub uses fixed OAuth endpoints, PKCE S256, and authenticated `/user` and `/user/emails` requests; the public profile email is ignored. Its verified primary email can be private or on a later page.

Apple validates signed ID tokens, issuer, audience, expiry and nonce, accepting the provider's boolean or string `email_verified` claim. It does not advertise PKCE. Its cross-site `form_post` callback is relayed with 303 to the same callback's GET so the browser sends its original SameSite=Lax binding cookie. The POST neither consumes state nor creates a session, and callback responses use `no-store` / `no-referrer`. Only state, code and error survive the relay; unsigned user data is ignored. Facebook uses state plus the same independent browser cookie, server-side code exchange and an authenticated `/me` request with HMAC `appsecret_proof`; it does not claim PKCE support.

An Apple private relay address may differ from an existing account's address. Under the current same-email linking policy, use a matching address to connect, or sign up with Apple first and use that stored relay address for email login. Account merging is not implemented.

Starting or connecting is a same-origin POST with CSRF protection. Each attempt has independent random state and a browser-binding HttpOnly/SameSite=Lax cookie. The database stores their hashes; purpose-separated HMAC derivation supplies the PKCE verifier and OIDC nonce from state and the application secret. Servers sharing the database, public origin, credentials, and secret can finish each other's attempts.

Attempts expire after ten minutes, or sooner when a connecting session reaches five minutes of age. A callback atomically consumes the attempt before contacting the provider. Cancellation, exchange failure, or replay requires a new attempt. Missing or mismatched state, browser cookie, provider, or callback origin fails before exchange. Deadlines and connecting sessions are checked again after network I/O. Starting another attempt in the same browser replaces its binding cookie, so the latest attempt is the usable one.
Expand All @@ -54,7 +62,7 @@ pnpm check
pnpm db:verify
```

The test provider is a local HTTP server with dummy clients, real RSA-signed JWTs, discovery/JWKS, one-use authorization codes, PKCE verification, and GitHub profile/email endpoints. An injected transport maps only the known provider URLs to this server and rejects unknown network destinations. No test contacts Google or GitHub.
The test provider is a local HTTP server with dummy clients, real RSA-signed JWTs, discovery/JWKS, one-use authorization codes, PKCE verification, and GitHub profile/email endpoints. An injected transport maps only the known provider URLs to this server and rejects unknown network destinations. No test contacts a real OAuth provider. The fixture also serves Apple discovery/JWKS and Facebook profile endpoints. [Workers tests](tests/integration/workers.test.ts) exercise all three target providers in workerd with real Postgres.

Coverage includes invalid signatures/claims/nonces, missing or unverified email, PKCE mismatch, paginated private email, cancellation, forged callbacks, exact expiry boundaries, expiry during exchange, replay and concurrent redemption, provider outages, rate limits, session rotation/logout, linking conflicts and freshness, disabled configuration, HTTPS cookie attributes, and callbacks reaching a second application instance. Real Postgres backs every application scenario.

Expand All @@ -81,3 +89,5 @@ await startProduction({
Register `https://your-app.example/oauth/google/callback` and `https://your-app.example/oauth/github/callback` with their respective providers. Apply migrations before starting the deployment. `startProduction` uses real time, secure randomness, HTTPS origin validation, Secure `__Host-` cookies, and no inbox routes. The transport seam is available only on the lower-level application factory, not the production wrapper.

Use a same-host HTTPS reverse proxy and keep the public origin fixed; request Host/forwarded headers never choose callback destinations. The example sees the proxy's IP, so configure internet-facing rate limits at the trusted proxy rather than forwarding untrusted client IP headers. Exclude token-bearing callback/link query strings from access logs. Rotating the shared application secret invalidates in-flight OAuth proofs and existing CSRF derivations; coordinate it across instances.

For Hono, Fetch handlers, or Cloudflare Workers, see [WORKERS.md](WORKERS.md).
50 changes: 50 additions & 0 deletions PACKAGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,53 @@ Auth reserves the existing `public.users`, `login_flows`, `login_challenges`, `s
`Auth` accepts a `renderEmail` function for branding. `createAuthHttp` from `@pgstencil/auth/http` supplies JSON routes under `/api/auth/`, native OAuth callbacks under `/oauth/`, and a non-consuming email-link redirect under `/login/link`. The SPA confirms the link with an authenticated browser-flow POST. Session tokens stay in HttpOnly cookies; the JSON state contains the CSRF token, public session fields, and configured provider names. See the adopter's backend spec for a complete React integration.

The project is MIT licensed and hosted at [diffplug/pgstencil](https://github.com/diffplug/pgstencil). Public npm namespace, registry credentials, trusted publishing and release automation remain deferred. These local archives are ordinary npm package artifacts, so that later switch does not require submodules or a source-loader integration.

## Better Auth integration

New applications can use `@pgstencil/auth/better-auth` and the request-scoped
`@pgstencil/auth/better-auth-workers` adapter. The old exports remain available
so adoption can be staged without changing already deployed auth code.

```ts
import {
createBetterAuthWorker,
type BetterAuthWorkerBindings,
} from '@pgstencil/auth/better-auth-workers';
import { postmarkEmail } from '@pgstencil/auth/postmark';

type Env = BetterAuthWorkerBindings & {
POSTMARK_SERVER_TOKEN: string;
EMAIL_FROM: string;
};
const auth = createBetterAuthWorker<Env>({
appName: 'Type The Rhythm',
sessionPolicy: 'single', // Dormouse uses 'multiple'.
successPath: '/profile',
errorPath: '/login',
email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM),
});
```

Supply an environment type extending `BetterAuthWorkerBindings` with the email
bindings used by your application. Forward `/api/auth/*` and `/api/providers` to
`auth.fetch(request, env, executionCtx)`. Bind `HYPERDRIVE`, `APP_ORIGIN`, and
`AUTH_SECRET`; paired provider credentials enable OAuth. A consumer can strip
provider bindings in its preview entry to guarantee email-only previews.

Use `betterAuthMigrations` from `@pgstencil/auth/better-auth-migrations`. These
reserve `public.user`, `session`, `account`, `verification`, `rateLimit`,
`pgstencil_auth_limits`, and `pgstencil_oauth_claims`. An existing deployment
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
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)`
for app creation and requests. Never inject it into production builds.

The [working example](examples/better-auth/README.md) documents the HTTP protocol,
security choices, native session-token storage tradeoff, test coverage and
provider callback registration. `packages:verify` also installs and exercises
this integration from the tarball alongside the legacy auth and Stripe packages.
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A TypeScript/pnpm starting point for Postgres websites with fast, isolated, deterministic tests. SQL files own the schema; IntegreSQL clones migrated templates; Kysely supplies typed queries. Supertest exercises real HTTP servers, and local capture functions produce readable Vitest snapshots.

The first application signs users in with an emailed eight-digit code, a one-use link, Google, or GitHub. Email stays in an in-memory inbox during development and testing. OAuth providers are optional; [OAUTH.md](OAUTH.md) covers credentials, callback URLs, account linking, and tests that use local provider endpoints.
The first application signs users in with an emailed eight-digit code, a one-use link, Google, Apple, Facebook, or GitHub. Email stays in an in-memory inbox during development and testing. OAuth providers are optional; [OAUTH.md](OAUTH.md) covers credentials, callback URLs, account linking, and tests that use local provider endpoints.

## Start

Expand All @@ -20,20 +20,24 @@ Use `PORT=3000 pnpm dev` for a fixed port. Development uses real time so cooldow

## The recipe

| Piece | Implementation |
| ------------------ | ---------------------------------------------------------------------------------------------------- |
| Schema versioning | `packages/auth/migrations/*.sql`, node-pg-migrate, applied-file SHA-256 validation |
| Database isolation | IntegreSQL template per migration/configuration fingerprint; writable database lease per application |
| Local services | Testcontainers starts pinned Postgres/IntegreSQL Compose services with dynamic loopback ports |
| Queries | Kysely over `pg`; committed declarations generated by kysely-codegen |
| HTTP tests | Supertest agents against listeners bound to `127.0.0.1:0` |
| OAuth | openid-client for Google OIDC and GitHub OAuth; local HTTP provider fixtures for tests |
| Time / randomness | Injected `DevTime` and `DevRandom`; production uses `SystemTime` and Node crypto |
| Email | Injected `EmailSender`, with `EmailDev` capture, waiting, unread checks and preview routes |
| Snapshots | Vitest file snapshots plus local JSON, response, HTML, Markdown and email captures |
| Piece | Implementation |
| ------------------ | ----------------------------------------------------------------------------------------------------- |
| Schema versioning | `packages/auth/migrations/*.sql`, node-pg-migrate, applied-file SHA-256 validation |
| Database isolation | IntegreSQL template per migration/configuration fingerprint; writable database lease per application |
| Local services | Testcontainers starts pinned Postgres/IntegreSQL Compose services with dynamic loopback ports |
| Queries | Kysely over `pg`; committed declarations generated by kysely-codegen |
| HTTP tests | Supertest agents against listeners bound to `127.0.0.1:0` |
| OAuth | openid-client for Google/Apple OIDC and Facebook/GitHub OAuth; local HTTP provider fixtures for tests |
| Time / randomness | Injected `DevTime` and `DevRandom`; production uses `SystemTime` and Node crypto |
| Email | Injected `EmailSender`, with `EmailDev` capture, waiting, unread checks and preview routes |
| Snapshots | Vitest file snapshots plus local JSON, response, HTML, Markdown and email captures |

The workspace contains `pgstencil`, `@pgstencil/auth`, and `@pgstencil/stripe`. They are not yet published to npm; [PACKAGES.md](PACKAGES.md) explains consumption through compiled local tarballs. The core package's `pgstencil/postgres` export is the runtime connection layer; `pgstencil/database` and `pgstencil/testing` include local Docker infrastructure. `examples/login` is a complete consumer, using Node's HTTP server and native HTML forms. No frontend framework is required.

Hono and Cloudflare Workers are supported through the shared Fetch adapter and request-scoped Hyperdrive connections. See [WORKERS.md](WORKERS.md) for the deployable example, local runtime tests, and deployment preparation.

The [Better Auth integration](examples/better-auth/README.md) is the path for new applications: email codes, Google/Apple/Facebook/GitHub login, explicit account linking, and configurable single or multiple sessions. It preserves deterministic parallel tests on Node and Workers. Run `pnpm dev:better-auth` for the local demo or `pnpm test:better-auth` for its tests. [PACKAGES.md](PACKAGES.md#better-auth-integration) shows the public package API. The original code/link and billing example remains available during the staged migration.

## Write a test

The complete application fixture is in [tests/integration/helpers.ts](tests/integration/helpers.ts). A smaller consumer can use the infrastructure directly:
Expand Down Expand Up @@ -118,7 +122,7 @@ The configured pool supports 40 test databases per fingerprint, with Postgres ca

Docker must be running; pgstencil does not install or launch Docker Desktop. If initialization is interrupted, rerun the command. A stale process lock is removed when its owning PID is no longer alive. Do not delete `.pgstencil` while another process uses it.

## Login and production boundaries
## Original login example and production boundaries

The example implements browser-bound code/link challenges, confirmation POSTs that do not consume links during GET previews, 10-minute deadlines, five attempts per challenge, resend cooldowns, database-backed email/IP rate limits shared across instances, one-time atomic redemption, normalized email uniqueness, fixed 24-hour opaque sessions, session rotation/revocation, CSRF tokens, origin checks, and escaped HTML.

Expand Down
Loading
Loading