diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index a9969cd..1c162bf 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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() diff --git a/.gitignore b/.gitignore index 8e08709..f4a7644 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,6 @@ coverage/ !.env.example # Package copies are generated from the root license during build. packages/*/LICENSE + +.wrangler/ +.dev.vars* diff --git a/.prettierignore b/.prettierignore index 4da1fa0..d4a0e1a 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,5 @@ pnpm-lock.yaml **/snapshots/** **/db.generated.ts examples/login/schema.sql + +.vscode/ diff --git a/OAUTH.md b/OAUTH.md index 273236d..2023871 100644 --- a/OAUTH.md +++ b/OAUTH.md @@ -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 ``` @@ -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. @@ -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. @@ -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. @@ -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). diff --git a/PACKAGES.md b/PACKAGES.md index 5a39740..0185ae9 100644 --- a/PACKAGES.md +++ b/PACKAGES.md @@ -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({ + 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. diff --git a/README.md b/README.md index 3d2a06a..49acec0 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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. diff --git a/WORKERS.md b/WORKERS.md new file mode 100644 index 0000000..94d121f --- /dev/null +++ b/WORKERS.md @@ -0,0 +1,71 @@ +# Hono and Cloudflare Workers + +For new authentication integrations, use `@pgstencil/auth/better-auth-workers`. +[The package recipe](PACKAGES.md#better-auth-integration) covers Hono composition, +Postmark, SQL migrations, session policies and deterministic test bundles. +[The Better Auth example](examples/better-auth/README.md) documents the email and +OAuth protocol plus production acceptance checks. + +The adapter below is the original implementation, retained for staged upgrades. + +`@pgstencil/auth/fetch` owns the JSON routes, cookies and CSRF checks. +`@pgstencil/auth/hono` mounts that adapter in Hono; the existing `/http` export +bridges the same adapter to Node. `@pgstencil/auth/workers` composes a Hono app +with a request-scoped Kysely/pg connection through Hyperdrive. Connections close +in `finally`, including errors. Docker, migrations and snapshot lenses remain +Node-side tools and are excluded from the Worker bundle. + +See [the deployable example](examples/workers/src/index.ts) and its +[Wrangler configuration](examples/workers/wrangler.jsonc). Build without uploading: + +```sh +pnpm --filter @pgstencil/example-workers build +pnpm test tests/integration/workers.test.ts +``` + +The runtime tests execute bundled Hono/auth code inside workerd, with real +Postgres clones via local Hyperdrive bindings. EmailDev and OAuth HTTP fixtures +replace external services. They cover email login, exact 23/24-hour session +boundaries, Secure cookies, CSRF, Google/Apple/Facebook callbacks, browser binding +and replay. Node provider tests additionally verify signatures, claims, nonces, +PKCE where supported, provider failures and missing email. Test clock controls +exist only in the test entrypoint, never the deployable Worker. + +## Deployment preparation + +1. Provision an empty hosted Postgres database. Hyperdrive supplies pooling, + not database storage. Apply the auth SQL migrations from Node using the direct + database URL; migrations must finish before the new Worker serves traffic. +2. Create Hyperdrive with **query caching disabled** and replace the placeholder + ID. Auth reads must immediately observe challenges, sessions and revocations. + Local Hyperdrive tests don't reproduce Cloudflare's remote pool or cache. +3. Set `APP_ORIGIN` to the exact HTTPS frontend origin and `EMAIL_FROM` to a + verified sending address. Mount the Worker on that same origin's `/api/auth/*`, + `/oauth/*` and `/login/link` paths; serve the SPA elsewhere. No cross-origin + auth or CORS credentials are needed. +4. Store `AUTH_SECRET` (at least 32 random characters), `POSTMARK_SERVER_TOKEN`, + and each provider's `*_CLIENT_ID` / `*_CLIENT_SECRET` as Wrangler secrets. + Apple needs a signed client-secret JWT, not a plain password. See [OAuth setup](OAUTH.md). +5. Register the exact HTTPS callbacks, deploy, then verify real email delivery + and each provider's consent/callback flow in a browser. Automated mocks cannot + establish that dashboard configuration or actual delivery is correct. + +The example deliberately contains placeholder domain and Hyperdrive settings; +building it does not deploy anything. Secret files (`.env*`, `.dev.vars*`) and +Wrangler state are ignored. Avoid logging callback queries, cookies, email codes +or provider tokens. Cloudflare supplies the trusted `CF-Connecting-IP` header +used by the shared database rate limiter. + +Hyperdrive [does not support advisory locks](https://developers.cloudflare.com/hyperdrive/reference/supported-databases-and-features/). +Runtime auth and Stripe serialization uses row locks; SQL migration tooling runs +directly against Postgres. OAuth first-use identity creation locks one of 64 +fixed rows, so arbitrary identities cannot grow a lock table without bound. + +Stripe webhook verification is asynchronous and uses Web Crypto. On Workers, +construct the Stripe SDK with `httpClient: Stripe.createFetchHttpClient()` and +`await billing.webhook(rawBody, signature)`. `verifyWebhook()` is now asynchronous +too. The runnable Workers example currently covers authentication; billing retains +its independent Node integration suite. + +For deployment details, use Cloudflare's [Hyperdrive setup](https://developers.cloudflare.com/hyperdrive/get-started/) +and [query caching documentation](https://developers.cloudflare.com/hyperdrive/concepts/query-caching/). diff --git a/examples/better-auth/README.md b/examples/better-auth/README.md new file mode 100644 index 0000000..3c60356 --- /dev/null +++ b/examples/better-auth/README.md @@ -0,0 +1,102 @@ +# Better Auth with pgstencil + +Better Auth 1.7.3 handles email-code login and Google, Apple, Facebook and GitHub +OAuth. pgstencil owns SQL migrations, Docker/IntegreSQL clones, email capture, +security policy and deterministic tests. The reusable exports live in `@pgstencil/auth`; see [package consumption](../../PACKAGES.md#better-auth-integration). This example is isolated from the old +auth implementation and from TTR production. + +With Docker running: + +```sh +pnpm dev:better-auth +pnpm test:better-auth +``` + +Open `http://127.0.0.1:8082`, enter any test email and read its eight-digit code at +`/dev/emails`. Development uses real time/randomness and a disposable database. +`PORT=0` chooses a free port. Stop with Ctrl-C. OAuth buttons appear only for +configured providers; set paired `GOOGLE_CLIENT_ID`/`GOOGLE_CLIENT_SECRET`, +`APPLE_CLIENT_ID`/`APPLE_CLIENT_SECRET`, `FACEBOOK_CLIENT_ID`/`FACEBOOK_CLIENT_SECRET` +and/or `GITHUB_CLIENT_ID`/`GITHUB_CLIENT_SECRET` in the process environment. + +## Security policy + +- Codes work across browsers, last ten minutes, allow three failed guesses and + use purpose-separated HMAC-SHA256 storage with the application secret. +- Atomic Postgres counters enforce a one-minute per-email resend cooldown, + five sends and fifteen verification submissions per email per fifteen minutes, + plus IP budgets and Better Auth's stricter short-window IP limits. Counter + keys contain keyed hashes rather than raw emails/IPs; expired counters are pruned. +- Each browser obtains `GET /api/auth/csrf`, then supplies `X-CSRF-Token` on + same-origin JSON POSTs. The matching HttpOnly cookie is signed. HTTPS cookies + use `__Host-` names, Secure, HttpOnly, Path=/ and SameSite=Lax. +- Only the implemented API operations are exposed. Responses omit upstream + session tokens, use no-store, and have security headers. CSP permits the local + external script; it does not require unsafe-inline. +- `sessionPolicy: 'single'` signs out all other devices on successful login (TTR). + `'multiple'` is the default and retains independent sessions (Dormouse). + A Postgres trigger serializes session creation per user. Logout revokes the + current session. Sessions last 24 hours with refresh and cookie caching disabled. +- Better Auth stores native session tokens in the database. A token alone cannot + authenticate: the cookie also needs the server signature, and no bearer plugin + is enabled. Browser JSON omits these tokens. This is an explicit upstream storage + tradeoff; the test suite proves a bare database token is rejected. +- OAuth identities never merge just because their email addresses match. Sign in + by email or an existing provider, then explicitly connect another provider. + Connecting requires a session less than ten minutes old, and the callback must + still carry that same live session. Different verified provider emails are allowed, + including Apple's private relay address. An identity cannot belong to two users. +- Callbacks check provider, signed browser state, expiry and an atomic Postgres + replay claim. Apple form_post relays to a GET that receives the Lax cookies. + Callback destinations are fixed to the application origin. Direct provider-token + sign-in and unused upstream auth endpoints are unavailable. +- The pinned version's Google/Apple redirect profile readers only decode ID tokens. + `verifiedOidc` explicitly enables Better Auth's signature/issuer/audience/expiry/ + nonce verification through its plugin API. Negative tests cover each check. + GitHub requires a verified primary email; Facebook uses the authenticated email + after Better Auth validates that the access token belongs to our app and user. +- Provider access, refresh and ID tokens are discarded after identity verification. + They are not kept in the database or returned to the browser. + +## Determinism and Workers + +Test bundles inject the actual `@pgstencil/auth/better-auth-testing` module file with esbuild. Date, Web Crypto +and outbound fetch facades use AsyncLocalStorage to select each app's clock, +random stream and local provider server. They do not replace process globals, +cryptographic hashing/signing or timers. Normal builds have no injection or test +clock routes. Repeatable email/OAuth cookies, sessions and timestamps are tested +across parallel apps; all four providers also run through real workerd. + +This adapter covers these APIs in the bundled dependency graph. Dependency +upgrades must rerun security and snapshot tests. The same request order is +repeatable; concurrent requests within one app need not have deterministic order. +Better Auth accepts a replayed session cookie at exactly 24 hours and rejects it +one millisecond later. Tests explicitly replay historical cookies independently +of the browser clock. + +SQL is generated for review and committed, never migrated during requests. +Background runtime schema inspection is disabled because it races request-scoped +Worker pool teardown. Tests verify the committed schema against Better Auth's +migration plan. The Worker uses a Hyperdrive binding and an EMAIL service binding; +tests replace EMAIL with in-memory capture. Hosted Hyperdrive/Neon and real provider +configuration still need the first candidate-deployment smoke test. + +## First production smoke test + +Register these callback URLs with each enabled provider, using the candidate's +stable public origin: + +| Provider | Callback | +| -------- | --------------------------------------------- | +| Google | `https:///api/auth/callback/google` | +| Apple | `https:///api/auth/callback/apple` | +| Facebook | `https:///api/auth/callback/facebook` | +| GitHub | `https:///api/auth/callback/github` | + +Existing Supabase client IDs/secrets can be reused when the provider configuration +allows the new callback. Apple uses a Services ID and an unexpired client-secret +JWT; its private relay also requires the mail sender to be registered with Apple. +Enter secrets directly into deployment tooling, not chat, source files or logs. +Do not delete Supabase/Pages until email and each required provider pass in a real +browser. The TTR test must also confirm that a second device's login signs out +the first, and that explicit linking works without creating a duplicate account. diff --git a/examples/better-auth/package.json b/examples/better-auth/package.json new file mode 100644 index 0000000..6e9929b --- /dev/null +++ b/examples/better-auth/package.json @@ -0,0 +1,14 @@ +{ + "name": "@pgstencil/example-better-auth", + "private": true, + "type": "module", + "dependencies": { + "@hono/node-server": "1.19.17", + "better-auth": "1.7.3", + "hono": "^4.13.7", + "kysely": "0.29.5", + "pg": "^8.16.0", + "pgstencil": "workspace:*", + "@pgstencil/auth": "workspace:*" + } +} diff --git a/examples/better-auth/src/auth.ts b/examples/better-auth/src/auth.ts new file mode 100644 index 0000000..7b64b11 --- /dev/null +++ b/examples/better-auth/src/auth.ts @@ -0,0 +1,65 @@ +import { createAuthApp } from '@pgstencil/auth/better-auth'; +export { authOptions } from '@pgstencil/auth/better-auth'; +export function createEmailApp(options: Parameters[0]) { + const result = createAuthApp(options); + result.app.get('/auth.js', (c) => + c.body(loginScript, 200, { + 'content-type': 'text/javascript; charset=utf-8', + }), + ); + result.app.get('/', (c) => c.html(loginHtml)); + return result; +} + +const loginHtml = ` +Better Auth email example +

Sign in

+
+ +

+`; + +const loginScript = ` +const send = document.querySelector('#send'), verify = document.querySelector('#verify'); +const status = document.querySelector('#status'), logout = document.querySelector('#logout'); +let csrf, signedIn = false; +const providerNames = {google:'Google', apple:'Apple', facebook:'Facebook', github:'GitHub'}; +const enabledProviders = await (await fetch('/api/providers')).json(); +const showProviders = async () => { + const container = document.querySelector('#providers'); container.replaceChildren(); + const linked = signedIn ? await (await fetch('/api/auth/list-accounts')).json() : []; + for (const provider of enabledProviders) { + const connected = linked.some(account => account.providerId === provider); + const button = document.createElement('button'); + button.textContent = (signedIn ? (connected ? 'Connected: ' : 'Connect ') : 'Continue with ') + providerNames[provider]; + button.disabled = connected; + button.onclick = async () => { try { const data = await post(signedIn ? 'link-social' : 'sign-in/social', {provider}); location.assign(data.url); } catch(error) {status.textContent = error.message;} }; + container.append(button); + } +}; +const post = async (path, body) => { + const response = await fetch('/api/auth/' + path, {method:'POST', headers:{'content-type':'application/json', 'x-csrf-token':csrf}, body:JSON.stringify(body)}); + const data = await response.json(); + if (!response.ok) throw new Error(data.message || 'Request failed'); + return data; +}; +send.onsubmit = async (event) => { + event.preventDefault(); + try { await post('email-otp/send-verification-otp', {email:send.email.value, type:'sign-in'}); verify.hidden=false; status.textContent='Check your email for a code.'; } + catch (error) { status.textContent=error.message; } +}; +verify.onsubmit = async (event) => { + event.preventDefault(); + try { await post('sign-in/email-otp', {email:send.email.value, otp:verify.otp.value}); await session(); } + catch (error) { status.textContent=error.message; } +}; +logout.onclick = async () => { try { await post('sign-out', {}); await session(); } catch (error) { status.textContent=error.message; } }; +async function session() { + const data = await (await fetch('/api/auth/get-session')).json(); + status.textContent=data ? 'Signed in as ' + data.user.email : 'Signed out'; + signedIn=!!data; send.hidden=!!data; verify.hidden=true; logout.hidden=!data; + await showProviders(); +} +csrf = (await (await fetch('/api/auth/csrf')).json()).csrf; +await session(); +if (new URL(location.href).searchParams.has('error')) { status.textContent = 'Could not sign in. Try again, or sign in by email and connect this provider.'; history.replaceState(null, '', '/'); }`; diff --git a/examples/better-auth/src/dev.ts b/examples/better-auth/src/dev.ts new file mode 100644 index 0000000..90928f9 --- /dev/null +++ b/examples/better-auth/src/dev.ts @@ -0,0 +1,70 @@ +import { betterAuthMigrations } from '@pgstencil/auth/better-auth-migrations'; +import { EmailDev, SystemTime } from 'pgstencil'; +import { allocateDatabase } from 'pgstencil/database'; +import { createEmailApp } from './auth.ts'; +import { listen } from './node.ts'; +import { html } from 'hono/html'; +import { oauthFromEnvironment } from './oauth.ts'; + +// A disposable lease keeps this experiment separate from the existing dev database. +const database = await allocateDatabase(betterAuthMigrations); +const email = new EmailDev(new SystemTime()); +let app: ReturnType | undefined; +const server = await listen( + async (request) => { + if (new URL(request.url).pathname === '/dev/emails') + return new Response( + await html` + + Local inbox +
+

Local inbox

+ ${email.all().map( + (mail) => + html`
+

${mail.subject}

+

${mail.to.join(', ')}

+
${mail.text}
+
`, + )}Back to sign in +
+ `, + { + headers: { + 'content-type': 'text/html; charset=utf-8', + 'cache-control': 'no-store', + 'content-security-policy': + "default-src 'none'; frame-ancestors 'none'; base-uri 'none'", + }, + }, + ); + return app + ? app.app.fetch(request) + : new Response('Starting', { status: 503 }); + }, + Number(process.env.PORT ?? 8082), +); +app = createEmailApp({ + databaseUrl: database.url, + email, + oauth: oauthFromEnvironment(process.env), + sessionPolicy: + process.env.SESSION_POLICY === 'single' ? 'single' : 'multiple', + origin: server.origin, + secret: 'better-auth-local-development-secret-only', +}); +console.log( + `Better Auth: ${server.origin}\nLocal email: ${server.origin}/dev/emails\nDisposable database; restarting starts fresh.`, +); +for (const signal of ['SIGINT', 'SIGTERM'] as const) + process.once(signal, () => { + void (async () => { + await server.close(); + await app?.close(); + email.close(); + await database.close(); + })().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; + }); + }); diff --git a/examples/better-auth/src/node.ts b/examples/better-auth/src/node.ts new file mode 100644 index 0000000..e316d1a --- /dev/null +++ b/examples/better-auth/src/node.ts @@ -0,0 +1,32 @@ +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { getRequestListener } from '@hono/node-server'; + +export async function listen( + fetch: (request: Request) => Response | Promise, + 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); + }), + ); + server.listen(port, '127.0.0.1'); + await once(server, 'listening'); + const address = server.address(); + if (!address || typeof address === 'string') + throw new Error('Expected a TCP address'); + return { + origin: `http://127.0.0.1:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +} diff --git a/examples/better-auth/src/oauth.ts b/examples/better-auth/src/oauth.ts new file mode 100644 index 0000000..7c90430 --- /dev/null +++ b/examples/better-auth/src/oauth.ts @@ -0,0 +1 @@ +export * from '@pgstencil/auth/better-auth-oauth'; diff --git a/examples/better-auth/src/schema.ts b/examples/better-auth/src/schema.ts new file mode 100644 index 0000000..a909c9d --- /dev/null +++ b/examples/better-auth/src/schema.ts @@ -0,0 +1,24 @@ +import { getMigrations } from 'better-auth/db/migration'; +import { connectDatabase } from 'pgstencil/postgres'; +import { authOptions } from './auth.ts'; + +/** Generate SQL for review; never run Better Auth's automatic migrations at request time. */ +export async function schemaChanges(databaseUrl: string) { + const database = connectDatabase(databaseUrl); + try { + return await getMigrations( + authOptions({ + database, + origin: 'https://example.test', + secret: 'schema-generation-only-not-a-production-secret', + email: { + async send() { + throw new Error('Schema generation cannot send mail'); + }, + }, + }), + ); + } finally { + await database.destroy(); + } +} diff --git a/examples/better-auth/src/worker.ts b/examples/better-auth/src/worker.ts new file mode 100644 index 0000000..4b9a244 --- /dev/null +++ b/examples/better-auth/src/worker.ts @@ -0,0 +1,27 @@ +import { + createBetterAuthWorker, + type BetterAuthWorkerBindings, +} from '@pgstencil/auth/better-auth-workers'; +export interface Bindings extends BetterAuthWorkerBindings { + SESSION_POLICY?: 'single' | 'multiple'; + EMAIL: { fetch(request: Request): Promise }; +} +export default { + fetch(request: Request, env: Bindings) { + return createBetterAuthWorker({ + sessionPolicy: env.SESSION_POLICY ?? 'multiple', + email: (bindings) => ({ + async send(message) { + const response = await bindings.EMAIL.fetch( + new Request('https://email.internal/send', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(message), + }), + ); + if (!response.ok) throw new Error('Email delivery failed'); + }, + }), + }).fetch(request, env); + }, +}; diff --git a/examples/login/schema.sql b/examples/login/schema.sql index e99c847..5cbf6de 100644 --- a/examples/login/schema.sql +++ b/examples/login/schema.sql @@ -144,7 +144,7 @@ CREATE TABLE public.oauth_flows ( expires_at timestamp with time zone NOT NULL, consumed_at timestamp with time zone, CONSTRAINT oauth_flows_check CHECK (((link_user_id IS NULL) = (link_session_hash IS NULL))), - CONSTRAINT oauth_flows_provider_check CHECK ((provider = ANY (ARRAY['google'::text, 'github'::text]))) + CONSTRAINT oauth_flows_provider_check CHECK ((provider = ANY (ARRAY['google'::text, 'github'::text, 'apple'::text, 'facebook'::text]))) ); @@ -157,7 +157,17 @@ CREATE TABLE public.oauth_identities ( subject text NOT NULL, user_id text NOT NULL, created_at timestamp with time zone NOT NULL, - CONSTRAINT oauth_identities_provider_check CHECK ((provider = ANY (ARRAY['google'::text, 'github'::text]))) + CONSTRAINT oauth_identities_provider_check CHECK ((provider = ANY (ARRAY['google'::text, 'github'::text, 'apple'::text, 'facebook'::text]))) +); + + +-- +-- Name: oauth_locks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.oauth_locks ( + id integer NOT NULL, + CONSTRAINT oauth_locks_id_check CHECK (((id >= 0) AND (id < 64))) ); @@ -334,6 +344,14 @@ ALTER TABLE ONLY public.oauth_identities ADD CONSTRAINT oauth_identities_user_id_provider_key UNIQUE (user_id, provider); +-- +-- Name: oauth_locks oauth_locks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.oauth_locks + ADD CONSTRAINT oauth_locks_pkey PRIMARY KEY (id); + + -- -- Name: pgmigrations pgmigrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- diff --git a/examples/login/src/app.ts b/examples/login/src/app.ts index 976e051..1dbf2aa 100644 --- a/examples/login/src/app.ts +++ b/examples/login/src/app.ts @@ -1,3 +1,4 @@ +import { appleCallbackLocation } from '@pgstencil/auth/fetch'; import { createServer, type IncomingMessage, @@ -299,6 +300,36 @@ export async function startApp(config: AppConfig) { ); return; } + if ( + req.method === 'POST' && + provider === 'apple' && + action === 'callback' + ) { + if ( + req.headers['content-type']?.split(';')[0]?.trim() !== + 'application/x-www-form-urlencoded' + ) { + res.writeHead(415).end(); + return; + } + const body = await readBody(req, MAX_BODY_BYTES); + if (!body) { + res.writeHead(413).end(); + return; + } + const location = appleCallbackLocation( + new URLSearchParams(body.toString()), + url.pathname, + ); + res + .writeHead(303, { + location, + 'cache-control': 'no-store', + 'referrer-policy': 'no-referrer', + }) + .end(); + return; + } if (req.method === 'GET' && provider && action === 'callback') { const { result, clearCookie } = await oauth.complete( provider, diff --git a/examples/workers/package.json b/examples/workers/package.json new file mode 100644 index 0000000..3e96a30 --- /dev/null +++ b/examples/workers/package.json @@ -0,0 +1,11 @@ +{ + "name": "@pgstencil/example-workers", + "private": true, + "type": "module", + "dependencies": { + "@pgstencil/auth": "workspace:*" + }, + "scripts": { + "build": "wrangler deploy --dry-run --outdir dist" + } +} diff --git a/examples/workers/src/index.ts b/examples/workers/src/index.ts new file mode 100644 index 0000000..592f1c0 --- /dev/null +++ b/examples/workers/src/index.ts @@ -0,0 +1,13 @@ +import { + createAuthWorker, + type AuthWorkerBindings, +} from '@pgstencil/auth/workers'; +import { postmarkEmail } from '@pgstencil/auth/postmark'; + +interface Env extends AuthWorkerBindings { + POSTMARK_SERVER_TOKEN: string; + EMAIL_FROM: string; +} +export default createAuthWorker({ + email: (env) => postmarkEmail(env.POSTMARK_SERVER_TOKEN, env.EMAIL_FROM), +}); diff --git a/examples/workers/wrangler.jsonc b/examples/workers/wrangler.jsonc new file mode 100644 index 0000000..a7c01da --- /dev/null +++ b/examples/workers/wrangler.jsonc @@ -0,0 +1,15 @@ +{ + "$schema": "../../node_modules/wrangler/config-schema.json", + "name": "pgstencil-auth-example", + "main": "src/index.ts", + "compatibility_date": "2026-09-08", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": false, + "vars": { + "APP_ORIGIN": "https://replace-before-deploy.example", + "EMAIL_FROM": "signin@replace-before-deploy.example", + }, + "hyperdrive": [ + { "binding": "HYPERDRIVE", "id": "00000000000000000000000000000000" }, + ], +} diff --git a/package.json b/package.json index 001ca48..e7c8654 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ }, "scripts": { "dev": "node --env-file-if-exists=.env --import tsx examples/login/src/dev.ts", + "dev:better-auth": "node --import tsx examples/better-auth/src/dev.ts", + "test:better-auth": "vitest run tests/integration/better-auth.test.ts tests/integration/better-auth-workers.test.ts tests/integration/better-auth-oauth.test.ts", "test": "vitest run", "test:watch": "vitest", "test:unit": "vitest run tests/unit", @@ -35,12 +37,15 @@ "@types/pg": "^8.15.0", "@types/supertest": "^6.0.0", "@types/turndown": "^5.0.0", + "esbuild": "^0.28.2", "kysely-codegen": "^0.19.0", + "miniflare": "5.20260908.0-alpha", "prettier": "^3.6.0", "supertest": "^7.2.0", "tsx": "^4.20.0", "typescript": "^5.9.0", - "vitest": "^4.0.0" + "vitest": "^4.0.0", + "wrangler": "^4.130.0" }, "license": "MIT", "repository": { diff --git a/packages/auth/better-auth-migrations/001_better_auth.sql b/packages/auth/better-auth-migrations/001_better_auth.sql new file mode 100644 index 0000000..cd2e968 --- /dev/null +++ b/packages/auth/better-auth-migrations/001_better_auth.sql @@ -0,0 +1,17 @@ +-- Up Migration +-- Generated from Better Auth 1.7.3 email OTP configuration. +create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" boolean not null, "image" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null); + +create table "session" ("id" text not null primary key, "expiresAt" timestamptz not null, "token" text not null unique, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade); + +create table "account" ("id" text not null primary key, "accountId" text not null, "providerId" text not null, "userId" text not null references "user" ("id") on delete cascade, "accessToken" text, "refreshToken" text, "idToken" text, "accessTokenExpiresAt" timestamptz, "refreshTokenExpiresAt" timestamptz, "scope" text, "password" text, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz not null); + +create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" timestamptz not null, "createdAt" timestamptz default CURRENT_TIMESTAMP not null, "updatedAt" timestamptz default CURRENT_TIMESTAMP not null); + +create table "rateLimit" ("id" text not null primary key, "key" text not null unique, "count" integer not null, "lastRequest" bigint not null); + +create index "session_userId_idx" on "session" ("userId"); + +create index "account_userId_idx" on "account" ("userId"); + +create index "verification_identifier_idx" on "verification" ("identifier"); diff --git a/packages/auth/better-auth-migrations/002_security_policy.sql b/packages/auth/better-auth-migrations/002_security_policy.sql new file mode 100644 index 0000000..7b7a54d --- /dev/null +++ b/packages/auth/better-auth-migrations/002_security_policy.sql @@ -0,0 +1,23 @@ +-- Up Migration +CREATE TABLE pgstencil_auth_limits ( + key text PRIMARY KEY, + count integer NOT NULL, + started_at timestamptz NOT NULL +); + +ALTER TABLE "session" ADD COLUMN "singleSession" boolean NOT NULL DEFAULT false; +CREATE UNIQUE INDEX account_provider_identity ON "account" ("providerId", "accountId"); + +-- Serialize session creation per user, including across separate Workers. +-- A failed INSERT rolls back both the deletion and the lock. +CREATE FUNCTION pgstencil_session_policy() RETURNS trigger LANGUAGE plpgsql AS $$ +BEGIN + PERFORM id FROM "user" WHERE id = NEW."userId" FOR UPDATE; + IF NEW."singleSession" THEN + DELETE FROM "session" WHERE "userId" = NEW."userId"; + END IF; + RETURN NEW; +END; +$$; +CREATE TRIGGER pgstencil_session_policy BEFORE INSERT ON "session" +FOR EACH ROW EXECUTE FUNCTION pgstencil_session_policy(); diff --git a/packages/auth/better-auth-migrations/003_oauth_claims.sql b/packages/auth/better-auth-migrations/003_oauth_claims.sql new file mode 100644 index 0000000..a60a281 --- /dev/null +++ b/packages/auth/better-auth-migrations/003_oauth_claims.sql @@ -0,0 +1,7 @@ +-- Up Migration +CREATE INDEX pgstencil_auth_limit_expiry ON pgstencil_auth_limits (started_at); +CREATE TABLE pgstencil_oauth_claims ( + key text PRIMARY KEY, + expires_at timestamptz NOT NULL +); +CREATE INDEX pgstencil_oauth_claim_expiry ON pgstencil_oauth_claims (expires_at); diff --git a/packages/auth/migrations/004_oauth_workers.sql b/packages/auth/migrations/004_oauth_workers.sql new file mode 100644 index 0000000..eb10e87 --- /dev/null +++ b/packages/auth/migrations/004_oauth_workers.sql @@ -0,0 +1,18 @@ +-- Up Migration +ALTER TABLE oauth_identities DROP CONSTRAINT oauth_identities_provider_check; +ALTER TABLE oauth_identities ADD CHECK (provider IN ('google', 'github', 'apple', 'facebook')); +ALTER TABLE oauth_flows DROP CONSTRAINT oauth_flows_provider_check; +ALTER TABLE oauth_flows ADD CHECK (provider IN ('google', 'github', 'apple', 'facebook')); + +-- Fixed-size transaction locks for first-time identity creation, including +-- concurrent callbacks. Supported by Hyperdrive, unlike advisory locks. +CREATE TABLE oauth_locks (id integer PRIMARY KEY CHECK (id >= 0 AND id < 64)); +INSERT INTO oauth_locks SELECT generate_series(0, 63); + +-- Down Migration +-- Refuse rollback while identities/flows belonging to the new providers exist. +ALTER TABLE oauth_identities DROP CONSTRAINT oauth_identities_provider_check; +ALTER TABLE oauth_identities ADD CHECK (provider IN ('google', 'github')); +ALTER TABLE oauth_flows DROP CONSTRAINT oauth_flows_provider_check; +ALTER TABLE oauth_flows ADD CHECK (provider IN ('google', 'github')); +DROP TABLE oauth_locks; diff --git a/packages/auth/package.json b/packages/auth/package.json index f1dd489..30f0e93 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -12,12 +12,23 @@ "./oauth-providers": "./src/oauth-providers.ts", "./db.generated": "./src/db.generated.ts", "./email": "./src/email.ts", - "./http": "./src/http.ts" + "./http": "./src/http.ts", + "./fetch": "./src/fetch.ts", + "./hono": "./src/hono.ts", + "./workers": "./src/workers.ts", + "./postmark": "./src/postmark.ts", + "./better-auth": "./src/better-auth.ts", + "./better-auth-oauth": "./src/better-auth-oauth.ts", + "./better-auth-migrations": "./src/better-auth-migrations.ts", + "./better-auth-testing": "./src/better-auth-testing.ts", + "./better-auth-workers": "./src/better-auth-workers.ts" }, "dependencies": { - "pgstencil": "workspace:*", + "hono": "^4.13.7", "kysely": "0.29.5", - "openid-client": "6.8.8" + "openid-client": "6.8.8", + "pgstencil": "workspace:*", + "better-auth": "1.7.3" }, "engines": { "node": ">=24" @@ -25,7 +36,8 @@ "files": [ "dist", "migrations", - "LICENSE" + "LICENSE", + "better-auth-migrations" ], "publishConfig": { "exports": { @@ -64,10 +76,46 @@ "./http": { "types": "./dist/http.d.ts", "import": "./dist/http.js" + }, + "./fetch": { + "types": "./dist/fetch.d.ts", + "import": "./dist/fetch.js" + }, + "./hono": { + "types": "./dist/hono.d.ts", + "import": "./dist/hono.js" + }, + "./workers": { + "types": "./dist/workers.d.ts", + "import": "./dist/workers.js" + }, + "./postmark": { + "types": "./dist/postmark.d.ts", + "import": "./dist/postmark.js" + }, + "./better-auth": { + "types": "./dist/better-auth.d.ts", + "import": "./dist/better-auth.js" + }, + "./better-auth-oauth": { + "types": "./dist/better-auth-oauth.d.ts", + "import": "./dist/better-auth-oauth.js" + }, + "./better-auth-migrations": { + "types": "./dist/better-auth-migrations.d.ts", + "import": "./dist/better-auth-migrations.js" + }, + "./better-auth-testing": { + "types": "./dist/better-auth-testing.d.ts", + "import": "./dist/better-auth-testing.js" + }, + "./better-auth-workers": { + "types": "./dist/better-auth-workers.d.ts", + "import": "./dist/better-auth-workers.js" } } }, - "description": "Email-code, email-link, Google and GitHub authentication with deterministic tests for pgstencil.", + "description": "Email, Google, Apple, Facebook and GitHub authentication for Node, Hono and Cloudflare Workers.", "license": "MIT", "homepage": "https://github.com/diffplug/pgstencil#readme", "repository": { diff --git a/packages/auth/src/auth.ts b/packages/auth/src/auth.ts index 26dff6b..c402090 100644 --- a/packages/auth/src/auth.ts +++ b/packages/auth/src/auth.ts @@ -101,11 +101,18 @@ export class Auth { for (const { key, limit } of [...keys].sort((a, b) => a.key.localeCompare(b.key), )) { - await sql`select pg_advisory_xact_lock(hashtext(${key}))`.execute(trx); + // Inserting first also serializes the very first use of a key. Unlike + // advisory locks, row locks work through Hyperdrive transaction pooling. + await trx + .insertInto('rate_limits') + .values({ key, count: 0, window_start: now }) + .onConflict((c) => c.column('key').doNothing()) + .execute(); const previous = await trx .selectFrom('rate_limits') .selectAll() .where('key', '=', key) + .forUpdate() .executeTakeFirst(); const fresh = !previous || diff --git a/packages/auth/src/better-auth-migrations.ts b/packages/auth/src/better-auth-migrations.ts new file mode 100644 index 0000000..33e68a1 --- /dev/null +++ b/packages/auth/src/better-auth-migrations.ts @@ -0,0 +1,4 @@ +import { fileURLToPath } from 'node:url'; +export const betterAuthMigrations = fileURLToPath( + new URL('../better-auth-migrations/', import.meta.url), +); diff --git a/packages/auth/src/better-auth-oauth.ts b/packages/auth/src/better-auth-oauth.ts new file mode 100644 index 0000000..e827759 --- /dev/null +++ b/packages/auth/src/better-auth-oauth.ts @@ -0,0 +1,278 @@ +import type { BetterAuthOptions, BetterAuthPlugin } from 'better-auth'; +import { verifyProviderIdToken } from 'better-auth/oauth2'; +import type { GithubProfile } from 'better-auth/social-providers'; +import { makeSignature } from 'better-auth/crypto'; +import { sql } from 'kysely'; +import { equal, keyed } from './better-auth-security.ts'; +import type { connectDatabase } from 'pgstencil/postgres'; + +export const providers = ['google', 'apple', 'facebook', 'github'] as const; +export type Provider = (typeof providers)[number]; +export type OAuthSettings = Partial< + Record +>; + +/** Pin the redirect flow's OIDC checks explicitly on Better Auth 1.7.3. + * Its Google/Apple provider metadata supports verification, but their default + * redirect getUserInfo only decodes claims. Use the library's actual verifier. + */ +export const verifiedOidc: BetterAuthPlugin = { + id: 'pgstencil-verified-oidc', + init(context) { + for (const provider of context.socialProviders) { + if (provider.id !== 'google' && provider.id !== 'apple') continue; + provider.requiresIdTokenNonce = true; + provider.issuer = + provider.id === 'google' + ? 'https://accounts.google.com' + : 'https://appleid.apple.com'; + if (provider.idToken && 'jwks' in provider.idToken) + provider.idToken.algorithms = ['RS256']; + const authorization = provider.createAuthorizationURL.bind(provider); + provider.createAuthorizationURL = async (data) => { + if (!data.idTokenNonce) throw new Error('Missing OIDC nonce'); + const url = await authorization(data); + url.searchParams.set('nonce', data.idTokenNonce); + return url; + }; + const info = provider.getUserInfo.bind(provider); + provider.getUserInfo = async (tokens) => { + if ( + !tokens.idToken || + !tokens.expectedIdTokenNonce || + !(await verifyProviderIdToken( + provider, + tokens.idToken, + tokens.expectedIdTokenNonce, + )) + ) + return null; + return info(tokens); + }; + } + }, +}; +export function oauthFromEnvironment( + env: Record, +): OAuthSettings { + const result: OAuthSettings = {}; + for (const provider of providers) { + const clientId = env[`${provider.toUpperCase()}_CLIENT_ID`]; + const clientSecret = env[`${provider.toUpperCase()}_CLIENT_SECRET`]; + if (!clientId && !clientSecret) continue; + if (!clientId?.trim() || !clientSecret?.trim()) + throw new Error( + `Set both ${provider.toUpperCase()}_CLIENT_ID and ${provider.toUpperCase()}_CLIENT_SECRET`, + ); + result[provider] = { clientId, clientSecret }; + } + return result; +} + +export function socialProviders( + settings: OAuthSettings = {}, +): BetterAuthOptions['socialProviders'] { + return { + ...settings, + ...(settings.facebook + ? { + facebook: { + ...settings.facebook, + // Better Auth validates the Graph token's app and user before reading /me. + // Facebook's authenticated primary email is our proof, as in the old adapter. + mapProfileToUser: async (profile) => ({ + emailVerified: !!profile.email, + }), + }, + } + : {}), + ...(settings.github + ? { + github: { + ...settings.github, + // Public profile email can be stale. Require the verified PRIMARY address. + getUserInfo: async (tokens) => { + const get = async (url: string) => { + const response = await fetch(url, { + headers: { + authorization: `Bearer ${tokens.accessToken}`, + 'user-agent': 'pgstencil', + accept: 'application/vnd.github+json', + }, + }); + if (!response.ok) + throw new Error('GitHub identity request failed'); + return response.json(); + }; + const profile = (await get( + 'https://api.github.com/user', + )) as GithubProfile; + if ( + !/^[0-9]+$/.test(String(profile.id)) || + Number(profile.id) <= 0 + ) + return null; + for (let page = 1; page <= 10; page++) { + const emails = (await get( + `https://api.github.com/user/emails?per_page=100&page=${page}`, + )) as { email: string; primary: boolean; verified: boolean }[]; + if (!Array.isArray(emails)) return null; + const primary = emails.find( + (email) => email.primary === true && email.verified === true, + ); + if (primary) + return { + user: { + name: profile.name ?? profile.login ?? '', + email: primary.email, + emailVerified: true, + }, + data: profile, + }; + if (emails.length < 100) break; + } + return null; + }, + }, + } + : {}), + }; +} + +type AuthHandle = { + handler(request: Request): Promise; + api: { + getSession(options: { headers: Headers }): Promise<{ + session: { id: string; createdAt: Date }; + user: { id: string }; + } | null>; + }; +}; +/** Restrict redirect inputs and bind explicit linking to the initiating live session. */ +export async function oauthRequest( + request: Request, + auth: AuthHandle, + options: { + database: ReturnType; + origin: string; + secret: string; + oauth?: OAuthSettings; + successPath?: string; + errorPath?: string; + }, +): Promise { + const path = new URL(request.url).pathname.slice('/api/auth'.length); + const fail = () => { + const url = new URL(options.errorPath ?? '/', options.origin); + url.searchParams.set('error', 'oauth_failed'); + return Response.redirect(url.href, 303); + }; + if (path === '/sign-in/social' || path === '/link-social') { + const body = (await request.json()) as Record; + if ( + !providers.includes(body.provider as Provider) || + !options.oauth?.[body.provider as Provider] + ) + return Response.json( + { message: 'Provider unavailable' }, + { status: 404 }, + ); + const session = + path === '/link-social' + ? await auth.api.getSession({ headers: request.headers }) + : null; + if ( + path === '/link-social' && + (!session || Date.now() - session.session.createdAt.getTime() >= 600_000) + ) + return Response.json( + { message: 'Sign in again before connecting an account' }, + { status: 401 }, + ); + // No caller-controlled redirects, scopes, token shortcuts or OAuth metadata. + const clean = { + provider: body.provider, + disableRedirect: true, + callbackURL: options.origin + (options.successPath ?? '/'), + errorCallbackURL: options.origin + (options.errorPath ?? '/'), + additionalData: { + pgstencilProvider: body.provider, + ...(session ? { pgstencilSession: session.session.id } : {}), + }, + }; + return auth.handler(new Request(request, { body: JSON.stringify(clean) })); + } + if (path.startsWith('/callback/') && request.method === 'GET') { + const state = new URL(request.url).searchParams.get('state'); + const provider = path.slice('/callback/'.length) as Provider; + if (!state || !providers.includes(provider) || !options.oauth?.[provider]) + return fail(); + const prefix = options.origin.startsWith('https:') + ? '__Host-pgstencil' + : 'pgstencil'; + const raw = request.headers + .get('cookie') + ?.split(';') + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix + '.state=')) + ?.slice(prefix.length + '.state='.length); + try { + if ( + !raw || + !equal( + decodeURIComponent(raw), + `${state}.${await makeSignature(state, options.secret)}`, + ) + ) + return fail(); + } catch { + return fail(); + } + const result = await sql<{ + value: string; + expiresAt: Date; + }>`SELECT value, "expiresAt" FROM verification WHERE identifier = ${state} ORDER BY "createdAt" DESC LIMIT 1`.execute( + options.database, + ); + const row = result.rows[0]; + if (!row || row.expiresAt.getTime() <= Date.now()) return fail(); + let data: { + pgstencilProvider?: string; + pgstencilSession?: string; + link?: { userId: string }; + }; + try { + data = JSON.parse(row.value) as typeof data; + } catch { + return fail(); + } + if (data.pgstencilProvider !== provider) return fail(); + if (data.link) { + const session = await auth.api.getSession({ headers: request.headers }); + if ( + !session || + session.session.id !== data.pgstencilSession || + session.user.id !== data.link.userId + ) + return fail(); + } + // Upstream checks state but uses separate read/delete calls. Claim it once + // in Postgres before exchanging the code, including across Worker isolates. + await sql`DELETE FROM pgstencil_oauth_claims WHERE expires_at <= ${new Date()}`.execute( + options.database, + ); + const claim = + await sql`INSERT INTO pgstencil_oauth_claims (key, expires_at) VALUES (${keyed(options.secret, 'oauth-state', state)}, ${row.expiresAt}) ON CONFLICT DO NOTHING RETURNING key`.execute( + options.database, + ); + if (claim.rows.length !== 1) return fail(); + } + const response = await auth.handler(request); + // Do not forward provider error descriptions or codes into URLs, logs or pages. + const location = response.headers.get('location'); + if (path.startsWith('/callback/') && location) { + const redirect = new URL(location, options.origin); + if (redirect.searchParams.has('error')) return fail(); + } + return response; +} diff --git a/packages/auth/src/better-auth-security.ts b/packages/auth/src/better-auth-security.ts new file mode 100644 index 0000000..de9b81d --- /dev/null +++ b/packages/auth/src/better-auth-security.ts @@ -0,0 +1,219 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; +import { sql } from 'kysely'; +import type { Hono } from 'hono'; +import { bodyLimit } from 'hono/body-limit'; +import type { connectDatabase } from 'pgstencil/postgres'; + +export function keyed(secret: string, purpose: string, value: string) { + return createHmac('sha256', secret) + .update(purpose) + .update('\0') + .update(value) + .digest('hex'); +} +export function equal(a: string, b: string) { + const left = Buffer.from(a), + right = Buffer.from(b); + return left.length === right.length && timingSafeEqual(left, right); +} +function cookies(request: Request) { + return new Map( + (request.headers.get('cookie') ?? '').split(';').map((part) => { + const index = part.indexOf('='); + return [part.slice(0, index).trim(), part.slice(index + 1)]; + }), + ); +} + +/** Shared, atomic limits: changing client IP cannot reset an email's budget. */ +async function consume( + db: ReturnType, + key: string, + max: number, + windowMs: number, +) { + const now = new Date(); + const cutoff = new Date(now.getTime() - windowMs); + const result = await sql<{ count: number }>` + INSERT INTO pgstencil_auth_limits (key, count, started_at) + VALUES (${key}, 1, ${now}) + ON CONFLICT (key) DO UPDATE SET + count = CASE WHEN pgstencil_auth_limits.started_at <= ${cutoff} THEN 1 ELSE pgstencil_auth_limits.count + 1 END, + started_at = CASE WHEN pgstencil_auth_limits.started_at <= ${cutoff} THEN ${now} ELSE pgstencil_auth_limits.started_at END + WHERE pgstencil_auth_limits.started_at <= ${cutoff} OR pgstencil_auth_limits.count < ${max} + RETURNING count + `.execute(db); + return result.rows.length === 1; +} + +export function protectAuth( + app: Hono, + options: { + origin: string; + secret: string; + database: ReturnType; + ipAddressHeaders?: string[]; + }, +) { + const secure = options.origin.startsWith('https:'); + const cookieName = secure ? '__Host-pgstencil.csrf' : 'pgstencil.csrf'; + const verify = (value: string | undefined) => { + if (!value || !/^[a-f0-9]{64}\.[a-f0-9]{64}$/.test(value)) return false; + const [token, signature] = value.split('.') as [string, string]; + return equal(signature, keyed(options.secret, 'csrf', token)); + }; + app.use('*', async (c, next) => { + await next(); + // Apply to the final response, including upstream immutable redirects. + c.header('Cache-Control', 'no-store'); + c.header('Referrer-Policy', 'no-referrer'); + c.header('X-Content-Type-Options', 'nosniff'); + c.header('X-Frame-Options', 'DENY'); + c.header( + 'Content-Security-Policy', + "default-src 'none'; script-src 'self'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ); + }); + app.use('/api/auth/*', bodyLimit({ maxSize: 16 * 1024 })); + app.get('/api/auth/csrf', (c) => { + let value = cookies(c.req.raw).get(cookieName); + if (!verify(value)) { + const token = Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).toString('hex'); + value = `${token}.${keyed(options.secret, 'csrf', token)}`; + c.header( + 'Set-Cookie', + `${cookieName}=${value}; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`, + ); + } + return c.json({ csrf: value!.split('.')[0] }); + }); + app.use('/api/auth/*', async (c, next) => { + const path = c.req.path.slice('/api/auth'.length); + const callback = /^\/callback\/(google|github|apple|facebook)$/.test(path); + const reads = ['/get-session', '/list-accounts']; + const writes = [ + '/email-otp/send-verification-otp', + '/sign-in/email-otp', + '/sign-in/social', + '/link-social', + '/sign-out', + ]; + if (callback) { + if (c.req.method === 'GET') return next(); + // Apple form_post carries no Lax cookies; Better Auth relays it to a GET + // which checks the signed browser state AND atomically consumes DB state. + if ( + path === '/callback/apple' && + c.req.method === 'POST' && + c.req.header('content-type')?.split(';')[0] === + 'application/x-www-form-urlencoded' + ) + return next(); + return c.json({ message: 'Method not allowed' }, 405); + } + if (c.req.method === 'GET' && reads.includes(path)) return next(); + if (c.req.method !== 'POST' || !writes.includes(path)) + return c.json({ message: 'Not found' }, 404); + if (c.req.header('origin') !== options.origin) + return c.json({ message: 'Invalid origin' }, 403); + const cookie = cookies(c.req.raw).get(cookieName); + if ( + !verify(cookie) || + !equal(c.req.header('x-csrf-token') ?? '', cookie!.split('.')[0]!) + ) + return c.json({ message: 'Invalid CSRF token' }, 403); + if (c.req.header('content-type')?.split(';')[0] !== 'application/json') + return c.json({ message: 'Send JSON' }, 415); + let body: Record; + try { + body = (await c.req.raw.clone().json()) as Record; + if (!body || typeof body !== 'object' || Array.isArray(body)) + throw new Error(); + } catch { + return c.json({ message: 'Invalid JSON' }, 400); + } + if ( + path === '/email-otp/send-verification-otp' || + path === '/sign-in/email-otp' + ) { + const email = + typeof body.email === 'string' ? body.email.toLowerCase() : ''; + if (email.length > 254 || !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(email)) + return c.json({ message: 'Invalid email' }, 400); + const send = path === '/email-otp/send-verification-otp'; + if (send && body.type !== 'sign-in') + return c.json({ message: 'Unsupported email operation' }, 400); + const ip = + (options.ipAddressHeaders ?? ['x-pgstencil-client-ip']) + .map((name) => c.req.header(name)) + .find(Boolean) ?? 'unknown'; + // Bound per-email counter creation even after upstream IP limits reject. + if ( + !(await consume( + options.database, + `${send ? 'send' : 'verify'}:ip:${keyed(options.secret, 'ip-rate', ip)}`, + send ? 30 : 100, + 15 * 60_000, + )) + ) + return c.json({ message: 'Please wait before trying again.' }, 429); + await sql`DELETE FROM pgstencil_auth_limits WHERE started_at < ${new Date(Date.now() - 15 * 60_000)}`.execute( + options.database, + ); + const key = keyed(options.secret, 'email-rate', email); + const allowed = + (!send || + (await consume(options.database, `cooldown:${key}`, 1, 60_000))) && + (await consume( + options.database, + `${send ? 'send' : 'verify'}:${key}`, + send ? 5 : 15, + 15 * 60_000, + )); + if (!allowed) + return c.json({ message: 'Please wait before trying again.' }, 429); + } + if ( + (path === '/sign-in/social' || path === '/link-social') && + (body.idToken || body.accessToken) + ) + return c.json({ message: 'Use the browser OAuth redirect flow' }, 400); + await next(); + }); +} + +/** The browser only needs public session data, never upstream session tokens. */ +export async function publicAuthResponse(response: Response) { + if ( + (response.status >= 300 && response.status < 400) || + !response.headers.get('content-type')?.includes('application/json') + ) + return response; + const body: unknown = await response.json(); + const scrub = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(scrub); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries( + Object.entries(value) + .filter( + ([key]) => + ![ + 'token', + 'accessToken', + 'refreshToken', + 'idToken', + 'singleSession', + ].includes(key), + ) + .map(([key, item]) => [key, scrub(item)]), + ); + }; + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(JSON.stringify(scrub(body)), { + status: response.status, + headers, + }); +} diff --git a/packages/auth/src/better-auth-testing.ts b/packages/auth/src/better-auth-testing.ts new file mode 100644 index 0000000..1225a23 --- /dev/null +++ b/packages/auth/src/better-auth-testing.ts @@ -0,0 +1,76 @@ +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Time, RandomSource } from 'pgstencil'; + +/** Injected by esbuild into TEST bundles only. Never changes process globals or timers. */ +export const deterministicScope = new AsyncLocalStorage<{ + time: Time; + random: RandomSource; + outboundFetch?: typeof fetch; +}>(); +const native = globalThis; +const nativeFetch = native.fetch; +const scopedFetch: typeof fetch = (...args) => + (deterministicScope.getStore()?.outboundFetch ?? nativeFetch)(...args); +const NativeDate = native.Date; +const nativeCrypto = native.crypto; +const now = () => + deterministicScope.getStore()?.time.now().getTime() ?? NativeDate.now(); +const ScopedDate: DateConstructor = new Proxy(NativeDate, { + construct(target, args, newTarget) { + return Reflect.construct( + target, + args.length ? args : [now()], + newTarget === ScopedDate ? target : newTarget, + ); + }, + apply() { + return new NativeDate(now()).toString(); + }, + get(target, key, receiver) { + return key === 'now' ? now : Reflect.get(target, key, receiver); + }, +}); +const scopedCrypto = new Proxy(nativeCrypto, { + get(target, key) { + if (key === 'getRandomValues') + return (array: ArrayBufferView) => { + const random = deterministicScope.getStore()?.random; + if (!random) return target.getRandomValues(array); + if ( + !ArrayBuffer.isView(array) || + array instanceof DataView || + array instanceof Float32Array || + array instanceof Float64Array + ) + throw new TypeError( + 'getRandomValues requires an integer typed array', + ); + if (array.byteLength > 65536) + throw new DOMException('Quota exceeded', 'QuotaExceededError'); + new Uint8Array(array.buffer, array.byteOffset, array.byteLength).set( + random.bytes(array.byteLength), + ); + return array; + }; + if (key === 'randomUUID') + return () => { + const random = deterministicScope.getStore()?.random; + if (!random) return target.randomUUID(); + const bytes = random.bytes(16); + bytes[6] = (bytes[6]! & 15) | 64; + bytes[8] = (bytes[8]! & 63) | 128; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + }; + const value = Reflect.get(target, key, target); + return typeof value === 'function' ? value.bind(target) : value; + }, +}); +export { + scopedFetch as fetch, + scopedFetch as 'globalThis.fetch', + ScopedDate as Date, + scopedCrypto as crypto, + ScopedDate as 'globalThis.Date', + scopedCrypto as 'globalThis.crypto', +}; diff --git a/packages/auth/src/better-auth-workers.ts b/packages/auth/src/better-auth-workers.ts new file mode 100644 index 0000000..5c57fc8 --- /dev/null +++ b/packages/auth/src/better-auth-workers.ts @@ -0,0 +1,64 @@ +import { Hono } from 'hono'; +import type { EmailSender } from 'pgstencil'; +import { createAuthApp, type AuthAppOptions } from './better-auth.ts'; +import { + oauthFromEnvironment, + providers, + type Provider, +} from './better-auth-oauth.ts'; + +export type BetterAuthWorkerBindings = { + HYPERDRIVE: { connectionString: string }; + APP_ORIGIN: string; + AUTH_SECRET: string; +} & Partial< + Record< + `${Uppercase}_CLIENT_ID` | `${Uppercase}_CLIENT_SECRET`, + string + > +>; + +/** Only the request owns its pool; no sockets or test controls cross invocations. */ +export function createBetterAuthWorker( + options: Pick< + AuthAppOptions, + 'sessionPolicy' | 'appName' | 'successPath' | 'errorPath' + > & { + email: (env: E) => EmailSender; + }, +) { + const app = new Hono<{ Bindings: E }>(); + app.all('*', async (c) => { + if (new URL(c.env.APP_ORIGIN).protocol !== 'https:') + throw new Error('Workers auth requires HTTPS'); + const credentials: Record = {}; + for (const provider of providers) + for (const suffix of ['CLIENT_ID', 'CLIENT_SECRET']) { + const key = `${provider.toUpperCase()}_${suffix}`; + const value = c.env[key as keyof E]; + if (typeof value === 'string') credentials[key] = value; + } + const auth = createAuthApp({ + ...options, + databaseUrl: c.env.HYPERDRIVE.connectionString, + origin: c.env.APP_ORIGIN, + secret: c.env.AUTH_SECRET, + email: options.email(c.env), + oauth: oauthFromEnvironment(credentials), + ipAddressHeaders: ['cf-connecting-ip'], + }); + try { + return await auth.app.fetch(c.req.raw); + } finally { + await auth.close(); + } + }); + app.onError((_, c) => + c.json( + { message: 'Sign-in is temporarily unavailable. Please try again.' }, + 503, + { 'cache-control': 'no-store' }, + ), + ); + return app; +} diff --git a/packages/auth/src/better-auth.ts b/packages/auth/src/better-auth.ts new file mode 100644 index 0000000..b10d473 --- /dev/null +++ b/packages/auth/src/better-auth.ts @@ -0,0 +1,202 @@ +import { betterAuth, type BetterAuthOptions } from 'better-auth'; +import { emailOTP } from 'better-auth/plugins/email-otp'; +import { Hono } from 'hono'; +import { connectDatabase } from 'pgstencil/postgres'; +import type { EmailSender } from 'pgstencil'; +import { + keyed, + protectAuth, + publicAuthResponse, +} from './better-auth-security.ts'; +import { + socialProviders, + verifiedOidc, + oauthRequest, + type OAuthSettings, +} from './better-auth-oauth.ts'; + +export interface AuthOptions { + database: ReturnType; + origin: string; + secret: string; + email: EmailSender; + ipAddressHeaders?: string[]; + sessionPolicy?: 'single' | 'multiple'; + oauth?: OAuthSettings; + appName?: string; + successPath?: string; + errorPath?: string; +} + +export type AuthAppOptions = Omit & { + databaseUrl: string; +}; + +export function authOptions(options: AuthOptions): BetterAuthOptions { + if (new URL(options.origin).origin !== options.origin) + throw new Error('Auth requires a canonical origin'); + if (options.secret.length < 32) + throw new Error('Auth secret must contain at least 32 characters'); + for (const path of [options.successPath ?? '/', options.errorPath ?? '/']) { + if ( + !path.startsWith('/') || + new URL(path, options.origin).origin !== options.origin + ) + throw new Error( + 'Auth redirect paths must stay on the application origin', + ); + } + const secure = options.origin.startsWith('https:'); + return { + appName: options.appName ?? 'pgstencil', + baseURL: options.origin, + secret: options.secret, + database: { db: options.database, type: 'postgres', transaction: true }, + telemetry: { enabled: false }, + logger: { disabled: true }, + socialProviders: socialProviders(options.oauth), + onAPIError: { errorURL: options.origin + (options.errorPath ?? '/') }, + user: { + validateUserInfo: async ({ user, source }) => { + if ( + source.method === 'oauth' && + (user.emailVerified !== true || + typeof user.email !== 'string' || + !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(user.email)) + ) + return { error: 'A verified email address is required' }; + }, + }, + // Keep production security enabled under NODE_ENV=test as well. + advanced: { + // SQL is verified before deployment; avoid background introspection racing + // a request-scoped Worker pool's teardown. + database: { validateSchema: false }, + useSecureCookies: false, // Names below carry __Host- themselves; avoid a second prefix. + cookiePrefix: secure ? '__Host-pgstencil' : 'pgstencil', + defaultCookieAttributes: { + secure, + httpOnly: true, + sameSite: 'lax', + path: '/', + }, + disableOriginCheck: false, + disableCSRFCheck: false, + ipAddress: { + ipAddressHeaders: options.ipAddressHeaders ?? ['x-pgstencil-client-ip'], + }, + }, + // Workers are request-scoped; an in-memory limiter would reset every request. + rateLimit: { enabled: true, storage: 'database' }, + session: { + additionalFields: { + singleSession: { + type: 'boolean', + required: true, + defaultValue: false, + input: false, + returned: false, + }, + }, + freshAge: 10 * 60, + expiresIn: 24 * 3600, + disableSessionRefresh: true, + cookieCache: { enabled: false }, + }, + databaseHooks: { + account: { + create: { + before: async (account) => ({ + data: { + ...account, + accessToken: null, + refreshToken: null, + idToken: null, + }, + }), + }, + update: { + before: async (account) => ({ + data: { + ...account, + accessToken: null, + refreshToken: null, + idToken: null, + }, + }), + }, + }, + session: { + create: { + before: async (session) => ({ + data: { + ...session, + singleSession: options.sessionPolicy === 'single', + }, + }), + }, + }, + }, + account: { + encryptOAuthTokens: true, + storeAccountCookie: false, + storeStateStrategy: 'database', + accountLinking: { + disableImplicitLinking: true, + allowDifferentEmails: true, + }, + }, + plugins: [ + verifiedOidc, + emailOTP({ + otpLength: 8, + expiresIn: 600, + allowedAttempts: 3, + storeOTP: { + hash: async (otp) => keyed(options.secret, 'email-otp', otp), + }, + async sendVerificationOTP({ email, otp, type }) { + if (type !== 'sign-in') + throw new Error('This example only supports sign-in email'); + await options.email.send({ + from: 'signin@example.test', + to: [email], + subject: options.appName + ? `Your ${options.appName} sign-in code` + : 'Your sign-in code', + text: `Your sign-in code is ${otp}. It expires in 10 minutes.`, + html: `

Your sign-in code is ${otp}.

It expires in 10 minutes.

`, + }); + }, + }), + ], + }; +} + +export function createAuthApp(options: AuthAppOptions) { + const db = connectDatabase(options.databaseUrl); + const auth = betterAuth(authOptions({ ...options, database: db })); + const app = new Hono(); + protectAuth(app, { ...options, database: db }); + app.on(['POST', 'GET'], '/api/auth/*', async (c) => + publicAuthResponse( + await oauthRequest(c.req.raw, auth, { ...options, database: db }), + ), + ); + app.get('/api/providers', (c) => c.json(Object.keys(options.oauth ?? {}))); + app.onError((_error, c) => + c.json({ message: 'Authentication failed; please try again' }, 500), + ); + return { + app, + auth, + db, + close: async () => { + try { + await auth.$context; + } finally { + await db.destroy(); + } + }, + }; +} diff --git a/packages/auth/src/db.generated.ts b/packages/auth/src/db.generated.ts index 0563a28..619a745 100644 --- a/packages/auth/src/db.generated.ts +++ b/packages/auth/src/db.generated.ts @@ -53,6 +53,10 @@ export interface OauthIdentities { user_id: string; } +export interface OauthLocks { + id: number; +} + export interface RateLimits { count: number; key: string; @@ -79,6 +83,7 @@ export interface DB { login_flows: LoginFlows; oauth_flows: OauthFlows; oauth_identities: OauthIdentities; + oauth_locks: OauthLocks; rate_limits: RateLimits; sessions: Sessions; users: Users; diff --git a/packages/auth/src/fetch.ts b/packages/auth/src/fetch.ts new file mode 100644 index 0000000..d80f95f --- /dev/null +++ b/packages/auth/src/fetch.ts @@ -0,0 +1,314 @@ +import { Auth, SESSION_MS } from './auth.ts'; +import { OAuth } from './oauth.ts'; +import type { Provider } from './oauth-providers.ts'; +import { cookieValues, normalizeEmail, sessionCookie } from './security.ts'; +export { HttpError } from './http-error.ts'; +import { HttpError } from './http-error.ts'; + +/** Read incrementally so a missing or forged Content-Length cannot bypass the limit. */ +export async function readRequestBody( + req: Request, + limit = 8192, +): Promise { + const reader = req.body?.getReader(); + if (!reader) return ''; + const decoder = new TextDecoder(); + let size = 0, + body = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > limit) { + await reader.cancel(); + throw new HttpError(413, 'Request too large.'); + } + body += decoder.decode(value, { stream: true }); + } + return body + decoder.decode(); + } finally { + reader.releaseLock(); + } +} +export async function readJsonRequest( + req: Request, +): Promise> { + if ( + req.headers.get('content-type')?.split(';')[0]?.trim() !== + 'application/json' + ) + throw new HttpError(415, 'Send JSON.'); + try { + const value: unknown = JSON.parse(await readRequestBody(req)); + if (!value || typeof value !== 'object' || Array.isArray(value)) + throw new Error(); + return value as Record; + } catch (error) { + if (error instanceof HttpError) throw error; + throw new HttpError(400, 'Invalid JSON.'); + } +} +export interface SessionView { + user: { id: string; email: string }; + createdAt: string; + expiresAt: string; +} +export interface AuthState { + session: SessionView | null; + csrf: string; + pendingEmail: string | null; + providers: Provider[]; +} + +/** JSON adapter for SPA shells; cookies and all security decisions stay server-side. */ +export function createAuthFetch(options: { + auth: Auth; + oauth: OAuth; + secure: boolean; + loginPath?: string; + accountPath?: string; + /** Only use a forwarded address after your trusted proxy overwrites that header. */ + clientAddress?: (req: Request) => string; +}) { + const { auth, oauth, secure } = options; + const origin = auth.deps.origin; + if (new URL(origin).origin !== origin) + throw new Error('Auth requires an origin only'); + const loginPath = options.loginPath ?? '/login'; + const accountPath = options.accountPath ?? '/profile'; + for (const path of [loginPath, accountPath]) + if (!path.startsWith('/') || new URL(path, origin).origin !== origin) + throw new Error('Auth redirects must be same-origin'); + const names = { + session: secure ? '__Host-pgstencil' : 'pgstencil_dev', + pending: secure ? '__Host-pgstencil-pending' : 'pgstencil_pending', + oauth: secure ? '__Host-pgstencil-oauth' : 'pgstencil_oauth', + }; + const source = options.clientAddress ?? (() => 'unknown'); + const rawSession = (req: Request) => + cookieValues(req.headers.get('cookie') ?? undefined)[names.session]; + const session = (req: Request) => auth.session(rawSession(req)); + const csrfHeader = (req: Request) => req.headers.get('x-csrf-token') ?? ''; + function setCookie( + res: Headers, + name: string, + value: string, + seconds: number, + ) { + res.append( + 'set-cookie', + sessionCookie(name, value, auth.deps.time.now(), seconds, secure), + ); + } + function redirect(headers: Headers, path: string) { + headers.set('location', path); + return new Response(null, { status: 303, headers }); + } + function json(headers: Headers, value: unknown) { + headers.set('content-type', 'application/json; charset=utf-8'); + headers.set('x-content-type-options', 'nosniff'); + return new Response(JSON.stringify(value), { headers }); + } + function checkOrigin(req: Request) { + if (req.headers.get('origin') !== origin) + throw new HttpError(403, 'Return to this site and try again.'); + } + async function pending(req: Request) { + return auth.pending( + cookieValues(req.headers.get('cookie') ?? undefined)[names.pending], + ); + } + async function authorize(req: Request, allowPending = false) { + checkOrigin(req); + const current = await session(req); + if (current && auth.validSessionCsrf(current, csrfHeader(req))) + return current; + const flow = allowPending ? await pending(req) : undefined; + if (flow && auth.validCsrf(flow, csrfHeader(req))) return undefined; + throw new HttpError( + current || allowPending ? 403 : 401, + current || allowPending + ? 'Refresh this page and try again.' + : 'Sign in to continue.', + ); + } + function signedIn(res: Headers, value: string) { + setCookie(res, names.session, value, SESSION_MS / 1000); + setCookie(res, names.pending, '', 0); + } + async function handle(req: Request): Promise { + const incoming = new URL(req.url); + const url = new URL(incoming.pathname + incoming.search, origin); + const callback = /^\/oauth\/(google|github|apple|facebook)\/callback$/.exec( + url.pathname, + ); + if ( + !url.pathname.startsWith('/api/auth/') && + url.pathname !== '/login/link' && + !callback + ) + return undefined; + const res = new Headers({ + 'cache-control': 'no-store', + 'referrer-policy': 'no-referrer', + }); + if (req.method === 'POST' && callback?.[1] === 'apple') { + if (!oauth.providers.enabled.includes('apple')) + throw new HttpError(404, 'Sign-in method unavailable.'); + if ( + req.headers.get('content-type')?.split(';')[0]?.trim() !== + 'application/x-www-form-urlencoded' + ) + throw new HttpError(415, 'Send form data.'); + const form = new URLSearchParams(await readRequestBody(req)); + return redirect(res, appleCallbackLocation(form, url.pathname)); + } + if (req.method === 'GET' && callback) { + const provider = oauth.providers.enabled.find((id) => id === callback[1]); + if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); + const result = await oauth.complete( + provider, + url, + cookieValues(req.headers.get('cookie') ?? undefined)[names.oauth], + rawSession(req), + ); + if (result.clearCookie) setCookie(res, names.oauth, '', 0); + if (result.result.ok) { + signedIn(res, result.result.session); + return redirect(res, accountPath); + } else + return redirect( + res, + `${loginPath}?error=${encodeURIComponent(result.result.message)}`, + ); + } + if (req.method === 'GET' && url.pathname === '/login/link') { + // A link preview never consumes a challenge. The SPA presents a confirm button. + return redirect( + res, + `${loginPath}#${new URLSearchParams({ id: url.searchParams.get('id') ?? '', token: url.searchParams.get('token') ?? '' })}`, + ); + } + if (req.method === 'GET' && url.pathname === '/api/auth/session') { + const current = await session(req); + let flow = await pending(req); + if (!current && !flow) { + flow = await auth.newFlow(); + setCookie(res, names.pending, flow.cookie, 30 * 60); + } + return json(res, { + session: current + ? { + user: { id: current.user_id, email: current.email }, + createdAt: current.created_at.toISOString(), + expiresAt: current.expires_at.toISOString(), + } + : null, + csrf: current ? auth.sessionCsrf(rawSession(req)!) : flow!.csrf, + pendingEmail: flow?.flow.email ?? null, + providers: [...oauth.providers.enabled], + } satisfies AuthState); + } + if (req.method === 'GET' && url.pathname === '/api/auth/providers') { + const current = await session(req); + if (!current) throw new HttpError(401, 'Sign in to continue.'); + const identities = await auth.deps.db + .selectFrom('oauth_identities') + .select('provider') + .where('user_id', '=', current.user_id) + .orderBy('provider') + .execute(); + return json( + res, + identities.map((identity) => identity.provider), + ); + } + if (req.method !== 'POST') throw new HttpError(404, 'Route not found.'); + checkOrigin(req); + const body = await readJsonRequest(req); + const text = (key: string) => + typeof body[key] === 'string' ? (body[key] as string) : ''; + if (url.pathname === '/api/auth/logout') { + await authorize(req); + await auth.logout(rawSession(req)!); + setCookie(res, names.session, '', 0); + return json(res, { ok: true }); + } + const oauthRoute = + /^\/api\/auth\/oauth\/(google|github|apple|facebook)\/(start|connect)$/.exec( + url.pathname, + ); + if (oauthRoute?.[2] === 'connect') { + const current = await authorize(req); + const provider = oauth.providers.enabled.find( + (id) => id === oauthRoute[1], + ); + if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); + const result = await oauth.begin(provider, source(req), current); + if (!result.ok) throw new HttpError(result.status, result.message); + setCookie(res, names.oauth, result.cookie, result.seconds); + return json(res, { url: result.url }); + } + const flow = await pending(req); + if (!flow || !auth.validCsrf(flow, csrfHeader(req))) + throw new HttpError(403, 'Start a new sign-in.'); + if (oauthRoute) { + const provider = oauth.providers.enabled.find( + (id) => id === oauthRoute[1], + ); + if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); + const result = await oauth.begin(provider, source(req)); + if (!result.ok) throw new HttpError(result.status, result.message); + setCookie(res, names.oauth, result.cookie, result.seconds); + return json(res, { url: result.url }); + } + if ( + url.pathname === '/api/auth/email' || + url.pathname === '/api/auth/resend' + ) { + const email = normalizeEmail( + url.pathname.endsWith('/resend') + ? (flow.flow.email ?? '') + : text('email'), + ); + if (!email) throw new HttpError(400, 'Enter a valid email address.'); + const result = await auth.send(flow, email, source(req)); + if (!result.ok) throw new HttpError(result.status, result.message); + return json(res, { ok: true }); + } + if (url.pathname === '/api/auth/verify') { + const method = text('method'); + if (method !== 'code' && method !== 'link') + throw new HttpError(400, 'Choose code or link verification.'); + const result = await auth.verify( + flow, + method, + text('value'), + text('id') || undefined, + source(req), + rawSession(req), + ); + if (!result.ok) throw new HttpError(result.status, result.message); + signedIn(res, result.session); + return json(res, { ok: true }); + } + throw new HttpError(404, 'Route not found.'); + } + return { handle, session, authorize, names, checkOrigin }; +} + +/** Relay Apple's cross-site POST to a GET that carries the browser's Lax cookies. */ +export function appleCallbackLocation( + form: URLSearchParams, + pathname: string, +): string { + const query = new URLSearchParams(); + for (const key of ['state', 'code', 'error']) { + const values = form.getAll(key); + if (values.length > 1) throw new HttpError(400, 'Invalid callback.'); + if (values[0]) query.set(key, values[0]); + } + // No tokens are exchanged until the GET checks state AND the browser cookie. + return `${pathname}?${query}`; +} diff --git a/packages/auth/src/hono.ts b/packages/auth/src/hono.ts new file mode 100644 index 0000000..8698d42 --- /dev/null +++ b/packages/auth/src/hono.ts @@ -0,0 +1,34 @@ +import { Hono, type Context, type Env } from 'hono'; +import { createAuthFetch, HttpError } from './fetch.ts'; + +export type AuthFetch = ReturnType; +/** The factory may acquire request-scoped services from Hono context variables. */ +export function createAuthHono( + factory: AuthFetch | ((context: Context) => AuthFetch), +) { + const app = new Hono(); + app.onError( + (error) => + new Response( + JSON.stringify({ + error: + error instanceof HttpError + ? error.message + : 'Sign-in is temporarily unavailable. Please try again.', + }), + { + status: error instanceof HttpError ? error.status : 503, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + 'referrer-policy': 'no-referrer', + }, + }, + ), + ); + app.all('*', async (c) => { + const api = typeof factory === 'function' ? factory(c) : factory; + return (await api.handle(c.req.raw)) ?? c.notFound(); + }); + return app; +} diff --git a/packages/auth/src/http-error.ts b/packages/auth/src/http-error.ts new file mode 100644 index 0000000..9cd99ef --- /dev/null +++ b/packages/auth/src/http-error.ts @@ -0,0 +1,8 @@ +export class HttpError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} diff --git a/packages/auth/src/http.ts b/packages/auth/src/http.ts index 88ab17a..a142bc9 100644 --- a/packages/auth/src/http.ts +++ b/packages/auth/src/http.ts @@ -1,16 +1,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; -import { Auth, SESSION_MS } from './auth.ts'; -import { OAuth } from './oauth.ts'; -import { cookieValues, normalizeEmail, sessionCookie } from './security.ts'; - -export class HttpError extends Error { - constructor( - readonly status: number, - message: string, - ) { - super(message); - } -} +import { createAuthFetch } from './fetch.ts'; +import { HttpError } from './http-error.ts'; +export { HttpError } from './http-error.ts'; +export type { SessionView, AuthState } from './fetch.ts'; export async function readBody( req: IncomingMessage, limit = 8192, @@ -48,237 +40,65 @@ export function sendJson(res: ServerResponse, value: unknown, status = 200) { }) .end(JSON.stringify(value)); } -export interface SessionView { - user: { id: string; email: string }; - createdAt: string; - expiresAt: string; -} -export interface AuthState { - session: SessionView | null; - csrf: string; - pendingEmail: string | null; - providers: ('google' | 'github')[]; -} -/** JSON adapter for SPA shells; cookies and all security decisions stay server-side. */ -export function createAuthHttp(options: { - auth: Auth; - oauth: OAuth; - secure: boolean; - loginPath?: string; - accountPath?: string; - /** Only use a forwarded address after your trusted proxy overwrites that header. */ - clientAddress?: (req: IncomingMessage) => string; -}) { - const { auth, oauth, secure } = options; - const origin = auth.deps.origin; - if (new URL(origin).origin !== origin) - throw new Error('Auth requires an origin only'); - const loginPath = options.loginPath ?? '/login'; - const accountPath = options.accountPath ?? '/profile'; - for (const path of [loginPath, accountPath]) - if (!path.startsWith('/') || new URL(path, origin).origin !== origin) - throw new Error('Auth redirects must be same-origin'); - const names = { - session: secure ? '__Host-pgstencil' : 'pgstencil_dev', - pending: secure ? '__Host-pgstencil-pending' : 'pgstencil_pending', - oauth: secure ? '__Host-pgstencil-oauth' : 'pgstencil_oauth', - }; - const source = - options.clientAddress ?? - ((req: IncomingMessage) => req.socket.remoteAddress ?? 'unknown'); - const rawSession = (req: IncomingMessage) => - cookieValues(req.headers.cookie)[names.session]; - const session = (req: IncomingMessage) => auth.session(rawSession(req)); - const csrfHeader = (req: IncomingMessage) => - typeof req.headers['x-csrf-token'] === 'string' - ? req.headers['x-csrf-token'] - : ''; - function setCookie( - res: ServerResponse, - name: string, - value: string, - seconds: number, - ) { - const existing = - (res.getHeader('set-cookie') as string[] | undefined) ?? []; - res.setHeader('set-cookie', [ - ...existing, - sessionCookie(name, value, auth.deps.time.now(), seconds, secure), - ]); - } - function redirect(res: ServerResponse, path: string) { - res - .writeHead(303, { - location: path, - 'cache-control': 'no-store', - 'referrer-policy': 'strict-origin', - }) - .end(); - } - function checkOrigin(req: IncomingMessage) { - if (req.headers.origin !== origin) - throw new HttpError(403, 'Return to this site and try again.'); - } - async function pending(req: IncomingMessage) { - return auth.pending(cookieValues(req.headers.cookie)[names.pending]); - } - async function authorize(req: IncomingMessage, allowPending = false) { - checkOrigin(req); - const current = await session(req); - if (current && auth.validSessionCsrf(current, csrfHeader(req))) - return current; - const flow = allowPending ? await pending(req) : undefined; - if (flow && auth.validCsrf(flow, csrfHeader(req))) return undefined; - throw new HttpError( - current || allowPending ? 403 : 401, - current || allowPending - ? 'Refresh this page and try again.' - : 'Sign in to continue.', +/** Node bridge; Hono/Workers use the same security decisions through the Fetch adapter. */ +export function createAuthHttp( + options: Omit[0], 'clientAddress'> & { + clientAddress?: (req: IncomingMessage) => string; + }, +) { + const sources = new WeakMap(); + const api = createAuthFetch({ + ...options, + clientAddress: (req) => sources.get(req) ?? 'unknown', + }); + function request(req: IncomingMessage, body?: Buffer) { + const headers = new Headers(); + for (const [key, value] of Object.entries(req.headers)) { + if (Array.isArray(value)) + for (const item of value) headers.append(key, item); + else if (value !== undefined) headers.set(key, value); + } + const value = new Request( + new URL(req.url ?? '/', options.auth.deps.origin), + { + method: req.method ?? 'GET', + headers, + ...(body ? { body: new Uint8Array(body) } : {}), + }, ); + sources.set( + value, + options.clientAddress?.(req) ?? req.socket.remoteAddress ?? 'unknown', + ); + return value; } - function signedIn(res: ServerResponse, value: string) { - setCookie(res, names.session, value, SESSION_MS / 1000); - setCookie(res, names.pending, '', 0); - } - async function handle( - req: IncomingMessage, - res: ServerResponse, - ): Promise { - const url = new URL(req.url ?? '/', origin); - const callback = /^\/oauth\/(google|github)\/callback$/.exec(url.pathname); - if ( - !url.pathname.startsWith('/api/auth/') && - url.pathname !== '/login/link' && - !callback - ) - return false; - res.setHeader('cache-control', 'no-store'); - res.setHeader('referrer-policy', 'strict-origin'); - if (req.method === 'GET' && callback) { - const provider = oauth.providers.enabled.find((id) => id === callback[1]); - if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); - const result = await oauth.complete( - provider, - url, - cookieValues(req.headers.cookie)[names.oauth], - rawSession(req), - ); - if (result.clearCookie) setCookie(res, names.oauth, '', 0); - if (result.result.ok) { - signedIn(res, result.result.session); - redirect(res, accountPath); - } else - redirect( - res, - `${loginPath}?error=${encodeURIComponent(result.result.message)}`, - ); - return true; - } - if (req.method === 'GET' && url.pathname === '/login/link') { - // A link preview never consumes a challenge. The SPA presents a confirm button. - redirect( - res, - `${loginPath}#${new URLSearchParams({ id: url.searchParams.get('id') ?? '', token: url.searchParams.get('token') ?? '' })}`, - ); - return true; - } - if (req.method === 'GET' && url.pathname === '/api/auth/session') { - const current = await session(req); - let flow = await pending(req); - if (!current && !flow) { - flow = await auth.newFlow(); - setCookie(res, names.pending, flow.cookie, 30 * 60); - } - sendJson(res, { - session: current - ? { - user: { id: current.user_id, email: current.email }, - createdAt: current.created_at.toISOString(), - expiresAt: current.expires_at.toISOString(), - } - : null, - csrf: current ? auth.sessionCsrf(rawSession(req)!) : flow!.csrf, - pendingEmail: flow?.flow.email ?? null, - providers: [...oauth.providers.enabled], - } satisfies AuthState); - return true; - } - if (req.method !== 'POST') throw new HttpError(404, 'Route not found.'); - checkOrigin(req); - const body = await readJson(req); - const text = (key: string) => - typeof body[key] === 'string' ? (body[key] as string) : ''; - if (url.pathname === '/api/auth/logout') { - await authorize(req); - await auth.logout(rawSession(req)!); - setCookie(res, names.session, '', 0); - sendJson(res, { ok: true }); - return true; - } - const oauthRoute = - /^\/api\/auth\/oauth\/(google|github)\/(start|connect)$/.exec( - url.pathname, - ); - if (oauthRoute?.[2] === 'connect') { - const current = await authorize(req); - const provider = oauth.providers.enabled.find( - (id) => id === oauthRoute[1], - ); - if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); - const result = await oauth.begin(provider, source(req), current); - if (!result.ok) throw new HttpError(result.status, result.message); - setCookie(res, names.oauth, result.cookie, result.seconds); - sendJson(res, { url: result.url }); - return true; - } - const flow = await pending(req); - if (!flow || !auth.validCsrf(flow, csrfHeader(req))) - throw new HttpError(403, 'Start a new sign-in.'); - if (oauthRoute) { - const provider = oauth.providers.enabled.find( - (id) => id === oauthRoute[1], + return { + names: api.names, + session: (req: IncomingMessage) => api.session(request(req)), + authorize: (req: IncomingMessage, allowPending = false) => + api.authorize(request(req), allowPending), + checkOrigin: (req: IncomingMessage) => api.checkOrigin(request(req)), + async handle(req: IncomingMessage, res: ServerResponse): Promise { + const path = new URL(req.url ?? '/', options.auth.deps.origin).pathname; + if ( + !path.startsWith('/api/auth/') && + path !== '/login/link' && + !/^\/oauth\/[^/]+\/callback$/.test(path) + ) + return false; + const response = await api.handle( + request(req, req.method === 'POST' ? await readBody(req) : undefined), ); - if (!provider) throw new HttpError(404, 'Sign-in method unavailable.'); - const result = await oauth.begin(provider, source(req)); - if (!result.ok) throw new HttpError(result.status, result.message); - setCookie(res, names.oauth, result.cookie, result.seconds); - sendJson(res, { url: result.url }); + if (!response) return false; + res.statusCode = response.status; + response.headers.forEach((value, key) => { + if (key !== 'set-cookie') res.setHeader(key, value); + }); + const cookies = response.headers.getSetCookie(); + if (cookies.length) res.setHeader('set-cookie', cookies); + res.end(Buffer.from(await response.arrayBuffer())); return true; - } - if ( - url.pathname === '/api/auth/email' || - url.pathname === '/api/auth/resend' - ) { - const email = normalizeEmail( - url.pathname.endsWith('/resend') - ? (flow.flow.email ?? '') - : text('email'), - ); - if (!email) throw new HttpError(400, 'Enter a valid email address.'); - const result = await auth.send(flow, email, source(req)); - if (!result.ok) throw new HttpError(result.status, result.message); - sendJson(res, { ok: true }); - return true; - } - if (url.pathname === '/api/auth/verify') { - const method = text('method'); - if (method !== 'code' && method !== 'link') - throw new HttpError(400, 'Choose code or link verification.'); - const result = await auth.verify( - flow, - method, - text('value'), - text('id') || undefined, - source(req), - rawSession(req), - ); - if (!result.ok) throw new HttpError(result.status, result.message); - signedIn(res, result.session); - sendJson(res, { ok: true }); - return true; - } - throw new HttpError(404, 'Route not found.'); - } - return { handle, session, authorize, names, checkOrigin }; + }, + }; } diff --git a/packages/auth/src/oauth-providers.ts b/packages/auth/src/oauth-providers.ts index 26c346c..f59952e 100644 --- a/packages/auth/src/oauth-providers.ts +++ b/packages/auth/src/oauth-providers.ts @@ -1,15 +1,20 @@ +import { createHmac } from 'node:crypto'; import * as client from 'openid-client'; import { normalizeEmail } from './security.ts'; -export const PROVIDERS = ['google', 'github'] as const; +export const PROVIDERS = ['google', 'github', 'apple', 'facebook'] as const; export type Provider = (typeof PROVIDERS)[number]; export const PROVIDER_LABELS: Record = { google: 'Google', github: 'GitHub', + apple: 'Apple', + facebook: 'Facebook', }; export const PROVIDER_ORIGINS: Record = { google: 'https://accounts.google.com', github: 'https://github.com', + apple: 'https://appleid.apple.com', + facebook: 'https://www.facebook.com', }; export interface OAuthCredentials { clientId: string; @@ -78,9 +83,9 @@ export class OAuthProviders { } private async configure(provider: Provider): Promise { const { clientId, clientSecret } = this.settings[provider]!; - if (provider === 'google') { + if (provider === 'google' || provider === 'apple') { return client.discovery( - new URL(PROVIDER_ORIGINS.google), + new URL(PROVIDER_ORIGINS[provider]), clientId, { client_secret: clientSecret, id_token_signed_response_alg: 'RS256' }, client.ClientSecretPost(clientSecret), @@ -91,6 +96,23 @@ export class OAuthProviders { }, ); } + if (provider === 'facebook') { + // Meta's unversioned endpoints follow the application's configured API version. + const config = new client.Configuration( + { + issuer: PROVIDER_ORIGINS.facebook, + authorization_endpoint: 'https://www.facebook.com/dialog/oauth', + token_endpoint: 'https://graph.facebook.com/oauth/access_token', + response_types_supported: ['code'], + }, + clientId, + clientSecret, + client.ClientSecretPost(clientSecret), + ); + config.timeout = 10; + if (this.transport) config[client.customFetch] = this.transport; + return config; + } // GitHub implements OAuth 2, but does not publish OIDC discovery metadata. const config = new client.Configuration( { @@ -116,13 +138,27 @@ export class OAuthProviders { const config = await this.configuration(provider); const parameters: Record = { redirect_uri: proof.redirectUri, - scope: provider === 'google' ? 'openid email' : 'read:user user:email', + scope: + provider === 'github' + ? 'read:user user:email' + : provider === 'facebook' + ? 'email' + : 'openid email', state: proof.state, - code_challenge: await client.calculatePKCECodeChallenge(proof.verifier), - code_challenge_method: 'S256', }; - if (provider === 'google') parameters.nonce = proof.nonce; - if (connecting) parameters.prompt = 'select_account'; + if (provider === 'google' || provider === 'github') { + parameters.code_challenge = await client.calculatePKCECodeChallenge( + proof.verifier, + ); + parameters.code_challenge_method = 'S256'; + } + if (provider === 'google' || provider === 'apple') + parameters.nonce = proof.nonce; + if (provider === 'apple') parameters.response_mode = 'form_post'; + if (connecting && provider === 'google') + parameters.prompt = 'select_account'; + if (connecting && provider === 'facebook') + parameters.auth_type = 'reauthenticate'; return client.buildAuthorizationUrl(config, parameters); } async identity( @@ -133,25 +169,59 @@ export class OAuthProviders { const config = await this.configuration(provider); const tokens = await client.authorizationCodeGrant(config, callback, { expectedState: proof.state, - pkceCodeVerifier: proof.verifier, - ...(provider === 'google' + ...(provider === 'google' || provider === 'github' + ? { pkceCodeVerifier: proof.verifier } + : {}), + ...(provider === 'google' || provider === 'apple' ? { expectedNonce: proof.nonce, idTokenExpected: true } : {}), }); // Tokens are used only during this call. Never persist or log them. - if (provider === 'google') { + if (provider === 'google' || provider === 'apple') { const claims = tokens.claims(); - if (!claims || claims.email_verified !== true) + if ( + !claims || + !( + claims.email_verified === true || + (provider === 'apple' && claims.email_verified === 'true') + ) + ) throw new IdentityError( - 'Google must provide a verified email address.', + `${PROVIDER_LABELS[provider]} must provide a verified email address.`, ); if ( typeof claims.sub !== 'string' || !/^[\x21-\x7e]{1,255}$/.test(claims.sub) ) - throw new IdentityError('Invalid Google identity'); + throw new IdentityError( + `Invalid ${PROVIDER_LABELS[provider]} identity`, + ); return { subject: claims.sub, email: verifiedEmail(claims.email) }; } + if (provider === 'facebook') { + const url = new URL('https://graph.facebook.com/me'); + url.searchParams.set('fields', 'id,email'); + url.searchParams.set( + 'appsecret_proof', + createHmac('sha256', this.settings.facebook!.clientSecret) + .update(tokens.access_token) + .digest('hex'), + ); + const response = await client.fetchProtectedResource( + config, + tokens.access_token, + url, + 'GET', + ); + if (!response.ok) throw new Error('Facebook identity request failed'); + const profile = object(await response.json()); + if (typeof profile.id !== 'string' || !/^[0-9]{1,255}$/.test(profile.id)) + throw new IdentityError('Invalid Facebook identity'); + // Facebook's authenticated primary email is trusted as in Supabase's + // Facebook adapter. Missing email/denied permission cannot create an account. + // Matching emails never silently link two provider identities. + return { subject: profile.id, email: verifiedEmail(profile.email) }; + } const resource = async (url: string) => { const response = await client.fetchProtectedResource( config, @@ -201,7 +271,9 @@ export class OAuthProviders { } } -export function oauthFromEnvironment(env: NodeJS.ProcessEnv): OAuthSettings { +export function oauthFromEnvironment( + env: Record, +): OAuthSettings { const settings: OAuthSettings = {}; for (const provider of PROVIDERS) { const prefix = provider.toUpperCase(); diff --git a/packages/auth/src/oauth.ts b/packages/auth/src/oauth.ts index a9adcc3..ba9afb0 100644 --- a/packages/auth/src/oauth.ts +++ b/packages/auth/src/oauth.ts @@ -229,7 +229,14 @@ export class OAuth { const result = await db .transaction() .execute(async (trx): Promise => { - await sql`SELECT pg_advisory_xact_lock(hashtext(${'oauth-identity:' + provider + ':' + identity.subject}))`.execute( + // A bounded set of lock rows also covers identities not inserted yet. + // Hash collisions only serialize unrelated logins; they cannot merge them. + const bucket = + parseInt( + digest(provider + ':' + identity.subject).slice(0, 8), + 16, + ) % 64; + await sql`SELECT id FROM oauth_locks WHERE id = ${bucket} FOR UPDATE`.execute( trx, ); const now = time.now(); diff --git a/packages/auth/src/postmark.ts b/packages/auth/src/postmark.ts new file mode 100644 index 0000000..00d7935 --- /dev/null +++ b/packages/auth/src/postmark.ts @@ -0,0 +1,40 @@ +import type { EmailSender } from 'pgstencil'; + +/** Production transport; tests inject EmailDev instead. Provider errors contain no message content. */ +export function postmarkEmail(token: string, from: string): EmailSender { + if (!token || !from) + throw new Error('Configure Postmark and the sender address'); + return { + async send(message) { + const response = await fetch('https://api.postmarkapp.com/email', { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + 'content-type': 'application/json', + 'x-postmark-server-token': token, + }, + body: JSON.stringify({ + From: from, + To: message.to.join(','), + Subject: message.subject, + TextBody: message.text, + HtmlBody: message.html, + MessageStream: 'outbound', + ...(message.replyTo ? { ReplyTo: message.replyTo } : {}), + ...(message.cc ? { Cc: message.cc.join(',') } : {}), + ...(message.bcc ? { Bcc: message.bcc.join(',') } : {}), + ...(message.headers + ? { + Headers: Object.entries(message.headers).map( + ([Name, Value]) => ({ Name, Value }), + ), + } + : {}), + }), + }); + if (!response.ok) throw new Error('Email delivery failed'); + const result = (await response.json()) as { ErrorCode?: number }; + if (result.ErrorCode !== 0) throw new Error('Email delivery failed'); + }, + }; +} diff --git a/packages/auth/src/workers.ts b/packages/auth/src/workers.ts new file mode 100644 index 0000000..9a240cc --- /dev/null +++ b/packages/auth/src/workers.ts @@ -0,0 +1,115 @@ +import { Hono } from 'hono'; +import { + SecureRandom, + SystemTime, + type EmailSender, + type Time, + type RandomSource, +} from 'pgstencil'; +import { connectDatabase } from 'pgstencil/postgres'; +import { Auth } from './auth.ts'; +import type { loginEmail } from './email.ts'; +import type { DB } from './db.generated.ts'; +import { OAuth } from './oauth.ts'; +import { + OAuthProviders, + PROVIDERS, + oauthFromEnvironment, + type Provider, + type OAuthFetch, +} from './oauth-providers.ts'; +import { createAuthFetch } from './fetch.ts'; +import { createAuthHono, type AuthFetch } from './hono.ts'; + +export type AuthWorkerBindings = { + HYPERDRIVE: { connectionString: string }; + APP_ORIGIN: string; + AUTH_SECRET: string; +} & Partial< + Record< + `${Uppercase}_CLIENT_ID` | `${Uppercase}_CLIENT_SECRET`, + string + > +>; + +/** Request-scoped database connections: never share sockets across Worker invocations. */ +export function createAuthWorker(options: { + email: (env: E) => EmailSender; + loginPath?: string; + accountPath?: string; + renderEmail?: typeof loginEmail; + /** Injection points belong in a separate test entrypoint, never public HTTP controls. */ + time?: Time; + random?: RandomSource; + oauthFetch?: OAuthFetch; +}) { + type Environment = { Bindings: E; Variables: { auth: AuthFetch } }; + const app = new Hono(); + app.use('*', async (c, next) => { + const env = c.env; + if (new URL(env.APP_ORIGIN).protocol !== 'https:') + throw new Error('Workers auth requires an HTTPS application origin'); + const db = connectDatabase(env.HYPERDRIVE.connectionString); + try { + const auth = new Auth({ + db, + origin: env.APP_ORIGIN, + secret: env.AUTH_SECRET, + time: options.time ?? new SystemTime(), + random: options.random ?? new SecureRandom(), + email: options.email(env), + ...(options.renderEmail ? { renderEmail: options.renderEmail } : {}), + }); + const credentials: Record = {}; + for (const provider of PROVIDERS) + for (const suffix of ['CLIENT_ID', 'CLIENT_SECRET'] as const) { + const key = + `${provider.toUpperCase()}_${suffix}` as keyof AuthWorkerBindings; + const value = env[key]; + if (typeof value === 'string') credentials[key] = value; + } + c.set( + 'auth', + createAuthFetch({ + auth, + oauth: new OAuth( + auth, + new OAuthProviders( + oauthFromEnvironment(credentials), + options.oauthFetch, + ), + ), + secure: true, + ...(options.loginPath ? { loginPath: options.loginPath } : {}), + ...(options.accountPath ? { accountPath: options.accountPath } : {}), + // Cloudflare overwrites this header on inbound requests. + clientAddress: (req) => + req.headers.get('cf-connecting-ip') ?? 'unknown', + }), + ); + await next(); + } finally { + await db.destroy(); + } + }); + app.route( + '/', + createAuthHono((c) => c.get('auth')), + ); + app.onError( + () => + new Response( + JSON.stringify({ + error: 'Sign-in is temporarily unavailable. Please try again.', + }), + { + status: 503, + headers: { + 'content-type': 'application/json', + 'cache-control': 'no-store', + }, + }, + ), + ); + return app; +} diff --git a/packages/stripe/src/index.ts b/packages/stripe/src/index.ts index 1729dfa..7f66ed9 100644 --- a/packages/stripe/src/index.ts +++ b/packages/stripe/src/index.ts @@ -487,15 +487,18 @@ export class Billing { }; } /** Throws BillingError(400) for anything the sender got wrong. */ - verifyWebhook(body: Buffer | string, signature: string): Stripe.Event { + async verifyWebhook( + body: Buffer | string, + signature: string, + ): Promise { let event: Stripe.Event; try { - event = this.stripe.webhooks.constructEvent( + event = await this.stripe.webhooks.constructEventAsync( body, signature, this.config.webhookSecret, 300, - undefined, + Stripe.createSubtleCryptoProvider(), this.time.now().getTime(), ); } catch (error) { @@ -518,16 +521,26 @@ export class Billing { /** Application database effects join the same commit and retry boundary. */ apply?: (event: Stripe.Event, trx: Transaction) => Promise, ): Promise { - const event = this.verifyWebhook(body, signature); + const event = await this.verifyWebhook(body, signature); try { await this.db.transaction().execute(async (trx) => { - await sql`select pg_advisory_xact_lock(hashtext(${`stripe-event:${event.id}`}))`.execute( - trx, - ); + await trx + .insertInto('events') + .values({ + id: event.id, + type: event.type, + received_at: this.time.now(), + processed_at: null, + attempts: 0, + failed: false, + }) + .onConflict((c) => c.column('id').doNothing()) + .execute(); const saved = await trx .selectFrom('events') .selectAll() .where('id', '=', event.id) + .forUpdate() .executeTakeFirst(); if (saved?.processed_at) return; // Any event naming a customer we own triggers the same authoritative diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a71195..66540e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,9 +20,15 @@ importers: '@types/turndown': specifier: ^5.0.0 version: 5.0.6 + esbuild: + specifier: ^0.28.2 + version: 0.28.2 kysely-codegen: specifier: ^0.19.0 version: 0.19.0(kysely@0.29.5)(pg@8.23.0)(typescript@5.9.3) + miniflare: + specifier: 5.20260908.0-alpha + version: 5.20260908.0-alpha prettier: specifier: ^3.6.0 version: 3.9.6 @@ -38,6 +44,33 @@ importers: vitest: specifier: ^4.0.0 version: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + wrangler: + specifier: ^4.130.0 + version: 4.130.0 + + examples/better-auth: + dependencies: + '@hono/node-server': + specifier: 1.19.17 + version: 1.19.17(hono@4.13.7) + '@pgstencil/auth': + specifier: workspace:* + version: link:../../packages/auth + better-auth: + specifier: 1.7.3 + version: 1.7.3(pg@8.23.0)(vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) + hono: + specifier: ^4.13.7 + version: 4.13.7 + kysely: + specifier: 0.29.5 + version: 0.29.5 + pg: + specifier: ^8.16.0 + version: 8.23.0 + pgstencil: + specifier: workspace:* + version: link:../../packages/pgstencil examples/login: dependencies: @@ -57,8 +90,20 @@ importers: specifier: workspace:* version: link:../../packages/pgstencil + examples/workers: + dependencies: + '@pgstencil/auth': + specifier: workspace:* + version: link:../../packages/auth + packages/auth: dependencies: + better-auth: + specifier: 1.7.3 + version: 1.7.3(pg@8.23.0)(vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))) + hono: + specifier: ^4.13.7 + version: 4.13.7 kysely: specifier: 0.29.5 version: 0.29.5 @@ -115,156 +160,444 @@ packages: '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@better-auth/core@1.7.3': + resolution: {integrity: sha512-JdP7lOkyE83jgjn7RilJj1XvZ7n2JjRsErKJuaXchjyuNo6cf1iVd3GtbhAtiUyJJkWdk8yL+LaUBKT80H0zLA==} + peerDependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@cloudflare/workers-types': '>=4' + '@opentelemetry/api': ^1.9.0 + better-call: 1.4.0 + jose: ^6.1.0 + kysely: ^0.28.5 || ^0.29.0 + nanostores: ^1.0.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + '@opentelemetry/api': + optional: true + + '@better-auth/drizzle-adapter@1.7.3': + resolution: {integrity: sha512-S+nQRlxbUhkR43LrSv8c98ZvOvmv3nrtOnHkiZXkdDkr60PWp7maC2cqgzZ2C9exCC1a+4TugJlx2jRL+r+/9A==} + peerDependencies: + '@better-auth/core': ^1.7.3 + '@better-auth/utils': 0.4.2 + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + peerDependenciesMeta: + drizzle-orm: + optional: true + + '@better-auth/kysely-adapter@1.7.3': + resolution: {integrity: sha512-UIsyJMIrjUnT+yTaS6dkCxYYmtPwxFHxwSJ8+CLty2II5w9BewlDxDA0/QzhoL/InYCPxQ5Y6xIgLHZG1dhwRA==} + peerDependencies: + '@better-auth/core': ^1.7.3 + '@better-auth/utils': 0.4.2 + kysely: ^0.28.17 || ^0.29.0 + peerDependenciesMeta: + kysely: + optional: true + + '@better-auth/memory-adapter@1.7.3': + resolution: {integrity: sha512-WdLANFY/QWC3G351RCzxU+Y9YlW+BQ1oG9NwBTSOUWQw5rZ87ws+weU8tvAMO7sQ4C9gKIlkOKBKUKXrSt91Tw==} + peerDependencies: + '@better-auth/core': ^1.7.3 + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.3': + resolution: {integrity: sha512-YL9m01tNogmFmRvOWJ46M9WwE6HirCXHDell29mtsQB/Qs1TPNLrgj7ybGMMGhQuyj6S218+8Wbls8m9s+i8RQ==} + peerDependencies: + '@better-auth/core': ^1.7.3 + '@better-auth/utils': 0.4.2 + mongodb: ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + mongodb: + optional: true + + '@better-auth/prisma-adapter@1.7.3': + resolution: {integrity: sha512-TJ/DhlU7oLzrC626/1wfYA1Pl+lVsXa/zXBZ8d7Rlc2YO5fGd5fsAYJdRrYMyA51iZEo+rWLXMhZ0cez1BamsQ==} + peerDependencies: + '@better-auth/core': ^1.7.3 + '@better-auth/utils': 0.4.2 + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + peerDependenciesMeta: + '@prisma/client': + optional: true + prisma: + optional: true + + '@better-auth/telemetry@1.7.3': + resolution: {integrity: sha512-aixgHbJhGvS8PRczX/LR3murYyBnIvOGmJw37ZZBMg6ZtLR/UBJAkufbDi1HYn5THSuTJ3D5DtY85Ahh/ABQtw==} + peerDependencies: + '@better-auth/core': ^1.7.3 + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + resolution: {integrity: sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A==} + + '@better-auth/utils@0.5.0': + resolution: {integrity: sha512-BL8W4EfIZFwlu0r54m3v1ztjDhu6dDe/amLTm0xybmbZaNgYUqhD3SjpAsnq0q8YD6/ki4iwIgxJNLP/N3TxiA==} + + '@better-fetch/fetch@1.3.1': + resolution: {integrity: sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g==} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.1': + resolution: {integrity: sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20260908.1': + resolution: {integrity: sha512-t3juyCXFn12OklBL0S7UC98py3nLEmuioBOUOazeyeVsLUu8fv+pfJrR+XzwSH2Uh6rCXZNogRh+LtV0YpzMcQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20260908.1': + resolution: {integrity: sha512-I1nwA4qm/fUNSKhPSO76YA6JAhcA0KU63yFcYHN6AiDowGbDyfjDPH9KCVopT5rI1LY4GfbdAi5b9gODqZsAkQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20260908.1': + resolution: {integrity: sha512-s/h5uSW1UC6dGeVKSqrPLpTu+vo0fKJZCNEokW1Ol1qcCB2oN6WCRHhoyzo4Y69oONAXRgLfLBYaHWe7z51Vnw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20260908.1': + resolution: {integrity: sha512-PP/nUKl0R6colwfocggL8dctO/dFMqMft12unzFUkoAJr0aXQeT+ia0JuNmOUWXe6EfvyCSmAbijEsTKQ5t/ew==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20260908.1': + resolution: {integrity: sha512-jkaS5EKKTvzAdIlbMfOYrHoTucWVRwYBwIig+xRsjXQv5BXTLA2Llg+iM8djlKtk0CjkztZhXmuxuqoqWKZmFw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -285,13 +618,188 @@ packages: engines: {node: '>=6'} hasBin: true + '@hono/node-server@1.19.17': + resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.2': + resolution: {integrity: sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.2': + resolution: {integrity: sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.2': + resolution: {integrity: sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.1': + resolution: {integrity: sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.1': + resolution: {integrity: sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.1': + resolution: {integrity: sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.1': + resolution: {integrity: sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.1': + resolution: {integrity: sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.1': + resolution: {integrity: sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.1': + resolution: {integrity: sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.1': + resolution: {integrity: sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + resolution: {integrity: sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + resolution: {integrity: sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.2': + resolution: {integrity: sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.2': + resolution: {integrity: sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.2': + resolution: {integrity: sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.2': + resolution: {integrity: sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.2': + resolution: {integrity: sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.2': + resolution: {integrity: sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.2': + resolution: {integrity: sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.2': + resolution: {integrity: sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.2': + resolution: {integrity: sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.2': + resolution: {integrity: sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.2': + resolution: {integrity: sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.2': + resolution: {integrity: sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.2': + resolution: {integrity: sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + '@jridgewell/sourcemap-codec@1.6.0': resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} @@ -301,10 +809,22 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@noble/ciphers@2.4.0': + resolution: {integrity: sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} + '@noble/hashes@2.4.0': + resolution: {integrity: sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==} + engines: {node: '>= 20.19.0'} + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + '@oxc-project/types@0.148.0': resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} @@ -315,6 +835,15 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -441,6 +970,13 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -633,9 +1169,82 @@ packages: bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-auth@1.7.3: + resolution: {integrity: sha512-8xGp68JQ+l36kniDEgP8bP99TLi1GdEv0NTEUBkyqnYnus/cgFUZseUfqUHKzr2BAsg2O6aD88I9f0U68shanQ==} + peerDependencies: + '@lynx-js/react': '*' + '@prisma/client': ^5.0.0 || ^6.0.0 || ^7.0.0 + '@sveltejs/kit': ^2.0.0 + '@tanstack/react-start': ^1.0.0 + '@tanstack/solid-start': ^1.0.0 + better-sqlite3: ^12.0.0 + drizzle-kit: '>=0.31.4 || >=1.0.0-beta.1' + drizzle-orm: ^0.45.2 || >=1.0.0-rc.1 <2.0.0 + mongodb: ^6.0.0 || ^7.0.0 + mysql2: ^3.0.0 + next: ^14.0.0 || ^15.0.0 || ^16.0.0 + pg: ^8.0.0 + prisma: ^5.0.0 || ^6.0.0 || ^7.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + solid-js: ^1.0.0 + svelte: ^4.0.0 || ^5.0.0 + vitest: ^2.0.0 || ^3.0.0 || ^4.0.0 + vue: ^3.0.0 + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@prisma/client': + optional: true + '@sveltejs/kit': + optional: true + '@tanstack/react-start': + optional: true + '@tanstack/solid-start': + optional: true + better-sqlite3: + optional: true + drizzle-kit: + optional: true + drizzle-orm: + optional: true + mongodb: + optional: true + mysql2: + optional: true + next: + optional: true + pg: + optional: true + prisma: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vitest: + optional: true + vue: + optional: true + + better-call@1.4.0: + resolution: {integrity: sha512-bBKOT4vv1kZLDgxVePdilk/Jwkn+dtRRsmi3DzHcDP+WnswyVl6dR59l2HEeP/0cB+bDoopASAesWDPIdd/zZA==} + peerDependencies: + zod: ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -747,6 +1356,10 @@ packages: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cookiejar@2.1.4: resolution: {integrity: sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==} @@ -795,6 +1408,9 @@ packages: supports-color: optional: true + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -888,6 +1504,9 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -907,6 +1526,11 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -1047,6 +1671,10 @@ packages: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} + hono@4.13.7: + resolution: {integrity: sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==} + engines: {node: '>=16.9.0'} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} @@ -1117,6 +1745,10 @@ packages: json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + kysely-codegen@0.19.0: resolution: {integrity: sha512-ZpdQQnpfY0kh45CA6yPA9vdFsBE+b06Fx7QVcbL5rX//yjbA0yYGZGhnH7GTd4P4BY/HIv5uAfuOD83JVZf95w==} engines: {node: '>=20.0.0'} @@ -1285,6 +1917,10 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + miniflare@5.20260908.0-alpha: + resolution: {integrity: sha512-BHIknb0u6vLIvb+R8PsUAzgoWWk3OlW85DrV2SG0EydfSpIba28YT0rH6xZThjnSwDxzNuW3FDRNSWvbiI/DVw==} + engines: {node: '>=22.0.0'} + minimatch@10.2.6: resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} @@ -1326,6 +1962,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanostores@1.5.3: + resolution: {integrity: sha512-rQLB6eV4f2AW/n3L0JmwCROpaisYy9EDEADvEFSd1C/qG8hB6O5TPlh9A791JRbJr4CnMQBzptDcvD9OR1+6WA==} + engines: {node: ^20.0.0 || >=22.0.0} + node-pg-migrate@9.0.0: resolution: {integrity: sha512-lp5+UZx1KKgOz/y0h6BYGPuJ5wVlZTgiBPGXCGoX6Bs6X9LtiIhrI4V2yW4mPwnJBfptVq0KtLQtXHcoEKOyig==} engines: {node: '>=20.11.0'} @@ -1400,6 +2040,9 @@ packages: resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} engines: {node: 18 || 20 || >=22} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -1542,6 +2185,9 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rou3@0.9.2: + resolution: {integrity: sha512-3SOzvaAg8rkHrXtRjpCvCvbyO5to9oOO27Z/XqHEYXfMRVSw/qMIVdmaOk9W2lcRLtR6dlqTjo9hDeJk70QBYQ==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -1551,6 +2197,18 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + + sharp@0.35.2: + resolution: {integrity: sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==} + engines: {node: '>=20.9.0'} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1664,6 +2322,10 @@ packages: resolution: {integrity: sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==} engines: {node: '>=14.18.0'} + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -1722,6 +2384,9 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.13: resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} engines: {node: '>=18.0.0'} @@ -1745,6 +2410,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + undici@7.29.1: resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} engines: {node: '>=20.18.1'} @@ -1753,6 +2422,9 @@ packages: resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} engines: {node: '>=22.19.0'} + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -1859,6 +2531,21 @@ packages: engines: {node: '>=8'} hasBin: true + workerd@1.20260908.1: + resolution: {integrity: sha512-rYhpW6NWHD++p34ej+VXWzi5Pdnmd7ncdbB/ux4s+i3mf7IeOpW3O4roqClQ+Z8ernvyMCknBLCb76z1rA+a7Q==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.130.0: + resolution: {integrity: sha512-fzNjnTzyZl31PGJOFXbiLeZqEIToQ9KOzkkvGdQz4wlB+BoHnl3BRwCR5xg8AqYyzVunvvMHlMzvlbThQ7rrAA==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20260908.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -1874,6 +2561,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -1903,6 +2602,12 @@ packages: resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -1922,81 +2627,246 @@ snapshots: '@balena/dockerignore@1.0.2': {} + '@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3)': + dependencies: + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@opentelemetry/semantic-conventions': 1.43.0 + '@standard-schema/spec': 1.1.0 + better-call: 1.4.0(zod@4.5.4) + jose: 6.2.12 + kysely: 0.29.5 + nanostores: 1.5.3 + zod: 4.5.4 + + '@better-auth/drizzle-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + + '@better-auth/kysely-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5)': + dependencies: + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + optionalDependencies: + kysely: 0.29.5 + + '@better-auth/memory-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + + '@better-auth/mongo-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + + '@better-auth/prisma-adapter@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)': + dependencies: + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + + '@better-auth/telemetry@1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': + dependencies: + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + + '@better-auth/utils@0.4.2': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-auth/utils@0.5.0': + dependencies: + '@noble/hashes': 2.4.0 + + '@better-fetch/fetch@1.3.1': {} + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260908.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20260908.1 + + '@cloudflare/workerd-darwin-64@1.20260908.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20260908.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20260908.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20260908.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20260908.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + '@esbuild/aix-ppc64@0.28.2': optional: true + '@esbuild/android-arm64@0.28.1': + optional: true + '@esbuild/android-arm64@0.28.2': optional: true + '@esbuild/android-arm@0.28.1': + optional: true + '@esbuild/android-arm@0.28.2': optional: true + '@esbuild/android-x64@0.28.1': + optional: true + '@esbuild/android-x64@0.28.2': optional: true + '@esbuild/darwin-arm64@0.28.1': + optional: true + '@esbuild/darwin-arm64@0.28.2': optional: true + '@esbuild/darwin-x64@0.28.1': + optional: true + '@esbuild/darwin-x64@0.28.2': optional: true + '@esbuild/freebsd-arm64@0.28.1': + optional: true + '@esbuild/freebsd-arm64@0.28.2': optional: true + '@esbuild/freebsd-x64@0.28.1': + optional: true + '@esbuild/freebsd-x64@0.28.2': optional: true + '@esbuild/linux-arm64@0.28.1': + optional: true + '@esbuild/linux-arm64@0.28.2': optional: true + '@esbuild/linux-arm@0.28.1': + optional: true + '@esbuild/linux-arm@0.28.2': optional: true + '@esbuild/linux-ia32@0.28.1': + optional: true + '@esbuild/linux-ia32@0.28.2': optional: true + '@esbuild/linux-loong64@0.28.1': + optional: true + '@esbuild/linux-loong64@0.28.2': optional: true + '@esbuild/linux-mips64el@0.28.1': + optional: true + '@esbuild/linux-mips64el@0.28.2': optional: true + '@esbuild/linux-ppc64@0.28.1': + optional: true + '@esbuild/linux-ppc64@0.28.2': optional: true + '@esbuild/linux-riscv64@0.28.1': + optional: true + '@esbuild/linux-riscv64@0.28.2': optional: true + '@esbuild/linux-s390x@0.28.1': + optional: true + '@esbuild/linux-s390x@0.28.2': optional: true + '@esbuild/linux-x64@0.28.1': + optional: true + '@esbuild/linux-x64@0.28.2': optional: true + '@esbuild/netbsd-arm64@0.28.1': + optional: true + '@esbuild/netbsd-arm64@0.28.2': optional: true + '@esbuild/netbsd-x64@0.28.1': + optional: true + '@esbuild/netbsd-x64@0.28.2': optional: true + '@esbuild/openbsd-arm64@0.28.1': + optional: true + '@esbuild/openbsd-arm64@0.28.2': optional: true + '@esbuild/openbsd-x64@0.28.1': + optional: true + '@esbuild/openbsd-x64@0.28.2': optional: true + '@esbuild/openharmony-arm64@0.28.1': + optional: true + '@esbuild/openharmony-arm64@0.28.2': optional: true + '@esbuild/sunos-x64@0.28.1': + optional: true + '@esbuild/sunos-x64@0.28.2': optional: true + '@esbuild/win32-arm64@0.28.1': + optional: true + '@esbuild/win32-arm64@0.28.2': optional: true + '@esbuild/win32-ia32@0.28.1': + optional: true + '@esbuild/win32-ia32@0.28.2': optional: true + '@esbuild/win32-x64@0.28.1': + optional: true + '@esbuild/win32-x64@0.28.2': optional: true @@ -2019,6 +2889,116 @@ snapshots: protobufjs: 7.6.6 yargs: 17.7.3 + '@hono/node-server@1.19.17(hono@4.13.7)': + dependencies: + hono: 4.13.7 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.1 + optional: true + + '@img/sharp-darwin-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.1 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.1': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.1': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.1': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.1': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.1': + optional: true + + '@img/sharp-linux-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.1 + optional: true + + '@img/sharp-linux-arm@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.1 + optional: true + + '@img/sharp-linux-ppc64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.1 + optional: true + + '@img/sharp-linux-riscv64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.1 + optional: true + + '@img/sharp-linux-s390x@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.1 + optional: true + + '@img/sharp-linux-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.2': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + optional: true + + '@img/sharp-wasm32@0.35.2': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.2': + dependencies: + '@img/sharp-wasm32': 0.35.2 + optional: true + + '@img/sharp-win32-arm64@0.35.2': + optional: true + + '@img/sharp-win32-ia32@0.35.2': + optional: true + + '@img/sharp-win32-x64@0.35.2': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -2028,8 +3008,15 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/sourcemap-codec@1.6.0': {} + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + '@js-sdsl/ordered-map@4.4.2': {} '@kwsites/file-exists@1.1.1': @@ -2040,8 +3027,14 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@noble/ciphers@2.4.0': {} + '@noble/hashes@1.8.0': {} + '@noble/hashes@2.4.0': {} + + '@opentelemetry/semantic-conventions@1.43.0': {} + '@oxc-project/types@0.148.0': {} '@paralleldrive/cuid2@2.3.1': @@ -2051,6 +3044,18 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -2118,6 +3123,10 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + '@standard-schema/spec@1.1.0': {} '@types/chai@5.2.3': @@ -2325,12 +3334,49 @@ snapshots: dependencies: tweetnacl: 0.14.5 + better-auth@1.7.3(pg@8.23.0)(vitest@4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))): + dependencies: + '@better-auth/core': 1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3) + '@better-auth/drizzle-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/kysely-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(kysely@0.29.5) + '@better-auth/memory-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.7.3(@better-auth/core@1.7.3(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.4.0(zod@4.5.4))(jose@6.2.12)(kysely@0.29.5)(nanostores@1.5.3))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.4.0 + '@noble/hashes': 2.4.0 + better-call: 1.4.0(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.12 + kysely: 0.29.5 + nanostores: 1.5.3 + zod: 4.5.4 + optionalDependencies: + pg: 8.23.0 + vitest: 4.1.11(@types/node@24.13.3)(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-call@1.4.0(zod@4.5.4): + dependencies: + '@better-auth/utils': 0.5.0 + '@better-fetch/fetch': 1.3.1 + rou3: 0.9.2 + set-cookie-parser: 3.1.2 + optionalDependencies: + zod: 4.5.4 + bl@4.1.0: dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 + blake3-wasm@2.1.5: {} + boolbase@1.0.0: {} brace-expansion@1.1.18: @@ -2461,6 +3507,8 @@ snapshots: cookie-signature@1.2.2: {} + cookie@1.1.1: {} + cookiejar@2.1.4: {} core-util-is@1.0.3: {} @@ -2507,6 +3555,8 @@ snapshots: dependencies: ms: 2.1.3 + defu@6.1.7: {} + delayed-stream@1.0.0: {} detect-libc@2.1.2: {} @@ -2603,6 +3653,8 @@ snapshots: dependencies: is-arrayish: 0.2.1 + error-stack-parser-es@1.0.5: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -2620,6 +3672,35 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -2783,6 +3864,8 @@ snapshots: dependencies: function-bind: 1.1.2 + hono@4.13.7: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -2844,6 +3927,8 @@ snapshots: json-parse-even-better-errors@2.3.1: {} + kleur@4.1.5: {} + kysely-codegen@0.19.0(kysely@0.29.5)(pg@8.23.0)(typescript@5.9.3): dependencies: chalk: 4.1.2 @@ -2951,6 +4036,18 @@ snapshots: mime@2.6.0: {} + miniflare@5.20260908.0-alpha: + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.2 + undici: 7.29.0 + workerd: 1.20260908.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + minimatch@10.2.6: dependencies: brace-expansion: 5.0.9 @@ -2982,6 +4079,8 @@ snapshots: nanoid@3.3.18: {} + nanostores@1.5.3: {} + node-pg-migrate@9.0.0(@types/pg@8.23.1)(pg@8.23.0): dependencies: glob: 13.0.6 @@ -3054,6 +4153,8 @@ snapshots: lru-cache: 11.5.2 minipass: 7.1.3 + path-to-regexp@6.3.0: {} + pathe@2.0.3: {} pg-cloudflare@1.4.0: @@ -3224,12 +4325,50 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.2.7 '@rolldown/binding-win32-x64-msvc': 1.2.7 + rou3@0.9.2: {} + safe-buffer@5.1.2: {} safe-buffer@5.2.1: {} safer-buffer@2.1.2: {} + semver@7.8.5: {} + + set-cookie-parser@3.1.2: {} + + sharp@0.35.2: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.2 + '@img/sharp-darwin-x64': 0.35.2 + '@img/sharp-freebsd-wasm32': 0.35.2 + '@img/sharp-libvips-darwin-arm64': 1.3.1 + '@img/sharp-libvips-darwin-x64': 1.3.1 + '@img/sharp-libvips-linux-arm': 1.3.1 + '@img/sharp-libvips-linux-arm64': 1.3.1 + '@img/sharp-libvips-linux-ppc64': 1.3.1 + '@img/sharp-libvips-linux-riscv64': 1.3.1 + '@img/sharp-libvips-linux-s390x': 1.3.1 + '@img/sharp-libvips-linux-x64': 1.3.1 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.1 + '@img/sharp-libvips-linuxmusl-x64': 1.3.1 + '@img/sharp-linux-arm': 0.35.2 + '@img/sharp-linux-arm64': 0.35.2 + '@img/sharp-linux-ppc64': 0.35.2 + '@img/sharp-linux-riscv64': 0.35.2 + '@img/sharp-linux-s390x': 0.35.2 + '@img/sharp-linux-x64': 0.35.2 + '@img/sharp-linuxmusl-arm64': 0.35.2 + '@img/sharp-linuxmusl-x64': 0.35.2 + '@img/sharp-webcontainers-wasm32': 0.35.2 + '@img/sharp-win32-arm64': 0.35.2 + '@img/sharp-win32-ia32': 0.35.2 + '@img/sharp-win32-x64': 0.35.2 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3370,6 +4509,8 @@ snapshots: transitivePeerDependencies: - supports-color + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -3471,6 +4612,9 @@ snapshots: dependencies: is-number: 7.0.0 + tslib@2.8.1: + optional: true + tsx@4.23.13: dependencies: esbuild: 0.28.2 @@ -3489,10 +4633,16 @@ snapshots: undici-types@7.18.2: {} + undici@7.29.0: {} + undici@7.29.1: {} undici@8.10.2: {} + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + util-deprecate@1.0.2: {} vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0): @@ -3552,6 +4702,30 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + workerd@1.20260908.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20260908.1 + '@cloudflare/workerd-darwin-arm64': 1.20260908.1 + '@cloudflare/workerd-linux-64': 1.20260908.1 + '@cloudflare/workerd-linux-arm64': 1.20260908.1 + '@cloudflare/workerd-windows-64': 1.20260908.1 + + wrangler@4.130.0: + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.1(unenv@2.0.0-rc.24)(workerd@1.20260908.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.1 + miniflare: 5.20260908.0-alpha + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20260908.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -3572,6 +4746,8 @@ snapshots: wrappy@1.0.2: {} + ws@8.21.0: {} + xtend@4.0.2: {} y18n@5.0.8: {} @@ -3601,6 +4777,19 @@ snapshots: y18n: 5.0.8 yargs-parser: 22.0.0 + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4a59780..c154da8 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,3 +3,4 @@ packages: - examples/* onlyBuiltDependencies: - esbuild + - workerd diff --git a/scripts/verify-packages.ts b/scripts/verify-packages.ts index 3ffdd9c..cc22f2b 100644 --- a/scripts/verify-packages.ts +++ b/scripts/verify-packages.ts @@ -44,6 +44,9 @@ await writeFile( import { allocateDatabase, connectDatabase, readMigrations, migrate } from 'pgstencil/database'; import { Auth, type AuthDB } from '@pgstencil/auth'; import { authMigrations } from '@pgstencil/auth/migrations'; +import { betterAuthMigrations } from '@pgstencil/auth/better-auth-migrations'; +import { createAuthApp } from '@pgstencil/auth/better-auth'; +import { deterministicScope } from '@pgstencil/auth/better-auth-testing'; import { createAuthHttp } from '@pgstencil/auth/http'; import { Billing, type BillingDB } from '@pgstencil/stripe'; import { billingMigrations } from '@pgstencil/stripe/migrations'; @@ -59,7 +62,7 @@ for (const name of ['pgstencil', '@pgstencil/auth', '@pgstencil/stripe']) { assert.ok(readFileSync(new URL('../LICENSE', entry), 'utf8').startsWith('MIT License')); } assert.ok(readFileSync(new URL('compose.yaml', import.meta.resolve('pgstencil/database')), 'utf8').includes('integresql')); -const lease = await allocateDatabase([authMigrations, billingMigrations]); +const lease = await allocateDatabase([authMigrations, billingMigrations, betterAuthMigrations]); const db = connectDatabase(lease.url); const billingDb = connectDatabase(lease.url); const time = new DevTime(); const random = new DevRandom(); const email = new EmailDev(time); @@ -78,8 +81,22 @@ try { for (const event of dev.events) { const signed = dev.signed(event); await billing.webhook(signed.body, signed.signature); } assert.equal((await billing.status(session.user_id)).access, true); assert.equal(typeof createAuthHttp, 'function'); - await migrate(lease.url, await readMigrations([authMigrations, billingMigrations])); - console.log('Packed imports, declarations, Compose/SQL assets, email login and card-required trial passed.'); + await migrate(lease.url, await readMigrations([authMigrations, billingMigrations, betterAuthMigrations])); + const modern = createAuthApp({databaseUrl:lease.url,origin:'https://consumer.test',secret:'packed-consumer-secret-at-least-32',email,sessionPolicy:'single'}); + try { + const csrfResponse = await modern.app.fetch(new Request('https://consumer.test/api/auth/csrf')); + const csrf = (await csrfResponse.json()).csrf; + const cookie = csrfResponse.headers.getSetCookie().map((v) => v.split(';')[0]).join('; '); + const post = (path: string, body: object) => modern.app.fetch(new Request('https://consumer.test/api/auth/' + path, {method:'POST',headers:{origin:'https://consumer.test',cookie,'x-csrf-token':csrf,'content-type':'application/json','x-pgstencil-client-ip':'127.0.0.1'},body:JSON.stringify(body)})); + assert.equal((await post('email-otp/send-verification-otp',{email:'modern@example.test',type:'sign-in'})).status,200); + const otp = (await email.next()).text.match(/\\b\\d{8}\\b/)![0]; + const signedIn = await post('sign-in/email-otp',{email:'modern@example.test',otp}); + assert.equal(signedIn.status,200); + assert.ok(signedIn.headers.getSetCookie()[0].startsWith('__Host-pgstencil.session_token=')); + assert.equal((await signedIn.json()).token,undefined); + assert.equal(typeof deterministicScope.run,'function'); + } finally { await modern.close(); } + console.log('Packed imports, declarations, SQL assets, legacy/Better Auth login and card-required trial passed.'); } finally { await db.destroy(); await billingDb.destroy(); await dev.close(); await lease.close(); email.close(); } `, ); diff --git a/tests/integration/auth-http.test.ts b/tests/integration/auth-http.test.ts index 428ee31..3ecabf5 100644 --- a/tests/integration/auth-http.test.ts +++ b/tests/integration/auth-http.test.ts @@ -16,7 +16,10 @@ import { import { connectDatabase } from '../../packages/pgstencil/src/postgres.ts'; import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; import { cookies, codeFrom } from './helpers.ts'; -import { mockOAuthServer, oauthCredentials } from '../support/oauth-server.ts'; +import { + mockOAuthServer, + allOAuthCredentials, +} from '../support/oauth-server.ts'; async function fixture() { const context = await createTestContext(); @@ -38,7 +41,7 @@ async function fixture() { auth, oauth: new OAuth( auth, - new OAuthProviders(oauthCredentials, provider.transport), + new OAuthProviders(allOAuthCredentials, provider.transport), ), secure: false, }); @@ -160,9 +163,9 @@ httpTest( ); httpTest( - 'JSON OAuth start and native callback preserve browser binding for Google and GitHub', + 'JSON OAuth start and native callback preserve browser binding for all providers', async ({ f }) => { - for (const provider of ['google', 'github'] as const) { + for (const provider of ['google', 'github', 'apple', 'facebook'] as const) { const state = await request(f.origin) .get('/api/auth/session') .expect(200); @@ -176,6 +179,17 @@ httpTest( email: `${provider}@example.test`, }, ); + if (provider === 'apple') { + const relay = await request(f.origin) + .post(callback.pathname) + .type('form') + .send(callback.searchParams.toString()) + .expect(303); + expect(relay.headers.location).toBe( + callback.pathname + callback.search, + ); + expect(relay.headers['set-cookie']).toBeUndefined(); + } const unbound = await request(f.origin) .get(callback.pathname + callback.search) .expect(303); diff --git a/tests/integration/better-auth-oauth.test.ts b/tests/integration/better-auth-oauth.test.ts new file mode 100644 index 0000000..27993bf --- /dev/null +++ b/tests/integration/better-auth-oauth.test.ts @@ -0,0 +1,480 @@ +import { test, expect } from 'vitest'; +import { build } from 'esbuild'; +import { builtinModules } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; +import { queryDatabase } from '../../packages/pgstencil/src/postgres.ts'; +import { + mockOAuthServer, + endpointPaths, + allOAuthCredentials, + type GrantOptions, +} from '../support/oauth-server.ts'; +import type { createDeterministicApp } from '../support/better-auth-entry.ts'; +import { + providers, + type Provider, +} from '../../examples/better-auth/src/oauth.ts'; + +const origin = 'https://auth.example.test'; +const built = (async () => { + const result = await build({ + entryPoints: ['tests/support/better-auth-entry.ts'], + bundle: true, + write: false, + platform: 'node', + format: 'esm', + external: [...builtinModules, 'node:*', 'pg-native'], + inject: [resolve('packages/auth/src/better-auth-testing.ts')], + banner: { + js: "import {createRequire} from 'node:module'; const require = createRequire(import.meta.url);", + }, + }); + await mkdir('.build', { recursive: true }); + const path = resolve('.build/better-auth-oauth-test.mjs'); + await writeFile(path, result.outputFiles[0]!.text); + return (await import(pathToFileURL(path).href)) as { + createDeterministicApp: typeof createDeterministicApp; + }; +})(); +async function fixture(policy: 'single' | 'multiple' = 'multiple') { + const context = await createTestContext({ + migrations: resolve('packages/auth/better-auth-migrations'), + seed: 'better-auth-oauth', + }); + const provider = await mockOAuthServer({ + betterAuth: true, + now: () => context.time.now(), + }); + const destinations: string[] = []; + const outboundFetch: typeof fetch = async (input, init) => { + const request = new Request(input, init); + const url = new URL(request.url); + const path = endpointPaths[url.origin + url.pathname]; + if (!path) + throw new Error(`Unexpected provider URL: ${url.origin}${url.pathname}`); + destinations.push(url.origin + url.pathname); + return fetch(new Request(provider.origin + path + url.search, request)); + }; + const app = (await built).createDeterministicApp({ + ...context, + databaseUrl: context.database.url, + origin, + secret: 'better-auth-local-oauth-secret-32-characters', + sessionPolicy: policy, + oauth: allOAuthCredentials, + outboundFetch, + }); + const request = (path: string, init?: RequestInit) => + app.fetch(new Request(origin + path, init)); + let ip = 0; + const browser = async () => { + const csrfResponse = await request('/api/auth/csrf'); + const csrf = ((await csrfResponse.json()) as { csrf: string }).csrf; + const jar = new Map(); + const accept = (response: Response) => { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(';'); + const pos = pair!.indexOf('='); + jar.set(pair!.slice(0, pos), pair!.slice(pos + 1)); + } + return response; + }; + accept(csrfResponse); + const cookie = () => + [...jar].map(([key, value]) => `${key}=${value}`).join('; '); + const address = `192.0.2.${++ip}`; + return { + jar, + cookie, + post: async (path: string, body: object) => + accept( + await request('/api/auth/' + path, { + method: 'POST', + headers: { + origin, + cookie: cookie(), + 'x-csrf-token': csrf, + 'content-type': 'application/json', + 'x-pgstencil-client-ip': address, + }, + body: JSON.stringify(body), + }), + ), + get: async (path: string) => + accept(await request(path, { headers: { cookie: cookie() } })), + follow: async (callback: URL) => + accept( + await request(callback.pathname + callback.search, { + headers: { cookie: cookie() }, + }), + ), + }; + }; + return { + ...context, + app, + provider, + destinations, + browser, + request, + async close() { + await app.close(); + await provider.close(); + await context.close(); + }, + }; +} +type Fixture = Awaited>; +type Browser = Awaited>; +async function start(browser: Browser, provider: Provider, link = false) { + const response = await browser.post(link ? 'link-social' : 'sign-in/social', { + provider, + }); + expect(response.status, await response.clone().text()).toBe(200); + return new URL(((await response.json()) as { url: string }).url); +} +async function login( + f: Fixture, + browser: Browser, + provider: Provider, + options: GrantOptions = {}, + link = false, +) { + const authorization = await start(browser, provider, link); + const callback = f.provider.authorize(provider, authorization, options); + return { authorization, callback, response: await browser.follow(callback) }; +} +async function session(browser: Browser) { + return (await (await browser.get('/api/auth/get-session')).json()) as { + user: { id: string; email: string }; + session: { id: string }; + } | null; +} + +for (const provider of providers) + test(`Better Auth OAuth: ${provider} login, safe token storage and callback replay`, async ({ + onTestFinished, + }) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const browser = await f.browser(); + const { response, callback, authorization } = await login( + f, + browser, + provider, + ); + expect(response.status, await response.clone().text()).toBe(302); + expect(response.headers.get('location')).toBe(origin + '/'); + expect((await session(browser))?.user.email).toBe('oauth@example.test'); + if (provider === 'google' || provider === 'github') + expect(authorization.searchParams.get('code_challenge_method')).toBe( + 'S256', + ); + if (provider === 'google' || provider === 'apple') + expect(authorization.searchParams.get('nonce')).toBeTruthy(); + expect((await browser.follow(callback)).headers.get('location')).toContain( + 'error=oauth_failed', + ); + const rows = await queryDatabase( + f.database.url, + 'SELECT "providerId", "accessToken", "refreshToken", "idToken" FROM account', + ); + expect(rows).toEqual([ + { + providerId: provider, + accessToken: null, + refreshToken: null, + idToken: null, + }, + ]); + expect(f.destinations.length).toBeGreaterThan(0); + }); + +for (const provider of ['google', 'apple'] as const) + test(`Better Auth OAuth: ${provider} rejects invalid signed claims`, async ({ + onTestFinished, + }) => { + const f = await fixture(); + onTestFinished(() => f.close()); + for (const options of [ + { badSignature: true }, + { verified: false }, + { missingIdToken: true }, + { claims: { aud: 'wrong-client' } }, + { claims: { iss: 'https://attacker.test' } }, + { claims: { exp: 1 } }, + { claims: { nonce: 'wrong' } }, + ] satisfies GrantOptions[]) { + const browser = await f.browser(); + const { response } = await login(f, browser, provider, options); + expect(response.headers.get('location')).toContain('error=oauth_failed'); + expect(await session(browser)).toBeNull(); + } + }); + +test('Better Auth OAuth: Apple form_post relay, wrong browser, mismatched provider and expired state', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const browser = await f.browser(), + stranger = await f.browser(); + const authorization = await start(browser, 'apple'); + const callback = f.provider.authorize('apple', authorization); + const relay = await f.request('/api/auth/callback/apple', { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: callback.searchParams, + }); + expect(relay.headers.get('referrer-policy')).toBe('no-referrer'); + expect(relay.headers.get('cache-control')).toBe('no-store'); + expect(relay.headers.get('content-security-policy')).toContain( + "frame-ancestors 'none'", + ); + const relayed = new URL(relay.headers.get('location')!); + expect(relayed.origin + relayed.pathname).toBe( + callback.origin + callback.pathname, + ); + expect(Object.fromEntries(relayed.searchParams)).toEqual( + Object.fromEntries(callback.searchParams), + ); + expect((await stranger.follow(callback)).headers.get('location')).toContain( + 'error=oauth_failed', + ); + const wrong = new URL(callback); + wrong.pathname = '/api/auth/callback/google'; + expect((await browser.follow(wrong)).headers.get('location')).toContain( + 'error=oauth_failed', + ); + expect((await browser.follow(callback)).headers.get('location')).toBe( + origin + '/', + ); + const another = await f.browser(); + const pending = f.provider.authorize( + 'google', + await start(another, 'google'), + ); + f.time.advanceMilliseconds(600_000); + expect((await another.follow(pending)).headers.get('location')).toContain( + 'error=oauth_failed', + ); +}); + +test('Better Auth OAuth: email collision requires explicit linking; linking binds to live session', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const browser = await f.browser(); + await browser.post('email-otp/send-verification-otp', { + email: 'oauth@example.test', + type: 'sign-in', + }); + const otp = (await f.email.next()).text.match(/\b\d{8}\b/)![0]; + expect( + ( + await browser.post('sign-in/email-otp', { + email: 'oauth@example.test', + otp, + }) + ).status, + ).toBe(200); + const userId = (await session(browser))!.user.id; + const other = await f.browser(); + expect( + (await login(f, other, 'google')).response.headers.get('location'), + ).toContain('error=oauth_failed'); + expect(await session(other)).toBeNull(); + expect( + (await login(f, browser, 'google', {}, true)).response.headers.get( + 'location', + ), + ).toBe(origin + '/'); + expect( + (await login(f, other, 'google')).response.headers.get('location'), + ).toBe(origin + '/'); + expect((await session(other))!.user.id).toBe(userId); + // Explicit linking also supports Apple's relay address, which differs from the account email. + expect( + ( + await login( + f, + browser, + 'apple', + { email: 'relay@privaterelay.appleid.com' }, + true, + ) + ).response.headers.get('location'), + ).toBe(origin + '/'); + const callback = f.provider.authorize( + 'github', + await start(browser, 'github', true), + ); + await browser.post('sign-out', {}); + expect((await browser.follow(callback)).headers.get('location')).toContain( + 'error=oauth_failed', + ); + const accounts = await queryDatabase( + f.database.url, + 'SELECT "providerId" FROM account ORDER BY "providerId"', + ); + expect(accounts).toEqual([{ providerId: 'apple' }, { providerId: 'google' }]); +}); + +for (const policy of ['single', 'multiple'] as const) + test(`Better Auth OAuth: concurrent ${policy} device logins`, async ({ + onTestFinished, + }) => { + const f = await fixture(policy); + onTestFinished(() => f.close()); + const initial = await f.browser(); + await login(f, initial, 'google'); + const browsers = await Promise.all([f.browser(), f.browser()]); + const pending = await Promise.all( + browsers.map(async (browser) => + f.provider.authorize('google', await start(browser, 'google')), + ), + ); + const responses = await Promise.all( + browsers.map((browser, i) => browser.follow(pending[i]!)), + ); + expect(responses.map((r) => r.headers.get('location'))).toEqual([ + origin + '/', + origin + '/', + ]); + const sessions = await Promise.all([initial, ...browsers].map(session)); + expect(sessions.filter(Boolean)).toHaveLength(policy === 'single' ? 1 : 3); + }); + +test('Better Auth OAuth: concurrent callbacks exchange once and errors contain no provider details', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const browser = await f.browser(); + const callback = f.provider.authorize( + 'google', + await start(browser, 'google'), + ); + const cookie = browser.cookie(); + const responses = await Promise.all( + Array.from({ length: 6 }, () => + f.request(callback.pathname + callback.search, { headers: { cookie } }), + ), + ); + expect( + responses.filter( + (response) => response.headers.get('location') === origin + '/', + ), + ).toHaveLength(1); + expect( + f.destinations.filter( + (url) => url === 'https://oauth2.googleapis.com/token', + ), + ).toHaveLength(1); + const other = await f.browser(); + const failed = await login(f, other, 'google', { tokenFailure: true }); + expect(failed.response.headers.get('location')).toBe( + origin + '/?error=oauth_failed', + ); + expect(await failed.response.text()).not.toContain('synthetic-secret'); + const cancelled = f.provider.authorize( + 'google', + await start(other, 'google'), + ); + cancelled.searchParams.set('error', 'access_denied'); + cancelled.searchParams.set( + 'error_description', + 'synthetic-secret-never-render-this', + ); + expect((await other.follow(cancelled)).headers.get('location')).toBe( + origin + '/?error=oauth_failed', + ); +}); + +test('Better Auth OAuth: missing/unverified email is rejected and provider identities cannot be stolen by linking', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + for (const [provider, options] of [ + ['github', { verified: false }], + ['facebook', { email: '' }], + ] as const) { + const browser = await f.browser(); + expect( + (await login(f, browser, provider, options)).response.headers.get( + 'location', + ), + ).toContain('error=oauth_failed'); + expect(await session(browser)).toBeNull(); + } + const owner = await f.browser(); + await login(f, owner, 'google'); + const other = await f.browser(); + await login(f, other, 'github', { email: 'other@example.test' }); + expect( + (await login(f, other, 'google', {}, true)).response.headers.get( + 'location', + ), + ).toContain('error=oauth_failed'); + expect((await session(other))!.user.email).toBe('other@example.test'); + f.time.advanceMilliseconds(600_000); + expect((await owner.post('link-social', { provider: 'apple' })).status).toBe( + 401, + ); +}); + +test('Better Auth OAuth: caller cannot override callback origin or use direct provider tokens', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const browser = await f.browser(); + expect( + ( + await browser.post('sign-in/social', { + provider: 'google', + idToken: { token: 'untrusted' }, + }) + ).status, + ).toBe(400); + const result = await browser.post('sign-in/social', { + provider: 'google', + callbackURL: 'https://attacker.test/', + additionalData: { pgstencilProvider: 'apple' }, + }); + expect(result.status).toBe(200); + const authorization = new URL(((await result.json()) as { url: string }).url); + const callback = f.provider.authorize('google', authorization); + expect((await browser.follow(callback)).headers.get('location')).toBe( + origin + '/', + ); +}); + +test('Better Auth OAuth: parallel applications reproduce cookies and session timestamps', async ({ + onTestFinished, +}) => { + const a = await fixture(), + b = await fixture(); + onTestFinished(() => a.close()); + onTestFinished(() => b.close()); + const browsers = await Promise.all([a.browser(), b.browser()]); + const results = await Promise.all([ + login(a, browsers[0]!, 'google'), + login(b, browsers[1]!, 'google'), + ]); + expect(results[0]!.authorization.href).toBe(results[1]!.authorization.href); + expect(results[0]!.response.headers.getSetCookie()).toEqual( + results[1]!.response.headers.getSetCookie(), + ); + const rows = await Promise.all( + [a, b].map((f) => + queryDatabase(f.database.url, 'SELECT * FROM session ORDER BY id'), + ), + ); + expect(rows[0]).toEqual(rows[1]); +}); diff --git a/tests/integration/better-auth-workers.test.ts b/tests/integration/better-auth-workers.test.ts new file mode 100644 index 0000000..0b79348 --- /dev/null +++ b/tests/integration/better-auth-workers.test.ts @@ -0,0 +1,299 @@ +import { test, expect } from 'vitest'; +import { build } from 'esbuild'; +import { builtinModules } from 'node:module'; +import { resolve } from 'node:path'; +import { + Miniflare, + convertV4MiniflareOptions, + Response as WorkerResponse, +} from 'miniflare'; +import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; +import type { EmailMessage } from '../../packages/pgstencil/src/email.ts'; +import { queryDatabase } from '../../packages/pgstencil/src/postgres.ts'; +import { + mockOAuthServer, + endpointPaths, + allOAuthCredentials, +} from '../support/oauth-server.ts'; +import { providers } from '../../examples/better-auth/src/oauth.ts'; + +const origin = 'https://better-auth.example.test'; +const bundles = [false, true].map((deterministic) => + build({ + entryPoints: [ + deterministic + ? 'tests/support/better-auth-worker.ts' + : 'examples/better-auth/src/worker.ts', + ], + bundle: true, + write: false, + metafile: true, + format: 'esm', + platform: 'node', + conditions: ['workerd', 'worker'], + external: ['node:*', 'cloudflare:*', 'pg-native'], + alias: Object.fromEntries( + builtinModules + .filter((name) => !name.startsWith('node:')) + .map((name) => [name, `node:${name}`]), + ), + inject: deterministic + ? [resolve('packages/auth/src/better-auth-testing.ts')] + : [], + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire('/worker.js');", + }, + }), +); + +async function fixture(deterministic = true, oauth = false) { + const context = await createTestContext({ + migrations: resolve('packages/auth/better-auth-migrations'), + }); + const provider = oauth + ? await mockOAuthServer({ betterAuth: true, now: () => context.time.now() }) + : undefined; + const bundle = await bundles[deterministic ? 1 : 0]!; + const worker = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: bundle.outputFiles[0]!.text, + compatibilityDate: '2026-09-08', + compatibilityFlags: ['nodejs_compat'], + bindings: { + APP_ORIGIN: origin, + AUTH_SECRET: 'better-auth-local-test-secret-32-characters', + ...(oauth + ? Object.fromEntries( + Object.entries(allOAuthCredentials).flatMap(([key, value]) => [ + [key.toUpperCase() + '_CLIENT_ID', value.clientId], + [key.toUpperCase() + '_CLIENT_SECRET', value.clientSecret], + ]), + ) + : {}), + }, + hyperdrives: { HYPERDRIVE: context.database.url }, + serviceBindings: { + ...(provider + ? { + OAUTH_TEST: async (request: Request) => { + const url = new URL(request.url); + const path = endpointPaths[url.origin + url.pathname]; + if (!path) throw new Error('Unexpected OAuth test destination'); + const response = await fetch( + provider.origin + path + url.search, + { + method: request.method, + headers: request.headers, + ...(request.method === 'GET' + ? {} + : { body: await request.arrayBuffer() }), + }, + ); + const headers: Record = {}; + response.headers.forEach((value, key) => { + headers[key] = value; + }); + return new WorkerResponse(await response.arrayBuffer(), { + status: response.status, + headers, + }); + }, + } + : {}), + EMAIL: async (request) => { + await context.email.send((await request.json()) as EmailMessage); + return new WorkerResponse('ok'); + }, + }, + }), + ); + await worker.ready; + const csrfResponse = await worker.dispatchFetch(origin + '/api/auth/csrf'); + const csrf = ((await csrfResponse.json()) as { csrf: string }).csrf; + const csrfCookie = csrfResponse.headers + .getSetCookie() + .map((v) => v.split(';')[0]) + .join('; '); + const post = (path: string, body: object, cookie = '') => + worker.dispatchFetch(origin + '/api/auth/' + path, { + method: 'POST', + headers: { + origin, + 'cf-connecting-ip': '192.0.2.1', + 'content-type': 'application/json', + cookie: [csrfCookie, cookie].filter(Boolean).join('; '), + 'x-csrf-token': csrf, + }, + body: JSON.stringify(body), + }); + return { + ...context, + worker, + provider, + csrf, + csrfCookie, + post, + get: async (cookie: string) => + ( + await worker.dispatchFetch(origin + '/api/auth/get-session', { + headers: { cookie }, + }) + ).json() as Promise<{ + user: { email: string }; + session: { createdAt: string; expiresAt: string }; + } | null>, + setTime: (time: string) => + worker.dispatchFetch(origin + '/__test/time', { + method: 'POST', + body: time, + }), + async close() { + await worker.dispose(); + await provider?.close(); + await context.close(); + }, + }; +} +type Fixture = Awaited>; +async function login(f: Fixture, address = 'worker@example.test') { + expect( + ( + await f.post('email-otp/send-verification-otp', { + email: address, + type: 'sign-in', + }) + ).status, + ).toBe(200); + const email = await f.email.next(); + const otp = email.text.match(/\b\d{8}\b/)![0]; + const response = await f.post('sign-in/email-otp', { + email: address, + otp, + }); + expect(response.status, await response.clone().text()).toBe(200); + const cookies = response.headers.getSetCookie(); + return { + email, + cookies, + cookie: cookies.map((value) => value.split(';')[0]).join('; '), + }; +} + +test('Better Auth in workerd: deterministic replay, separate clocks, shared database rate limits', async ({ + onTestFinished, +}) => { + const a = await fixture(), + b = await fixture(); + onTestFinished(() => a.close()); + onTestFinished(() => b.close()); + const [first, second] = await Promise.all([login(a), login(b)]); + expect(first.email).toEqual(second.email); + expect(first.cookies).toEqual(second.cookies); + const [rowsA, rowsB] = await Promise.all( + [a, b].map((f) => + queryDatabase(f.database.url, 'SELECT * FROM "session" ORDER BY id'), + ), + ); + expect(rowsA).toEqual(rowsB); + await a.setTime('2020-01-01T23:00:00Z'); + expect((await a.get(first.cookie))?.user.email).toBe('worker@example.test'); + await a.setTime('2020-01-02T00:00:00.001Z'); + expect(await a.get(first.cookie)).toBeNull(); + expect((await b.get(second.cookie))?.user.email).toBe('worker@example.test'); + // Each request creates a new Better Auth instance. Rate limits must survive that. + const sends = []; + for (let i = 0; i < 4; i++) + sends.push( + ( + await a.post('email-otp/send-verification-otp', { + email: `limit-${i}@example.test`, + type: 'sign-in', + }) + ).status, + ); + expect(sends).toEqual([200, 200, 200, 429]); + const sendFrom = (ip: string, forwarded: string) => + a.worker.dispatchFetch( + origin + '/api/auth/email-otp/send-verification-otp', + { + method: 'POST', + headers: { + origin, + 'cf-connecting-ip': ip, + 'x-forwarded-for': forwarded, + cookie: a.csrfCookie, + 'x-csrf-token': a.csrf, + 'content-type': 'application/json', + }, + body: JSON.stringify({ email: `${ip}@example.test`, type: 'sign-in' }), + }, + ); + expect((await sendFrom('192.0.2.1', '192.0.2.99')).status).toBe(429); + expect((await sendFrom('192.0.2.2', '192.0.2.1')).status).toBe(200); + expect((await a.worker.dispatchFetch(origin + '/dev/emails')).status).toBe( + 404, + ); +}); + +test('normal Workers build uses real time and randomness and contains no test clock controls', async ({ + onTestFinished, +}) => { + const f = await fixture(false); + onTestFinished(() => f.close()); + const first = await login(f); + const session = await f.get(first.cookie); + expect( + Math.abs(Date.parse(session!.session.createdAt) - Date.now()), + ).toBeLessThan(10_000); + expect( + Date.parse(session!.session.expiresAt) - + Date.parse(session!.session.createdAt), + ).toBe(86_400_000); + const second = await login(f, 'second@example.test'); + expect(first.cookie).not.toBe(second.cookie); + expect((await f.setTime('2020-01-01')).status).toBe(404); + const inputs = Object.keys((await bundles[0]!).metafile!.inputs); + expect( + inputs.some( + (path) => + path.includes('scoped-globals') || + path.includes('better-auth-testing') || + path.includes('better-auth-worker.ts'), + ), + ).toBe(false); + expect((await f.post('sign-out', {}, second.cookie)).status).toBe(200); + expect(await f.get(second.cookie)).toBeNull(); +}); + +for (const provider of providers) + test(`Better Auth OAuth in workerd: ${provider}`, async ({ + onTestFinished, + }) => { + const f = await fixture(true, true); + onTestFinished(() => f.close()); + const started = await f.post('sign-in/social', { provider }); + expect(started.status, await started.clone().text()).toBe(200); + const authorization = new URL( + ((await started.json()) as { url: string }).url, + ); + const stateCookie = started.headers + .getSetCookie() + .map((v) => v.split(';')[0]) + .join('; '); + const callback = f.provider!.authorize(provider, authorization); + const complete = await f.worker.dispatchFetch(callback.href, { + headers: { cookie: stateCookie }, + redirect: 'manual', + }); + expect( + complete.headers.get('location'), + await complete.clone().text(), + ).toBe(origin + '/'); + const cookie = complete.headers + .getSetCookie() + .filter((v) => v.includes('.session_token=')) + .map((v) => v.split(';')[0]) + .join('; '); + expect((await f.get(cookie))?.user.email).toBe('oauth@example.test'); + }); diff --git a/tests/integration/better-auth.test.ts b/tests/integration/better-auth.test.ts new file mode 100644 index 0000000..1de2d35 --- /dev/null +++ b/tests/integration/better-auth.test.ts @@ -0,0 +1,455 @@ +import { test, expect } from 'vitest'; +import { build } from 'esbuild'; +import { builtinModules } from 'node:module'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import request from 'supertest'; +import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; +import { queryDatabase } from '../../packages/pgstencil/src/postgres.ts'; +import { + captureEmail, + captureResponse, + stableJson, +} from '../../packages/pgstencil/src/snapshots.ts'; +import { schemaChanges } from '../../examples/better-auth/src/schema.ts'; +import { listen } from '../../examples/better-auth/src/node.ts'; +import type { createDeterministicApp } from '../support/better-auth-entry.ts'; + +const origin = 'https://better-auth.example.test'; +const migrations = resolve('packages/auth/better-auth-migrations'); +const built = (async () => { + const result = await build({ + entryPoints: ['tests/support/better-auth-entry.ts'], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + packages: 'bundle', + external: [ + ...builtinModules, + ...builtinModules.map((name) => `node:${name}`), + 'pg-native', + ], + inject: [resolve('packages/auth/src/better-auth-testing.ts')], + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);", + }, + }); + await mkdir('.build', { recursive: true }); + const file = resolve('.build/better-auth-test.mjs'); + await writeFile(file, result.outputFiles[0]!.text); + return (await import(pathToFileURL(file).href)) as { + createDeterministicApp: typeof createDeterministicApp; + }; +})(); +async function fixture( + now = '2020-01-01T00:00:00.000Z', + sessionPolicy: 'single' | 'multiple' = 'multiple', +) { + const context = await createTestContext({ + migrations, + now, + seed: 'better-auth-email', + }); + const { createDeterministicApp } = await built; + const app = createDeterministicApp({ + ...context, + sessionPolicy, + databaseUrl: context.database.url, + origin, + secret: 'better-auth-local-test-secret-32-characters', + }); + const server = await listen(app.fetch); + const client = request(server.origin); + const csrfResponse = await client.get('/api/auth/csrf'); + const csrfCookie = cookieFrom(csrfResponse); + const csrf = csrfResponse.body.csrf as string; + const post = (path: string, body: object, cookie = '') => + client + .post('/api/auth/' + path) + .set('Origin', origin) + .set('Cookie', [csrfCookie, cookie].filter(Boolean).join('; ')) + .set('X-CSRF-Token', csrf) + .send(body); + const get = (cookie = '') => + client.get('/api/auth/get-session').set('Cookie', cookie); + return { + ...context, + app, + server, + client, + csrf, + csrfCookie, + post, + get, + async close() { + await server.close(); + await app.close(); + await context.close(); + }, + }; +} +type Fixture = Awaited>; +const cookieFrom = (response: request.Response) => + (response.headers['set-cookie'] as unknown as string[]) + .map((v) => v.split(';')[0]) + .join('; '); +async function login(f: Fixture) { + expect( + ( + await f.post('email-otp/send-verification-otp', { + email: 'alice@example.test', + type: 'sign-in', + }) + ).status, + ).toBe(200); + const email = await f.email.next(); + const otp = email.text.match(/\b\d{8}\b/)![0]; + const response = await f.post('sign-in/email-otp', { + email: 'alice@example.test', + otp, + }); + expect(response.status, response.text).toBe(200); + return { email, otp, response, cookie: cookieFrom(response) }; +} + +test('Better Auth email: repeatable cookies, database and email snapshots across parallel apps', async ({ + onTestFinished, +}) => { + const fixtures = await Promise.all([fixture(), fixture()]); + for (const f of fixtures) onTestFinished(() => f.close()); + expect(fixtures[0]!.server.origin).not.toBe(fixtures[1]!.server.origin); + const results = await Promise.all(fixtures.map(login)); + expect(results[0]!.email).toEqual(results[1]!.email); + expect(results[0]!.response.body).toEqual(results[1]!.response.body); + expect(results[0]!.response.headers['set-cookie']).toEqual( + results[1]!.response.headers['set-cookie'], + ); + const rows = await Promise.all( + fixtures.map((f) => + queryDatabase(f.database.url, 'SELECT * FROM "session" ORDER BY id'), + ), + ); + expect(rows[0]).toEqual(rows[1]); + await expect(stableJson(rows[0])).toMatchFileSnapshot( + './snapshots/better-auth-session.json', + ); + await expect(captureEmail(results[0]!.email, origin)).toMatchFileSnapshot( + './snapshots/better-auth-email.md', + ); + await expect( + stableJson(results[0]!.response.headers['set-cookie']), + ).toMatchFileSnapshot('./snapshots/better-auth-cookies.json'); + const page = captureResponse(await fixtures[0]!.client.get('/'), origin); + await expect(page.html).toMatchFileSnapshot( + './snapshots/better-auth-login.html', + ); + await expect(page.markdown).toMatchFileSnapshot( + './snapshots/better-auth-login.md', + ); + expect( + ( + await fixtures[0]!.post('sign-in/email-otp', { + email: 'alice@example.test', + otp: results[0]!.otp, + }) + ).status, + ).not.toBe(200); + const plan = await schemaChanges(fixtures[0]!.database.url); + expect(plan.toBeCreated).toEqual([]); + expect(plan.toBeAdded).toEqual([]); + expect(plan.toBeAddedIndexes).toEqual([]); +}); + +test('Better Auth time: 23 hours, expiration boundary, isolated async contexts and unchanged host clock', async ({ + onTestFinished, +}) => { + const nativeDate = globalThis.Date; + const nativeCrypto = globalThis.crypto; + const a = await fixture(), + b = await fixture('2030-06-01T00:00:00Z'); + onTestFinished(() => a.close()); + onTestFinished(() => b.close()); + const [alice, other] = await Promise.all([login(a), login(b)]); + a.time.advanceHours(23); + expect((await a.get(alice.cookie)).body.user.email).toBe( + 'alice@example.test', + ); + const [one, two] = await Promise.all([a.app.probe(), b.app.probe()]); + expect(one.before).toBe(a.time.now().getTime()); + expect(one.after).toBe(one.before); + expect(two.before).toBe(b.time.now().getTime()); + expect(two.after).toBe(two.before); + a.time.advanceHours(1); + // Better Auth 1.7.3 compares expiresAt < now: equality remains valid for one millisecond. + expect((await a.get(alice.cookie)).body.user.email).toBe( + 'alice@example.test', + ); + a.time.advanceMilliseconds(1); + expect((await a.get(alice.cookie)).body).toBeNull(); + expect((await b.get(other.cookie)).body.user.email).toBe( + 'alice@example.test', + ); + expect(globalThis.Date).toBe(nativeDate); + expect(globalThis.crypto).toBe(nativeCrypto); + expect(Date.now()).toBeGreaterThan(Date.parse('2025-01-01')); +}); + +test('Better Auth email rejects expired codes and cross-origin sign-in', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const send = await f.post('email-otp/send-verification-otp', { + email: 'alice@example.test', + type: 'sign-in', + }); + expect(send.status).toBe(200); + const otp = (await f.email.next()).text.match(/\b\d{8}\b/)![0]; + f.time.advanceMilliseconds(600_001); + expect( + (await f.post('sign-in/email-otp', { email: 'alice@example.test', otp })) + .status, + ).not.toBe(200); + const response = await f.client + .post('/api/auth/email-otp/send-verification-otp') + .set('Origin', 'https://attacker.test') + .set('Cookie', 'browser=test') + .send({ email: 'alice@example.test', type: 'sign-in' }); + expect(response.status).toBe(403); + expect(f.email.all()).toHaveLength(1); +}); + +async function directPost( + f: Fixture, + path: string, + body: object, + ip = '192.0.2.1', + cookie = '', +) { + return f.app.fetch( + new Request(origin + '/api/auth/' + path, { + method: 'POST', + headers: { + origin, + 'content-type': 'application/json', + 'x-csrf-token': f.csrf, + 'x-pgstencil-client-ip': ip, + cookie: [f.csrfCookie, cookie].filter(Boolean).join('; '), + }, + body: JSON.stringify(body), + }), + ); +} + +test('email policy: secret-keyed codes, cross-browser redemption, concurrent single use and no token exposure', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + await f + .post('email-otp/send-verification-otp', { + email: 'alice@example.test', + type: 'sign-in', + }) + .expect(200); + const otp = (await f.email.next()).text.match(/\b\d{8}\b/)![0]; + const { createHmac, createHash } = await import('node:crypto'); + const [record] = await queryDatabase<{ value: string }>( + f.database.url, + 'SELECT value FROM verification', + ); + const expected = createHmac( + 'sha256', + 'better-auth-local-test-secret-32-characters', + ) + .update('email-otp\0') + .update(otp) + .digest('hex'); + expect(record!.value).toBe(expected + ':0'); + expect(record!.value).not.toContain( + createHash('sha256').update(otp).digest('base64url'), + ); + // Another browser obtains its own CSRF token; it never receives the sending browser's cookies. + const other = await f.client.get('/api/auth/csrf'); + const responses = await Promise.all( + Array.from({ length: 6 }, (_, i) => + f.app.fetch( + new Request(origin + '/api/auth/sign-in/email-otp', { + method: 'POST', + headers: { + origin, + 'content-type': 'application/json', + 'x-csrf-token': other.body.csrf, + cookie: cookieFrom(other), + 'x-pgstencil-client-ip': `192.0.2.${i + 1}`, + }, + body: JSON.stringify({ email: 'alice@example.test', otp }), + }), + ), + ), + ); + expect(responses.filter((r) => r.status === 200)).toHaveLength(1); + const success = responses.find((r) => r.status === 200)!; + expect(await success.json()).not.toHaveProperty('token'); + const cookie = success.headers + .getSetCookie() + .map((v) => v.split(';')[0]) + .join('; '); + const session = await f.get(cookie); + expect(session.body.session).not.toHaveProperty('token'); + const [row] = await queryDatabase<{ token: string }>( + f.database.url, + 'SELECT token FROM session', + ); + // DB tokens are not sufficient: a valid server signature is required on cookies. + expect( + (await f.get(`__Host-pgstencil.session_token=${row!.token}`)).body, + ).toBeNull(); + expect(success.headers.getSetCookie()[0]).toMatch( + /^__Host-pgstencil\.session_token=/, + ); + expect(success.headers.getSetCookie()[0]).not.toContain('Domain='); +}); + +test('email policy: distributed IPs cannot bypass cooldown, send quota or attempt budget', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const body = { email: 'limited@example.test', type: 'sign-in' }; + const burst = await Promise.all( + Array.from({ length: 8 }, (_, i) => + directPost( + f, + 'email-otp/send-verification-otp', + body, + `192.0.2.${i + 1}`, + ), + ), + ); + expect(burst.filter((r) => r.status === 200)).toHaveLength(1); + const otp = (await f.email.next()).text.match(/\b\d{8}\b/)![0]; + for (let i = 0; i < 3; i++) + expect( + ( + await directPost( + f, + 'sign-in/email-otp', + { email: body.email, otp: 'wrong-code' }, + `198.51.100.${i + 1}`, + ) + ).status, + ).not.toBe(200); + expect( + ( + await directPost( + f, + 'sign-in/email-otp', + { email: body.email, otp }, + '198.51.100.9', + ) + ).status, + ).not.toBe(200); + for (let i = 0; i < 4; i++) { + f.time.advanceMilliseconds(60_000); + expect( + ( + await directPost( + f, + 'email-otp/send-verification-otp', + body, + `203.0.113.${i + 1}`, + ) + ).status, + ).toBe(200); + } + f.time.advanceMilliseconds(60_000); + expect( + ( + await directPost( + f, + 'email-otp/send-verification-otp', + body, + '203.0.113.99', + ) + ).status, + ).toBe(429); + f.time.advanceMilliseconds(15 * 60_000); + expect( + ( + await directPost( + f, + 'email-otp/send-verification-otp', + body, + '203.0.113.99', + ) + ).status, + ).toBe(200); +}); + +test('auth surface: explicit CSRF, exact origin, security headers and disabled unused endpoints', async ({ + onTestFinished, +}) => { + const f = await fixture(); + onTestFinished(() => f.close()); + const body = { email: 'alice@example.test', type: 'sign-in' }; + await f.client + .post('/api/auth/email-otp/send-verification-otp') + .set('Origin', origin) + .send(body) + .expect(403); + await f.client + .post('/api/auth/email-otp/send-verification-otp') + .set('Origin', origin) + .set('Cookie', f.csrfCookie) + .set('X-CSRF-Token', 'wrong') + .send(body) + .expect(403); + await f.client + .post('/api/auth/email-otp/send-verification-otp') + .set('Origin', 'https://sibling.example.test') + .set('Cookie', f.csrfCookie) + .set('X-CSRF-Token', f.csrf) + .send(body) + .expect(403); + for (const path of [ + 'email-otp/check-verification-otp', + 'email-otp/reset-password', + 'update-user', + 'revoke-sessions', + ]) + await f.post(path, {}).expect(404); + const page = await f.client.get('/'); + expect(page.headers['content-security-policy']).toContain( + "script-src 'self'", + ); + expect(page.headers['content-security-policy']).toContain( + "frame-ancestors 'none'", + ); + expect(page.headers['cache-control']).toBe('no-store'); + expect(page.headers['referrer-policy']).toBe('no-referrer'); + expect(f.email.all()).toHaveLength(0); +}); + +for (const policy of ['single', 'multiple'] as const) + test(`session policy: ${policy}`, async ({ onTestFinished }) => { + const f = await fixture('2020-01-01T00:00:00Z', policy); + onTestFinished(() => f.close()); + const first = await login(f); + f.time.advanceMilliseconds(61_000); + const second = await login(f); + expect(first.cookie).not.toBe(second.cookie); + expect((await f.get(first.cookie)).body !== null).toBe( + policy === 'multiple', + ); + expect((await f.get(second.cookie)).body.user.email).toBe( + 'alice@example.test', + ); + await f.post('sign-out', {}, second.cookie).expect(200); + expect((await f.get(second.cookie)).body).toBeNull(); + expect((await f.get(first.cookie)).body !== null).toBe( + policy === 'multiple', + ); + }); diff --git a/tests/integration/database.test.ts b/tests/integration/database.test.ts index a7c1f5d..ff0cf0f 100644 --- a/tests/integration/database.test.ts +++ b/tests/integration/database.test.ts @@ -32,8 +32,14 @@ test('shared SQL sources upgrade independently while each history stays append-o lease.url, "INSERT INTO users VALUES ('u', 'upgrade@example.test', '2020-01-01'); INSERT INTO profiles VALUES ('u', 'preserved')", ); + const next = + Math.max( + ...(await readMigrations(shared)).map((file) => + Number(file.name.split('_')[0]), + ), + ) + 1; await writeFile( - join(shared, '003_auth_display.sql'), + join(shared, `${String(next).padStart(3, '0')}_auth_display.sql`), '-- Up Migration\nALTER TABLE users ADD COLUMN display_name text;\n-- Down Migration\nALTER TABLE users DROP COLUMN display_name;', ); await migrate(lease.url, await readMigrations(sources)); diff --git a/tests/integration/oauth-providers.test.ts b/tests/integration/oauth-providers.test.ts index 248d365..ab29983 100644 --- a/tests/integration/oauth-providers.test.ts +++ b/tests/integration/oauth-providers.test.ts @@ -3,7 +3,10 @@ import { OAuthProviders, type OAuthProof, } from '../../examples/login/src/oauth-providers.ts'; -import { mockOAuthServer, oauthCredentials } from '../support/oauth-server.ts'; +import { + mockOAuthServer, + allOAuthCredentials, +} from '../support/oauth-server.ts'; const proof: OAuthProof = { state: 'state-from-browser', @@ -23,7 +26,7 @@ const providerTest = test.extend<{ server: Mock; clients: OAuthProviders }>({ } }, clients: async ({ server }, use) => - use(new OAuthProviders(oauthCredentials, server.transport)), + use(new OAuthProviders(allOAuthCredentials, server.transport)), }); providerTest.for(['google', 'github'] as const)( '%s exchanges a code using PKCE and returns a verified stable identity', @@ -119,3 +122,77 @@ providerTest('a discovery outage is retryable', async ({ server, clients }) => { (await clients.authorizationUrl('google', proof, false)).hostname, ).toBe('accounts.google.com'); }); + +providerTest.for(['apple', 'facebook'] as const)( + '%s exchanges a browser-bound code without claiming unsupported PKCE', + async (provider, { server, clients }) => { + const url = await clients.authorizationUrl(provider, proof, false); + expect(url.searchParams.get('state')).toBe(proof.state); + expect(url.searchParams.has('code_challenge')).toBe(false); + if (provider === 'apple') { + expect(url.searchParams.get('nonce')).toBe(proof.nonce); + expect(url.searchParams.get('response_mode')).toBe('form_post'); + } + expect( + await clients.identity(provider, server.authorize(provider, url), proof), + ).toEqual({ + subject: provider === 'apple' ? 'apple-person-1' : '12345', + email: 'oauth@example.test', + }); + }, +); +providerTest.for([ + { badSignature: true }, + { claims: { iss: 'https://attacker.example' } }, + { claims: { aud: 'other-client' } }, + { claims: { nonce: 'other-nonce' } }, + { claims: { exp: 0 } }, + { missingIdToken: true }, + { verified: false }, + { claims: { email_verified: 'false' } }, +])( + 'Apple rejects invalid identity proof: %j', + async (options, { server, clients }) => { + const url = await clients.authorizationUrl('apple', proof, false); + await expect( + clients.identity('apple', server.authorize('apple', url, options), proof), + ).rejects.toThrow(); + }, +); +providerTest( + 'Apple accepts a verified private relay email with a string verification claim', + async ({ server, clients }) => { + const url = await clients.authorizationUrl('apple', proof, false); + expect( + await clients.identity( + 'apple', + server.authorize('apple', url, { + email: 'private@privaterelay.appleid.com', + claims: { email_verified: 'true' }, + }), + proof, + ), + ).toEqual({ + subject: 'apple-person-1', + email: 'private@privaterelay.appleid.com', + }); + }, +); +providerTest.for([ + { email: '' }, + { subject: 'invalid' }, + { profileFailure: true }, + { tokenFailure: true }, +])( + 'Facebook fails closed on missing identity or upstream failure: %j', + async (options, { server, clients }) => { + const url = await clients.authorizationUrl('facebook', proof, false); + await expect( + clients.identity( + 'facebook', + server.authorize('facebook', url, options), + proof, + ), + ).rejects.toThrow(); + }, +); diff --git a/tests/integration/snapshots/better-auth-cookies.json b/tests/integration/snapshots/better-auth-cookies.json new file mode 100644 index 0000000..1f3b281 --- /dev/null +++ b/tests/integration/snapshots/better-auth-cookies.json @@ -0,0 +1,3 @@ +[ + "__Host-pgstencil.session_token=w2yuuiOO8YfDZfLCdfUGFslJ8qZHIS6V.mqayyrrKNWCIB0180YVZOcSKWCcgtgQmnfgOCgZcgp4%3D; Max-Age=86400; Path=/; HttpOnly; Secure; SameSite=Lax" +] diff --git a/tests/integration/snapshots/better-auth-email.md b/tests/integration/snapshots/better-auth-email.md new file mode 100644 index 0000000..26f955b --- /dev/null +++ b/tests/integration/snapshots/better-auth-email.md @@ -0,0 +1,23 @@ +# Your sign-in code + +{ + "capturedAt": "2020-01-01T00:00:00.000Z", + "from": "signin@example.test", + "to": [ + "alice@example.test" + ] +} + +## Plaintext + +Your sign-in code is 19667655. It expires in 10 minutes. + +## Markdown + +Your sign-in code is **19667655**. + +It expires in 10 minutes. + +## HTML + +

Your sign-in code is 19667655.

It expires in 10 minutes.

diff --git a/tests/integration/snapshots/better-auth-login.html b/tests/integration/snapshots/better-auth-login.html new file mode 100644 index 0000000..e9e72d6 --- /dev/null +++ b/tests/integration/snapshots/better-auth-login.html @@ -0,0 +1,7 @@ + +Better Auth email example +

Sign in

+
+ +

+ \ No newline at end of file diff --git a/tests/integration/snapshots/better-auth-login.md b/tests/integration/snapshots/better-auth-login.md new file mode 100644 index 0000000..5dfa765 --- /dev/null +++ b/tests/integration/snapshots/better-auth-login.md @@ -0,0 +1,15 @@ +# Sign in + +Email + +\[email: \] + +\[Email a code\] + +Code + +\[otp: \] + +\[Sign in\] + +\[Sign out\] diff --git a/tests/integration/snapshots/better-auth-session.json b/tests/integration/snapshots/better-auth-session.json new file mode 100644 index 0000000..7ac740a --- /dev/null +++ b/tests/integration/snapshots/better-auth-session.json @@ -0,0 +1,13 @@ +[ + { + "createdAt": "2020-01-01T00:00:00.000Z", + "expiresAt": "2020-01-02T00:00:00.000Z", + "id": "4jTWBPymxPMErp1QsQeJ4O6cDuEjb0zY", + "ipAddress": "127.0.0.1", + "singleSession": false, + "token": "w2yuuiOO8YfDZfLCdfUGFslJ8qZHIS6V", + "updatedAt": "2020-01-01T00:00:00.000Z", + "userAgent": "", + "userId": "Z4QlCqqJmzirhpnjxhm1hzgQNDhFSWGL" + } +] diff --git a/tests/integration/workers.test.ts b/tests/integration/workers.test.ts new file mode 100644 index 0000000..fb37be5 --- /dev/null +++ b/tests/integration/workers.test.ts @@ -0,0 +1,254 @@ +import { test, expect } from 'vitest'; +import { build } from 'esbuild'; +import { builtinModules } from 'node:module'; +import { + Miniflare, + convertV4MiniflareOptions, + Response as MiniflareResponse, +} from 'miniflare'; +import { createTestContext } from '../../packages/pgstencil/src/testing.ts'; +import type { EmailMessage } from '../../packages/pgstencil/src/email.ts'; +import { + allOAuthCredentials, + endpointPaths, + mockOAuthServer, +} from '../support/oauth-server.ts'; +import { codeFrom } from './helpers.ts'; +import type { AuthState } from '../../packages/auth/src/fetch.ts'; + +const origin = 'https://worker.example.test'; +const bundle = build({ + entryPoints: ['tests/support/worker.ts'], + bundle: true, + write: false, + format: 'esm', + platform: 'node', + conditions: ['workerd', 'worker'], + external: ['node:*', 'cloudflare:*'], + alias: Object.fromEntries( + builtinModules + .filter((name) => !name.startsWith('node:')) + .map((name) => [name, `node:${name}`]), + ), + banner: { + js: "import { createRequire } from 'node:module'; const require = createRequire('/worker.js');", + }, +}); +type WorkerResponse = Awaited>; +const cookie = (response: WorkerResponse) => + response.headers + .getSetCookie() + .map((v) => v.split(';')[0]) + .join('; '); +const workerTest = test.extend<{ f: Awaited> }>({ + f: async ({}, use) => { + const f = await fixture(); + try { + await use(f); + } finally { + await f.close(); + } + }, +}); +async function fixture() { + const context = await createTestContext(); + const provider = await mockOAuthServer(); + const bindings: Record = { + APP_ORIGIN: origin, + AUTH_SECRET: 'workers-test-secret-at-least-32-characters', + }; + for (const [name, credentials] of Object.entries(allOAuthCredentials)) { + bindings[`${name.toUpperCase()}_CLIENT_ID`] = credentials.clientId; + bindings[`${name.toUpperCase()}_CLIENT_SECRET`] = credentials.clientSecret; + } + const worker = new Miniflare( + convertV4MiniflareOptions({ + modules: true, + script: (await bundle).outputFiles![0]!.text, + compatibilityDate: '2026-09-08', + compatibilityFlags: ['nodejs_compat'], + bindings, + hyperdrives: { HYPERDRIVE: context.database.url }, + async outboundService(req) { + const url = new URL(req.url); + if (url.origin === 'https://inbox.test') { + await context.email.send((await req.json()) as EmailMessage); + return new MiniflareResponse('ok'); + } + const path = endpointPaths[url.origin + url.pathname]; + if (!path) + throw new Error( + `Unexpected outbound request: ${url.origin}${url.pathname}`, + ); + const response = await fetch(provider.origin + path + url.search, { + method: req.method, + headers: Object.fromEntries(req.headers), + ...(req.method === 'POST' ? { body: await req.text() } : {}), + }); + return new MiniflareResponse(await response.arrayBuffer(), { + status: response.status, + headers: { + 'content-type': + response.headers.get('content-type') ?? 'application/json', + }, + }); + }, + }), + ); + try { + await worker.ready; + } catch (error) { + await worker.dispose(); + await provider.close(); + await context.close(); + throw error; + } + const get = (path: string, cookies = '') => + worker.dispatchFetch(origin + path, { + headers: { cookie: cookies }, + redirect: 'manual', + }); + const post = ( + path: string, + csrf: string, + cookies: string, + body: unknown = {}, + requestOrigin = origin, + ) => + worker.dispatchFetch(origin + path, { + method: 'POST', + headers: { + origin: requestOrigin, + cookie: cookies, + 'x-csrf-token': csrf, + 'content-type': 'application/json', + }, + body: JSON.stringify(body), + redirect: 'manual', + }); + return { + ...context, + worker, + provider, + get, + post, + async close() { + await worker.dispose(); + await provider.close(); + await context.close(); + }, + }; +} +workerTest( + 'real workerd + Postgres: email login, secure cookies, CSRF, exact 24-hour expiry', + async ({ f }) => { + const start = await f.get('/api/auth/session'); + expect(start.status).toBe(200); + const state = (await start.json()) as AuthState; + const pending = cookie(start); + expect( + ( + await f.post( + '/api/auth/email', + state.csrf, + pending, + { email: 'worker@example.test' }, + 'https://attacker.test', + ) + ).status, + ).toBe(403); + expect( + ( + await f.post('/api/auth/email', state.csrf, pending, { + email: 'worker@example.test', + }) + ).status, + ).toBe(200); + const email = await f.email.next(); + const verified = await f.post('/api/auth/verify', state.csrf, pending, { + method: 'code', + value: codeFrom(email), + }); + expect(verified.status).toBe(200); + expect(verified.headers.getSetCookie()[0]).toContain( + 'HttpOnly; SameSite=Lax; Max-Age=86400; Expires=Thu, 02 Jan 2020 00:00:00 GMT; Secure', + ); + const sessionCookie = cookie(verified); + const session = (await ( + await f.get('/api/auth/session', sessionCookie) + ).json()) as AuthState; + expect(session.session?.user.email).toBe('worker@example.test'); + expect( + (await f.post('/api/auth/logout', 'forged', sessionCookie)).status, + ).toBe(403); + await f.worker.dispatchFetch(origin + '/__test/time', { + method: 'POST', + body: '2020-01-01T23:00:00Z', + }); + expect( + ( + (await ( + await f.get('/api/auth/session', sessionCookie) + ).json()) as AuthState + ).session, + ).not.toBeNull(); + await f.worker.dispatchFetch(origin + '/__test/time', { + method: 'POST', + body: '2020-01-02T00:00:00Z', + }); + expect( + ( + (await ( + await f.get('/api/auth/session', sessionCookie) + ).json()) as AuthState + ).session, + ).toBeNull(); + }, +); +workerTest.for(['google', 'apple', 'facebook'] as const)( + 'real workerd: %s callback, browser binding and replay protection', + async (provider, { f }) => { + const start = await f.get('/api/auth/session'); + const state = (await start.json()) as AuthState; + const started = await f.post( + `/api/auth/oauth/${provider}/start`, + state.csrf, + cookie(start), + ); + expect(started.status).toBe(200); + const callback = f.provider.authorize( + provider, + new URL(((await started.json()) as { url: string }).url), + ); + let path = callback.pathname + callback.search; + if (provider === 'apple') { + const relay = await f.worker.dispatchFetch(origin + callback.pathname, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + origin: 'https://appleid.apple.com', + }, + body: callback.searchParams.toString(), + redirect: 'manual', + }); + expect(relay.status).toBe(303); + expect(relay.headers.get('referrer-policy')).toBe('no-referrer'); + expect(relay.headers.getSetCookie()).toEqual([]); + path = relay.headers.get('location')!; + } + const unbound = await f.get(path); + expect(unbound.headers.get('location')).toContain('/login?error='); + const result = await f.get(path, cookie(started)); + expect(result.headers.get('location')).toBe('/profile'); + expect( + ( + (await ( + await f.get('/api/auth/session', cookie(result)) + ).json()) as AuthState + ).session?.user.email, + ).toBe('oauth@example.test'); + expect( + (await f.get(path, cookie(started))).headers.get('location'), + ).toContain('/login?error='); + }, +); diff --git a/tests/support/better-auth-entry.ts b/tests/support/better-auth-entry.ts new file mode 100644 index 0000000..d728882 --- /dev/null +++ b/tests/support/better-auth-entry.ts @@ -0,0 +1,29 @@ +import { createEmailApp } from '../../examples/better-auth/src/auth.ts'; +import { deterministicScope } from '../../packages/auth/src/better-auth-testing.ts'; +import type { Time, RandomSource } from 'pgstencil'; + +export function createDeterministicApp( + options: Parameters[0] & { + time: Time; + random: RandomSource; + outboundFetch?: typeof fetch; + }, +) { + const app = deterministicScope.run(options, () => createEmailApp(options)); + return { + close: app.close, + fetch: (request: Request) => + deterministicScope.run(options, () => app.app.fetch(request)), + // Deliberately crosses async boundaries to test independent concurrent app contexts. + probe: () => + deterministicScope.run(options, async () => { + const before = Date.now(); + await new Promise((done) => setTimeout(done, 5)); + return { + before, + after: new Date().getTime(), + uuid: crypto.randomUUID(), + }; + }), + }; +} diff --git a/tests/support/better-auth-worker.ts b/tests/support/better-auth-worker.ts new file mode 100644 index 0000000..0024f18 --- /dev/null +++ b/tests/support/better-auth-worker.ts @@ -0,0 +1,37 @@ +import worker, { + type Bindings, +} from '../../examples/better-auth/src/worker.ts'; +import { DevTime, DevRandom } from 'pgstencil'; +import { deterministicScope } from '../../packages/auth/src/better-auth-testing.ts'; +const context = { + time: new DevTime(), + random: new DevRandom('better-auth-worker'), +}; +export default { + async fetch( + request: Request, + env: Bindings & { + OAUTH_TEST?: { fetch(request: Request): Promise }; + }, + ) { + if ( + new URL(request.url).pathname === '/__test/time' && + request.method === 'POST' + ) { + context.time.set(await request.text()); + return new Response('ok'); + } + return deterministicScope.run( + { + ...context, + ...(env.OAUTH_TEST + ? { + outboundFetch: (input: RequestInfo | URL, init?: RequestInit) => + env.OAUTH_TEST!.fetch(new Request(input, init)), + } + : {}), + }, + () => worker.fetch(request, env), + ); + }, +}; diff --git a/tests/support/oauth-server.ts b/tests/support/oauth-server.ts index 4ba4c97..6bd1a82 100644 --- a/tests/support/oauth-server.ts +++ b/tests/support/oauth-server.ts @@ -1,22 +1,31 @@ import { createServer } from 'node:http'; import { once } from 'node:events'; -import { createHash, generateKeyPairSync, sign } from 'node:crypto'; +import { createHash, createHmac, generateKeyPairSync, sign } from 'node:crypto'; import type { OAuthFetch, Provider, OAuthSettings, } from '../../examples/login/src/oauth-providers.ts'; -export const oauthCredentials = { +export const allOAuthCredentials = { google: { clientId: 'test-google-client', clientSecret: 'test-google-secret', }, + apple: { clientId: 'test-apple-client', clientSecret: 'test-apple-secret' }, + facebook: { + clientId: 'test-facebook-client', + clientSecret: 'test-facebook-secret', + }, github: { clientId: 'test-github-client', clientSecret: 'test-github-secret', }, } satisfies OAuthSettings; +export const oauthCredentials = { + google: allOAuthCredentials.google, + github: allOAuthCredentials.github, +}; const key = generateKeyPairSync('rsa', { modulusLength: 2048 }); // Only the badSignature grant needs a second key; generating it is ~40ms. let wrongKey: typeof key | undefined; @@ -28,7 +37,15 @@ const jwk = { use: 'sig', alg: 'RS256', }; -const endpointPaths: Record = { +export const endpointPaths: Record = { + 'https://appleid.apple.com/.well-known/openid-configuration': + '/apple/discovery', + 'https://appleid.apple.com/auth/token': '/apple/token', + 'https://appleid.apple.com/auth/keys': '/keys', + 'https://graph.facebook.com/oauth/access_token': '/facebook/token', + 'https://graph.facebook.com/me': '/facebook/user', + 'https://graph.facebook.com/debug_token': '/facebook/debug', + 'https://graph.facebook.com/v24.0/oauth/access_token': '/facebook/token', 'https://accounts.google.com/.well-known/openid-configuration': '/discovery', 'https://oauth2.googleapis.com/token': '/google/token', 'https://www.googleapis.com/oauth2/v3/certs': '/keys', @@ -54,7 +71,9 @@ interface Grant { } /** Real local HTTP endpoints, with test-only signing keys and dummy OAuth clients. */ -export async function mockOAuthServer() { +export async function mockOAuthServer( + settings: { betterAuth?: boolean; now?: () => Date } = {}, +) { const grants = new Map(); const accessTokens = new Map(); const requests: { @@ -74,19 +93,27 @@ export async function mockOAuthServer() { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); }; - if (url.pathname === '/discovery') { + if (url.pathname.endsWith('/discovery')) { if (discoveryFailure) return json({ error: 'unavailable' }, 503); + const apple = url.pathname.startsWith('/apple'); return json({ - issuer: 'https://accounts.google.com', - authorization_endpoint: - 'https://accounts.google.com/o/oauth2/v2/auth', - token_endpoint: 'https://oauth2.googleapis.com/token', - jwks_uri: 'https://www.googleapis.com/oauth2/v3/certs', + issuer: apple + ? 'https://appleid.apple.com' + : 'https://accounts.google.com', + authorization_endpoint: apple + ? 'https://appleid.apple.com/auth/authorize' + : 'https://accounts.google.com/o/oauth2/v2/auth', + token_endpoint: apple + ? 'https://appleid.apple.com/auth/token' + : 'https://oauth2.googleapis.com/token', + jwks_uri: apple + ? 'https://appleid.apple.com/auth/keys' + : 'https://www.googleapis.com/oauth2/v3/certs', response_types_supported: ['code'], subject_types_supported: ['public'], id_token_signing_alg_values_supported: ['RS256'], token_endpoint_auth_methods_supported: ['client_secret_post'], - code_challenge_methods_supported: ['S256'], + ...(apple ? {} : { code_challenge_methods_supported: ['S256'] }), }); } if (url.pathname === '/keys') return json({ keys: [jwk] }); @@ -96,7 +123,7 @@ export async function mockOAuthServer() { const grant = grants.get(code); grants.delete(code); if (!grant) return json({ error: 'invalid_grant' }, 400); - const credentials = oauthCredentials[grant.provider]; + const credentials = allOAuthCredentials[grant.provider]; const challenge = createHash('sha256') .update(form.get('code_verifier') ?? '') .digest('base64url'); @@ -108,8 +135,14 @@ export async function mockOAuthServer() { form.get('client_secret') !== credentials.clientSecret || form.get('redirect_uri') !== grant.authorization.searchParams.get('redirect_uri') || - challenge !== - grant.authorization.searchParams.get('code_challenge') || + (settings.betterAuth + ? grant.authorization.searchParams.has('code_challenge') && + challenge !== + grant.authorization.searchParams.get('code_challenge') + : grant.provider === 'google' || grant.provider === 'github' + ? challenge !== + grant.authorization.searchParams.get('code_challenge') + : form.has('code_verifier')) || grant.options.tokenFailure ) return json( @@ -129,12 +162,20 @@ export async function mockOAuthServer() { ? 'openid email' : 'read:user,user:email', }; - if (grant.provider === 'google' && !grant.options.missingIdToken) { - const now = Math.floor(Date.now() / 1000); // Upstream protocol clock, independent of application DevTime. + if ( + (grant.provider === 'google' || grant.provider === 'apple') && + !grant.options.missingIdToken + ) { + const now = Math.floor( + (settings.now?.().getTime() ?? Date.now()) / 1000, + ); // Upstream protocol clock, independent of application DevTime. const claims = { - iss: 'https://accounts.google.com', + iss: + grant.provider === 'apple' + ? 'https://appleid.apple.com' + : 'https://accounts.google.com', aud: credentials.clientId, - sub: grant.options.subject ?? 'google-person-1', + sub: grant.options.subject ?? `${grant.provider}-person-1`, email: grant.options.email ?? 'oauth@example.test', email_verified: grant.options.verified ?? true, nonce: grant.authorization.searchParams.get('nonce'), @@ -152,9 +193,52 @@ export async function mockOAuthServer() { } return json(response); } + if (url.pathname === '/facebook/debug') { + const grant = accessTokens.get( + url.searchParams.get('input_token') ?? '', + ); + const credentials = allOAuthCredentials.facebook; + const valid = + !!grant && + url.searchParams.get('access_token') === + `${credentials.clientId}|${credentials.clientSecret}`; + return json({ + data: { + is_valid: valid, + app_id: credentials.clientId, + user_id: grant?.options.subject ?? '12345', + }, + }); + } const accessToken = req.headers.authorization?.replace(/^Bearer /i, '') ?? ''; const grant = accessTokens.get(accessToken); + if (grant?.provider === 'facebook' && url.pathname === '/facebook/user') { + const expected = createHmac( + 'sha256', + allOAuthCredentials.facebook.clientSecret, + ) + .update(accessToken) + .digest('hex'); + if ( + !settings.betterAuth && + (url.searchParams.get('appsecret_proof') !== expected || + url.searchParams.get('fields') !== 'id,email') + ) + return json({ error: 'invalid_proof' }, 400); + if (grant.options.profileFailure) + return json({ error: 'unavailable' }, 503); + return json({ + id: grant.options.subject ?? '12345', + ...(settings.betterAuth + ? { + name: 'Mock Facebook', + picture: { data: { url: 'https://example.test/avatar' } }, + } + : {}), + email: grant.options.email ?? 'oauth@example.test', + }); + } if (!grant || grant.provider !== 'github') return json({ error: 'unauthorized' }, 401); if (grant.options.profileFailure) @@ -219,6 +303,7 @@ export async function mockOAuthServer() { }; return { transport, + origin, requests, failDiscovery(value: boolean) { discoveryFailure = value; diff --git a/tests/support/worker.ts b/tests/support/worker.ts new file mode 100644 index 0000000..03bc2b3 --- /dev/null +++ b/tests/support/worker.ts @@ -0,0 +1,27 @@ +import { DevTime, DevRandom } from '../../packages/pgstencil/src/index.ts'; +import { createAuthWorker } from '../../packages/auth/src/workers.ts'; +import type { EmailMessage } from '../../packages/pgstencil/src/email.ts'; +const time = new DevTime(); +const app = createAuthWorker({ + time, + random: new DevRandom('worker-test'), + email: () => ({ + async send(message: EmailMessage) { + const response = await fetch('https://inbox.test/send', { + method: 'POST', + body: JSON.stringify(message), + }); + if (!response.ok) throw new Error('Test email delivery failed'); + }, + }), +}); +// Control routes exist only in this test bundle; the production entry has none. +export default { + async fetch(request: Request, env: Parameters[1]) { + if (new URL(request.url).pathname === '/__test/time') { + time.set(await request.text()); + return new Response('ok'); + } + return app.fetch(request, env); + }, +};