From e3057928a2c37fdcb1a52c3a7b6bbe3ea1168dcf Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 10 Jun 2026 19:39:15 -0700 Subject: [PATCH] feat: add better authentication features and organization management - Introduced new database migration for enhanced user and organization management. - Updated package dependencies to include new Better Auth modules for API keys, Expo, and i18n. - Implemented SMS functionality for phone verification and password resets. - Enhanced authentication plugins with username and phone number support. - Added performance configuration options for session cookie caching and API key updates. - Updated email templates to include organization invitation messages. - Improved testing coverage for new features and configurations. --- docs/auth-migrations.md | 44 +- .../better-auth-cloudflare-plugin-research.md | 460 +++++++++++ docs/better-auth-plugin-compass.md | 130 +++ docs/schema/better-auth-target.sql | 61 ++ .../2026-06-11-better-auth-performance.md | 775 ++++++++++++++++++ migrations/0001_baseline_existing_auth.sql | 6 + .../0001_better_auth_account_center.sql | 25 - .../0002_add_better_auth_account_center.sql | 106 +++ package.json | 5 +- pnpm-lock.yaml | 63 ++ scripts/check-better-auth-migration.mjs | 2 +- src/auth.migration.ts | 104 +++ src/auth.ts | 22 +- src/email.ts | 15 +- src/env.ts | 14 +- src/index.ts | 6 +- src/password.ts | 2 +- src/performance.ts | 31 + src/plugins.ts | 115 ++- src/sms.ts | 92 +++ tests/auth-config.test.ts | 158 +++- 21 files changed, 2200 insertions(+), 36 deletions(-) create mode 100644 docs/better-auth-cloudflare-plugin-research.md create mode 100644 docs/better-auth-plugin-compass.md create mode 100644 docs/schema/better-auth-target.sql create mode 100644 docs/superpowers/plans/2026-06-11-better-auth-performance.md create mode 100644 migrations/0001_baseline_existing_auth.sql delete mode 100644 migrations/0001_better_auth_account_center.sql create mode 100644 migrations/0002_add_better_auth_account_center.sql create mode 100644 src/performance.ts create mode 100644 src/sms.ts diff --git a/docs/auth-migrations.md b/docs/auth-migrations.md index 0814c08..0e1cd56 100644 --- a/docs/auth-migrations.md +++ b/docs/auth-migrations.md @@ -1,6 +1,11 @@ # Auth Migrations -This project uses Better Auth CLI to generate SQLite-compatible SQL for Cloudflare D1, then uses Wrangler to apply the SQL as D1 migrations. +This project keeps two migration artifacts separate: + +- Better Auth target schema: the full schema generated from `src/auth.migration.ts`. +- D1 migrations: incremental SQL files applied by Wrangler to the current database. + +The target schema is a reference snapshot. Do not apply it directly to a production database that already has tables. ## Generate @@ -13,9 +18,11 @@ pnpm db:generate Review the generated SQL in: ```text -migrations/0001_better_auth_account_center.sql +docs/schema/better-auth-target.sql ``` +This file is generated from an in-memory SQLite database because Better Auth CLI needs a database adapter to discover the target schema. That is acceptable for schema generation, but it only represents the desired final shape. + ## Check CI and local readiness use: @@ -26,6 +33,12 @@ pnpm db:check This command generates SQL into a temporary file with Better Auth CLI and compares it with the committed migration file. +The check compares against: + +```text +docs/schema/better-auth-target.sql +``` + ## Apply Locally ```bash @@ -33,3 +46,30 @@ pnpm db:apply:local ``` Do not use `auth migrate` for D1. D1 migrations are applied by Wrangler so local and remote environments use the same migration history. + +## Production Migration Rule + +Production D1 databases may already contain an older Better Auth schema. In that case: + +1. Export a backup and current schema before changing anything: + + ```bash + wrangler d1 export cfw-auth --remote --output=backup.sql + wrangler d1 export cfw-auth --remote --output=prod-schema.sql --no-data + wrangler d1 migrations list cfw-auth --remote + ``` + +2. Treat `docs/schema/better-auth-target.sql` as the target. +3. Write incremental files under `migrations/` that move the existing production schema to the target. +4. Apply with Wrangler: + + ```bash + wrangler d1 migrations apply cfw-auth --remote + ``` + +## Current Migration Layout + +- `migrations/0001_baseline_existing_auth.sql` records a baseline for databases that already have the older auth tables. +- `migrations/0002_add_better_auth_account_center.sql` adds the current account-center tables, fields, indexes, API key table, and JWT `jwks` table. + +If a brand-new empty D1 database is needed, do not use the baseline path as-is. Either apply a full initial schema generated from `docs/schema/better-auth-target.sql`, or create a separate fresh-database migration sequence. diff --git a/docs/better-auth-cloudflare-plugin-research.md b/docs/better-auth-cloudflare-plugin-research.md new file mode 100644 index 0000000..2240a03 --- /dev/null +++ b/docs/better-auth-cloudflare-plugin-research.md @@ -0,0 +1,460 @@ +# Better Auth Plugins on Cloudflare + +## Research Question + +I am researching Better Auth plugins for username, phone-number OTP, organization, API key, and Expo. +Because `cfw-auth` runs as a Cloudflare Worker on D1 and needs these capabilities without leaking platform-specific complexity into the auth model. +So this project can keep one small auth core, add plugins deliberately, and know how to operate them in Cloudflare. + +Question type: method and decision. + +Evidence threshold: official Better Auth docs/package metadata, installed 1.6.x type definitions, Cloudflare Worker/D1 configuration, and local migration output. + +## Source Evaluation + +| Source | Role | Use level | Key claim | Limits | +| --- | --- | --- | --- | --- | +| Better Auth plugin docs, `https://better-auth.com/docs/concepts/plugins` | Official docs | Core evidence | Plugins can add schema, endpoints, hooks, middleware, rate limits, and client behavior. | The site is a Next app, so command-line extraction is noisy. | +| Better Auth plugin overview, `https://better-auth.com/docs/plugins` | Official docs | Core evidence | Better Auth groups 50+ plugins into authentication, authorization, API/token, provider, billing, security, utility, and analytics families. | Plugin availability can change between Better Auth releases. | +| Better Auth username docs, `https://better-auth.com/docs/plugins/username` | Official docs | Core evidence | Username adds username fields and sign-in/availability endpoints. | Exact runtime behavior should be checked against installed package types. | +| Better Auth phone-number docs, `https://better-auth.com/docs/plugins/phone-number` | Official docs | Core evidence | Phone number supports OTP sending, verification, sign-in, and password reset by phone. | SMS provider is intentionally outside Better Auth. | +| `better-auth@1.6.x` installed type definitions | Local package evidence | Core evidence | Confirms endpoint paths, schema additions, option names, and plugin ids used by this repo. | Tied to the installed version; re-check after dependency upgrades. | +| `@better-auth/api-key@1.6.x` installed type definitions | Local package evidence | Core evidence | Confirms API key table, key hashing, rate limit fields, user/org references, and endpoints. | Does not replace production threat modeling for API key usage. | +| `@better-auth/expo@1.6.x` package README | Maintained package docs | Core evidence | Expo server plugin plus Expo client plugin, trusted custom scheme, and SecureStore-backed storage. | Client-side Expo app is outside this Worker repo. | +| `@better-auth/i18n@1.6.x` installed type definitions | Local package evidence | Core evidence | Confirms the i18n plugin is a separate package and translates Better Auth error messages through after hooks. | Translation coverage is project-owned. | +| Cloudflare Wrangler config docs, `https://developers.cloudflare.com/workers/wrangler/configuration/` | Platform docs | Core evidence | Worker bindings, vars, compatibility dates, and flags are configured through Wrangler config. | Does not document Better Auth behavior. | +| Local target schema `docs/schema/better-auth-target.sql` | Project evidence | Core evidence | Current plugin set creates username, phone, organization/team/member/invitation, apikey, and jwks persistence. | Generated target SQL must stay in sync with Better Auth config; D1 migrations remain incremental. | + +## Plugin Ecosystem Map + +Better Auth's plugin ecosystem has four useful categories: + +| Category | Examples in this repo | When to use | Cloudflare note | +| --- | --- | --- | --- | +| Identity and sign-in methods | `username`, `phoneNumber`, `emailOTP`, `passkey`, social providers | Add new ways to prove or discover a user identity. | Provider callbacks must use `fetch`, Worker-compatible crypto, and correct `trustedOrigins`. | +| Account and session controls | `twoFactor`, `multiSession`, `lastLoginMethod`, `admin`, `bearer`, `jwt`, `i18n` | Add account security, admin operations, alternate token formats, localized errors, or session policy. | Avoid enabling token formats by default; each one expands the security surface. | +| B2B and authorization | `organization`, teams, members, invitations, roles | Model tenants, memberships, invitations, and per-organization access. | Keep tenant authorization in the auth boundary; expose projections to apps. | +| API and machine access | `apiKey` | Let users or organizations create revocable machine credentials. | Hash keys, prefer explicit prefixes, and be careful with database-backed rate counters on D1. | +| Client/runtime integration | `expo`, OpenAPI | Adapt auth behavior to client platforms or operational tooling. | Expo needs a trusted custom scheme; OpenAPI helps smoke-test the Worker. | +| Abuse prevention | `captcha`, `haveIBeenPwned` | Reduce automated abuse or weak passwords. | Turnstile is a natural Cloudflare fit; configure secrets as Worker secrets. | + +## Current `cfw-auth` Fit + +The current code already follows the right high-level Cloudflare shape: + +- `src/auth.ts` passes the D1 binding as `database: env.DB`. +- `src/index.ts` mounts Better Auth under `/api/auth/*` through Hono and forwards Worker background work through `ctx.executionCtx.waitUntil`. +- `wrangler.jsonc` declares the D1 binding, compatibility date, `nodejs_compat`, and non-secret vars. +- `src/auth.migration.ts` uses an in-memory SQLite database for Better Auth CLI schema generation, then Wrangler applies generated SQL to D1. +- `docs/auth-migrations.md` correctly says not to use `auth migrate` for D1; Wrangler should own D1 migration history. + +The main operational rule is: plugin configuration that affects persistence must be present in both runtime auth config and migration auth config. + +## Plugin Notes + +### i18n + +Use for translating Better Auth error messages based on request locale. + +Installed package evidence confirms: + +- Package: `@better-auth/i18n` +- Plugin id: `i18n` +- Client package: `@better-auth/i18n/client` +- Detection strategies: `header`, `cookie`, `session`, and `callback` +- It translates server error messages through an after hook and does not add persistence schema by itself. + +`cfw-auth` choice: + +```ts +i18n({ + translations: { + en: {}, + zh: { + USER_NOT_FOUND: "用户不存在。", + INVALID_EMAIL_OR_PASSWORD: "邮箱或密码无效。", + }, + }, + defaultLocale: "en", + detection: ["header", "cookie"], + localeCookie: "cfw_auth_locale", +}) +``` + +Best practices: + +- Keep English as the fallback locale until all user-facing flows have a complete translated copy path. +- Prefer `Accept-Language` plus an explicit locale cookie. Avoid guessing from IP. +- Translate stable error codes, not arbitrary log messages. +- Do not leak account-existence details through different translations. + +### Username + +Use for human-friendly account identifiers and sign-in by username/password. + +Installed package evidence confirms: + +- Plugin id: `username` +- User fields: `username`, `displayUsername` +- Endpoints: `POST /sign-in/username`, `POST /is-username-available` +- `username` is unique and returned in user data. + +`cfw-auth` choice: + +```ts +username({ + minUsernameLength: 3, + maxUsernameLength: 32, + validationOrder: { + username: "post-normalization", + }, +}) +``` + +Best practices: + +- Normalize before uniqueness matters. The default normalization is lowercase. +- Keep username separate from display name. Usernames are identifiers; display names are presentation. +- Do not let username replace email verification when email is still part of account recovery. + +### Phone Number and SMS OTP + +Use for phone verification, phone sign-in, phone-based account creation, and phone password reset. + +Installed package evidence confirms: + +- Plugin id: `phone-number` +- User fields: `phoneNumber`, `phoneNumberVerified` +- Endpoints: `POST /phone-number/send-otp`, `POST /phone-number/verify`, `POST /sign-in/phone-number`, `POST /phone-number/request-password-reset`, `POST /phone-number/reset-password` +- `phoneNumber` is unique. + +`cfw-auth` choice: + +```ts +phoneNumber({ + otpLength: 6, + expiresIn: 300, + allowedAttempts: 3, + requireVerification: true, + phoneNumberValidator: (value) => /^\+[1-9]\d{7,14}$/.test(value), + signUpOnVerification: { + getTempEmail: (value) => `phone-${value.replace(/\D/g, "")}@phone.cfw-auth.local`, + getTempName: (value) => value, + }, + sendOTP: async ({ phoneNumber, code }) => sendSms(...), +}) +``` + +Best practices: + +- Store and accept E.164-style numbers only. The current validator enforces a leading `+` and a sane digit range. +- Treat SMS as possession proof, not high-assurance identity proof. +- Keep SMS provider code in an adapter (`src/sms.ts`), because Twilio, webhook relays, and future Cloudflare-native services should not change the auth core. +- Decide whether phone-created users may later add a real email, because Better Auth still has a required email shape in the user model. + +### Organization + +Use for B2B tenancy: organizations, members, invitations, teams, and roles. + +Installed package evidence confirms: + +- Plugin id: `organization` +- Main concepts: organization, member, invitation, team, teamMember +- Endpoints cover create/update/delete organization, set active organization/team, invitations, members, team management, and permission checks. +- `sendInvitationEmail` must construct the invitation URL; Better Auth gives the invitation id and data. + +`cfw-auth` choice: + +```ts +organization({ + teams: { + enabled: true, + defaultTeam: { enabled: true }, + }, + requireEmailVerificationOnInvitation: true, + cancelPendingInvitationsOnReInvite: true, + sendInvitationEmail: async (data) => { + const url = `${appOrigin}/accept-invitation?id=${encodeURIComponent(data.id)}`; + await sendEmail(...); + }, +}) +``` + +Best practices: + +- Treat organization as a tenant boundary, not only a UI grouping. +- Keep membership and invitation authorization inside Better Auth unless a real domain rule requires a separate app policy. +- Avoid dynamic roles until fixed roles are insufficient. Dynamic access control is available but adds operational complexity. +- Build app-specific projections from organization/member state instead of letting app tables become the source of auth truth. + +### API Key + +Use for machine-to-machine access owned by a user or organization. + +Installed package evidence confirms: + +- Package: `@better-auth/api-key` +- Plugin id: `api-key` +- Table: `apikey` +- Endpoints include `POST /api-key/create`, `POST /api-key/verify`, `GET /api-key/get`, `POST /api-key/update`, `POST /api-key/delete`, `GET /api-key/list`, and `POST /api-key/delete-all-expired-api-keys`. +- Key hashing is enabled by default; disabling it is explicitly warned against in package types. +- `references` can be `user` or `organization`. + +`cfw-auth` choice: + +```ts +apiKey([ + { + configId: "default", + defaultPrefix: "cfw_", + requireName: true, + enableMetadata: true, + rateLimit: { enabled: true, timeWindow: 86_400_000, maxRequests: 1_000 }, + }, + { + configId: "organization", + references: "organization", + defaultPrefix: "cfw_org_", + requireName: true, + enableMetadata: true, + rateLimit: { enabled: true, timeWindow: 86_400_000, maxRequests: 10_000 }, + }, +]) +``` + +Best practices: + +- Keep hashing enabled. +- Use distinct prefixes for human debugging and incident response. +- Prefer organization-referenced keys for shared service integrations. +- Do not enable `enableSessionForAPIKeys` unless the app explicitly accepts API keys acting like user sessions. +- On D1, database-backed rate limiting is acceptable for moderate control, but not a substitute for edge/WAF-level abuse controls. +- If latency becomes a problem, `deferUpdates` exists but requires `advanced.backgroundTasks.handler` and accepts eventual consistency. + +### Expo + +Use when an Expo or React Native client needs native session/cookie handling and deep-link callbacks. + +Package evidence confirms: + +- Server import: `import { expo } from "@better-auth/expo"` +- Client import: `import { expoClient } from "@better-auth/expo/client"` +- Expo client should use `expo-secure-store` for secure session/cookie storage. +- `trustedOrigins` must include the app scheme, for example `cfwauth://`. + +`cfw-auth` choice: + +```ts +expo() +``` + +and: + +```ts +export function trustedOrigins(env: Env): string[] { + return [...csvEnv(env.TRUSTED_ORIGINS), ...expoOrigins(env)]; +} + +export function expoOrigins(env: Env): string[] { + return csvEnv(env.EXPO_SCHEME).map((scheme) => `${scheme}://`); +} +``` + +Best practices: + +- Keep Expo scheme configuration explicit through `EXPO_SCHEME`. +- The Worker does not need Expo client packages; the mobile app does. +- Configure `baseURL` to the Worker auth URL and ensure CORS accepts the web origins that need cookies. + +### Captcha + +Use for high-abuse public endpoints such as email sign-up, email sign-in, and password reset. + +Installed package evidence confirms: + +- Plugin id: `captcha` +- It runs as `onRequest` protection and does not add schema. +- Current repo uses Cloudflare Turnstile when `CAPTCHA_PROVIDER=cloudflare-turnstile` and `CAPTCHA_SECRET_KEY` are set. + +`cfw-auth` choice: + +```ts +captcha({ + provider: "cloudflare-turnstile", + secretKey: env.CAPTCHA_SECRET_KEY, + endpoints: ["/sign-up/email", "/sign-in/email", "/forget-password"], +}) +``` + +Best practices: + +- Keep the Turnstile secret in Wrangler secrets. +- Protect only high-risk public endpoints first; do not force CAPTCHA into every authenticated request. +- Treat CAPTCHA as abuse reduction, not account security. + +### Generic OAuth + +Use when a provider is not one of Better Auth's built-in social providers or when an enterprise IdP is configured from discovery metadata. + +Installed package evidence confirms: + +- Plugin id: `generic-oauth` +- It adds OAuth2 sign-in, callback, and account-linking endpoints. +- It contributes social providers through plugin initialization and reuses the normal `account` table. + +`cfw-auth` choice: + +```ts +genericOAuth({ + config: [ + { + providerId: env.GENERIC_OAUTH_PROVIDER_ID, + discoveryUrl: env.GENERIC_OAUTH_DISCOVERY_URL, + clientId: env.GENERIC_OAUTH_CLIENT_ID, + clientSecret: env.GENERIC_OAUTH_CLIENT_SECRET, + scopes: ["openid", "email", "profile"], + }, + ], +}) +``` + +Best practices: + +- Keep provider config in env so one Worker build can serve different environments. +- Prefer OIDC discovery URLs over hand-maintained endpoint URLs when the provider supports discovery. +- Keep provider IDs stable; changing them can split account linkage. + +### JWT and Bearer + +Use JWT when another service needs a signed token and JWKS endpoint. Use Bearer when API callers need to present bearer tokens to Better Auth endpoints. + +Installed package evidence confirms: + +- `jwt` plugin id: `jwt` +- `jwt` adds token/JWKS/sign/verify endpoints. +- `jwt` adds a `jwks` schema with public/private key material and key expiry. +- `bearer` plugin id: `bearer` +- `bearer` is hook-only and does not add schema. + +`cfw-auth` choice: + +```ts +if (booleanEnv(env.ENABLE_JWT)) { + plugins.push(jwt()); +} + +if (booleanEnv(env.ENABLE_BEARER)) { + plugins.push(bearer()); +} +``` + +Best practices: + +- Keep JWT disabled until a concrete downstream service needs it. +- Do not replace browser cookie sessions with JWT. +- Because `jwt` creates `jwks`, keep it in `src/auth.migration.ts` even if runtime activation is env-gated. +- Keep Bearer disabled until a client actually needs token-to-session conversion. + +## Cloudflare Runtime Checklist + +- Use D1 binding directly as the Better Auth database for current Better Auth versions. +- Keep `BETTER_AUTH_SECRET`, provider API keys, CAPTCHA secret, and SMS provider secrets in Wrangler secrets. +- Keep non-secret URL/origin/provider toggles in `wrangler.jsonc`. +- Use `ctx.executionCtx.waitUntil` through Better Auth `advanced.backgroundTasks.handler`. +- Use `fetch`, Web Crypto-compatible code, and Worker-safe APIs in adapters. +- Generate SQL with Better Auth CLI, commit it, and apply with `wrangler d1 migrations apply`. +- Re-run `pnpm db:check`, `pnpm typecheck`, and `pnpm test` after plugin changes. + +## Minimal Domain Model + +### Business Slice + +Workflow: a person or integration authenticates to a Cloudflare-hosted auth service, joins or creates an organization, and may issue an API key for user or organization access. + +Actors: user, organization owner/admin/member, invited user, API client, Expo mobile client. + +Commands: + +- Sign up or sign in by email/password, username/password, phone OTP, OAuth, passkey, or Expo-backed client flow. +- Verify email or phone. +- Create organization, invite member, accept invitation, set active organization/team. +- Create, verify, update, revoke, or list API keys. + +Events: + +- UserRegistered +- IdentifierVerified +- OrganizationCreated +- MemberInvited +- InvitationAccepted +- ApiKeyCreated +- ApiKeyVerified +- ApiKeyRevoked + +Policies: + +- Email verification gates invitation acceptance. +- Phone sign-up requires OTP verification. +- API keys are named, hashed, rate-limited, and scoped to user or organization reference. +- Expo schemes must be trusted origins. + +### Minimal Core Model + +| Concept | Type | Meaning | Key rules | +| --- | --- | --- | --- | +| User | Entity | Human account in Better Auth. | Owns identifiers and sessions. | +| Identifier | Value object | Email, username, phone, passkey, OAuth account. | Verification rules differ by identifier type. | +| Organization | Entity | Tenant boundary. | Has unique slug and membership. | +| Membership | Entity | User's role in an organization or team. | Permission checks should flow through membership. | +| Invitation | Entity | Pending request to join an organization. | Email proof and expiry matter. | +| API Key | Entity | Machine credential scoped to a reference. | Key value is secret and hashed; lifecycle is revocable. | +| Client Runtime | Boundary | Web, Expo native, API client. | Affects transport and storage, not core identity semantics. | + +### Invariants + +- A username, email, and phone number must not identify two users. +- A phone login/registration must not complete without valid OTP verification when `requireVerification` is enabled. +- An API key's raw value is only shown at creation; persisted value must be hashed. +- Organization membership is the authority for organization permissions. +- D1 migration SQL must match the runtime plugin set. + +### Aggregates / Consistency Boundaries + +| Aggregate | Root | Invariants protected | Outside coordination | +| --- | --- | --- | --- | +| Account | User | Unique identifiers, verification status, password/session policy. | Email/SMS/OAuth providers. | +| Organization | Organization | Slug, teams, memberships, invitations. | Email invitation delivery, app-specific billing/entitlements. | +| API Key | API key | Hashing, ownership reference, revocation, rate counters. | Edge/WAF abuse controls, downstream API authorization. | + +### Bounded Contexts + +| Context | Owns | Language boundary | Integrates with | Translation needed | +| --- | --- | --- | --- | --- | +| Auth | users, sessions, identifiers, verification | Identity proof and session lifecycle | Email, SMS, OAuth, passkey | Provider payloads to Better Auth user/account model | +| Tenant | organizations, members, teams, invitations | B2B access and membership | Auth users, app domains | Organization/member projection to app authorization | +| Credential | API keys | Machine access and key lifecycle | Tenant, downstream APIs | API key verification result to service principal | +| Client Integration | Expo/web/API clients | Transport, redirect, secure storage | Auth endpoints | Scheme/origin/client storage to Better Auth client plugins | + +### Edge Complexity + +- SMS providers stay behind `src/sms.ts`. +- Email providers stay behind `src/email.ts`. +- OAuth providers stay behind `src/oauth.ts`. +- Expo scheme handling stays in `trustedOrigins`. +- D1 migration generation stays in `src/auth.migration.ts`. + +### Change Tests + +| Change | Expected impact | Boundary result | +| --- | --- | --- | +| Add a new SMS vendor | Edit `src/sms.ts` and env docs only. | Good adapter boundary. | +| Add paid organization limits | Add organization policy/hook or billing integration, not new auth identity model. | Tenant boundary absorbs it. | +| Add mobile app scheme | Update `EXPO_SCHEME` and trusted origin tests. | Client boundary absorbs it. | +| Add service account keys | Extend API key metadata/permissions or add a new config id. | Credential boundary absorbs it. | +| Switch D1 migration strategy | Update migration docs/scripts, not runtime plugin semantics. | Persistence boundary absorbs it. | + +## Research Limits and Next Questions + +- The official docs site is dynamically rendered; package type definitions are the most precise local evidence for the installed version. +- This note does not decide UI workflows for accepting invitations, phone-only users adding email, or API key permission statements. +- Before production, add threat modeling for phone number enumeration, OTP abuse, API key leakage, and organization invitation takeover. diff --git a/docs/better-auth-plugin-compass.md b/docs/better-auth-plugin-compass.md new file mode 100644 index 0000000..34d1a06 --- /dev/null +++ b/docs/better-auth-plugin-compass.md @@ -0,0 +1,130 @@ +# Better Auth Plugin Compass + +This note is the project-level compass for choosing Better Auth plugins. It combines the current official plugin ecosystem, installed package evidence, and `cfw-auth`'s Cloudflare/D1 constraints. + +## Source Baseline + +- Better Auth official plugin overview, v1.6: `https://better-auth.com/docs/plugins` +- Better Auth plugin mechanism docs: `https://better-auth.com/docs/concepts/plugins` +- Official plugin pages for 2FA, passkey, organization, API key, JWT, Stripe, and community plugins +- Installed package type definitions under `node_modules/better-auth` and `node_modules/@better-auth/*` +- Local runtime config: `src/plugins.ts` +- Local schema-generation config: `src/auth.migration.ts` + +Important rule: official docs establish product intent; installed package types establish the exact API shape this repo can compile against. + +## Ecosystem Map + +| Category | Plugins | Primary use | +| --- | --- | --- | +| Login and identity proof | `twoFactor`, `passkey`, `magicLink`, `emailOTP`, `phoneNumber`, `anonymous`, `username`, `oneTap`, `siwe`, `genericOAuth`, `multiSession`, `lastLoginMethod` | Human authentication, passwordless flows, account recovery, session UX | +| Authorization and management | `admin`, `organization`, `sso`, `scim` | Admin controls, B2B tenants, enterprise SSO, directory sync | +| API, token, and machine access | `agentAuth`, `apiKey`, `jwt`, `bearer`, `oneTimeToken`, `oauthProxy` | API clients, service access, token exchange, agent flows | +| Identity-provider surface | `oauthProvider`, `oidcProvider`, `mcp`, `deviceAuthorization` | Turning the app into an OAuth/OIDC/MCP identity provider | +| Billing and entitlement | `stripe`, `polar`, `autumn`, `creem`, `dodopayments`, `commet` | Subscription, checkout, seats, usage-based pricing | +| Security and operations | `captcha`, `haveIBeenPwned`, `i18n`, `openAPI`, `testUtils` | Abuse prevention, password safety, localized errors, docs, tests | +| Analytics and tracking | `dub` | Lead tracking and OAuth/link attribution | + +Community plugins are useful for ecosystem gaps such as LDAP, audit logs, Firebase Auth, payment providers, wallet chains, university email checks, or devtools. Treat them as third-party code: review maintenance, schema changes, runtime compatibility, and test coverage before adopting them. + +## `cfw-auth` Current Plugin Set + +Default runtime plugins: + +- `openAPI` +- `i18n` +- `haveIBeenPwned` +- `emailOTP` +- `twoFactor` +- `multiSession` +- `lastLoginMethod` +- `admin` +- `username` +- `phoneNumber` +- `organization` +- `apiKey` +- `expo` + +Conditional runtime plugins: + +- `captcha`, when `CAPTCHA_PROVIDER=cloudflare-turnstile` and `CAPTCHA_SECRET_KEY` are set +- `genericOAuth`, when generic OAuth provider env vars are set +- `passkey`, when `PASSKEY_RP_ID`, `PASSKEY_RP_NAME`, and `PASSKEY_ORIGIN` are set +- `jwt`, when `ENABLE_JWT=true` +- `bearer`, when `ENABLE_BEARER=true` + +Migration-only static config must include every plugin that can affect schema or generated endpoint metadata. In this repo that means `src/auth.migration.ts` intentionally includes dummy config for conditional plugins. The important schema-bearing addition is `jwt`, which creates the `jwks` table. + +## Usage Patterns + +Server config: + +```ts +import { betterAuth } from "better-auth"; +import { i18n } from "@better-auth/i18n"; +import { passkey } from "@better-auth/passkey"; +import { jwt, organization, twoFactor } from "better-auth/plugins"; + +export const auth = betterAuth({ + database: env.DB, + plugins: [ + i18n({ + translations: { + en: {}, + zh: { USER_NOT_FOUND: "用户不存在。" }, + }, + detection: ["header", "cookie"], + }), + organization(), + twoFactor(), + passkey(), + jwt(), + ], +}); +``` + +Client config, when a plugin exposes a client plugin: + +```ts +import { createAuthClient } from "better-auth/client"; +import { i18nClient } from "@better-auth/i18n/client"; +import { twoFactorClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [ + i18nClient(), + twoFactorClient(), + ], +}); +``` + +## Best Practices + +- Choose plugins from workflow pressure, not from the catalog. Every plugin can add endpoints, hooks, schema, client actions, and security review surface. +- Keep server and client plugin choices paired. Some server plugins need a matching client plugin for typed client actions. +- After changing schema-affecting plugins, run `pnpm db:generate`, inspect SQL, then run `pnpm db:check`. +- Keep conditional runtime plugins represented in `src/auth.migration.ts` when they can affect schema or generated API shape. +- Do not treat `jwt` as a session replacement. Use it for services that need JWT/JWKS, and keep cookie sessions as the normal browser auth path. +- Use `bearer` only for clients that must send bearer tokens to auth endpoints. It widens token-acceptance behavior. +- Keep `captcha` on high-abuse endpoints such as email sign-up, email sign-in, and password reset. Turnstile secrets belong in Worker secrets. +- Keep `genericOAuth` provider config behind env and avoid hard-coding enterprise IdP details. +- Treat `organization` as the tenant boundary. App tables should consume organization/member projections rather than becoming a second source of auth truth. +- Keep API keys hashed, named, prefixed, scoped, and revocable. Organization-owned keys are better for shared integrations. +- Review community plugins like any third-party dependency: schema, hooks, secrets, runtime assumptions, release activity, and tests. + +## Simplicity Boundary + +The stable core for this project is: + +```text +User + Session + Identifier + Organization + Membership + API Key +``` + +Everything else is an edge capability: + +- `captcha`, `haveIBeenPwned`, and `i18n` are auth-flow protection and presentation edges. +- `genericOAuth`, `passkey`, `phoneNumber`, and `emailOTP` are identity proof adapters. +- `jwt`, `bearer`, and `apiKey` are machine/API access edges. +- `expo` is client runtime integration. + +Do not move billing, enterprise SSO, SCIM, MCP provider, or OAuth/OIDC provider concerns into the core until a real caller needs them. diff --git a/docs/schema/better-auth-target.sql b/docs/schema/better-auth-target.sql new file mode 100644 index 0000000..b560155 --- /dev/null +++ b/docs/schema/better-auth-target.sql @@ -0,0 +1,61 @@ +create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null, "twoFactorEnabled" integer, "lastLoginMethod" text, "role" text, "banned" integer, "banReason" text, "banExpires" date, "username" text unique, "displayUsername" text, "phoneNumber" text unique, "phoneNumberVerified" integer); + +create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade, "impersonatedBy" text, "activeOrganizationId" text, "activeTeamId" text); + +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" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null); + +create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date not null, "updatedAt" date not null); + +create table "twoFactor" ("id" text not null primary key, "secret" text not null, "backupCodes" text not null, "userId" text not null references "user" ("id") on delete cascade, "verified" integer); + +create table "passkey" ("id" text not null primary key, "name" text, "publicKey" text not null, "userId" text not null references "user" ("id") on delete cascade, "credentialID" text not null, "counter" integer not null, "deviceType" text not null, "backedUp" integer not null, "transports" text, "createdAt" date, "aaguid" text); + +create table "organization" ("id" text not null primary key, "name" text not null, "slug" text not null unique, "logo" text, "createdAt" date not null, "metadata" text); + +create table "team" ("id" text not null primary key, "name" text not null, "organizationId" text not null references "organization" ("id") on delete cascade, "createdAt" date not null, "updatedAt" date); + +create table "teamMember" ("id" text not null primary key, "teamId" text not null references "team" ("id") on delete cascade, "userId" text not null references "user" ("id") on delete cascade, "createdAt" date); + +create table "member" ("id" text not null primary key, "organizationId" text not null references "organization" ("id") on delete cascade, "userId" text not null references "user" ("id") on delete cascade, "role" text not null, "createdAt" date not null); + +create table "invitation" ("id" text not null primary key, "organizationId" text not null references "organization" ("id") on delete cascade, "email" text not null, "role" text, "teamId" text, "status" text not null, "expiresAt" date not null, "createdAt" date not null, "inviterId" text not null references "user" ("id") on delete cascade); + +create table "apikey" ("id" text not null primary key, "configId" text not null, "name" text, "start" text, "referenceId" text not null, "prefix" text, "key" text not null, "refillInterval" integer, "refillAmount" integer, "lastRefillAt" date, "enabled" integer, "rateLimitEnabled" integer, "rateLimitTimeWindow" integer, "rateLimitMax" integer, "requestCount" integer, "remaining" integer, "lastRequest" date, "expiresAt" date, "createdAt" date not null, "updatedAt" date not null, "permissions" text, "metadata" text); + +create table "jwks" ("id" text not null primary key, "publicKey" text not null, "privateKey" text not null, "createdAt" date not null, "expiresAt" date); + +create index "session_userId_idx" on "session" ("userId"); + +create index "account_userId_idx" on "account" ("userId"); + +create index "verification_identifier_idx" on "verification" ("identifier"); + +create index "twoFactor_secret_idx" on "twoFactor" ("secret"); + +create index "twoFactor_userId_idx" on "twoFactor" ("userId"); + +create index "passkey_userId_idx" on "passkey" ("userId"); + +create index "passkey_credentialID_idx" on "passkey" ("credentialID"); + +create unique index "organization_slug_uidx" on "organization" ("slug"); + +create index "team_organizationId_idx" on "team" ("organizationId"); + +create index "teamMember_teamId_idx" on "teamMember" ("teamId"); + +create index "teamMember_userId_idx" on "teamMember" ("userId"); + +create index "member_organizationId_idx" on "member" ("organizationId"); + +create index "member_userId_idx" on "member" ("userId"); + +create index "invitation_organizationId_idx" on "invitation" ("organizationId"); + +create index "invitation_email_idx" on "invitation" ("email"); + +create index "apikey_configId_idx" on "apikey" ("configId"); + +create index "apikey_referenceId_idx" on "apikey" ("referenceId"); + +create index "apikey_key_idx" on "apikey" ("key"); \ No newline at end of file diff --git a/docs/superpowers/plans/2026-06-11-better-auth-performance.md b/docs/superpowers/plans/2026-06-11-better-auth-performance.md new file mode 100644 index 0000000..771cc0e --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-better-auth-performance.md @@ -0,0 +1,775 @@ +# Better Auth 接口响应时间优化实现计划 + +> **给 agent 执行者:** 必选子技能:使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans` 按任务逐项实现本计划。步骤统一使用 checkbox(`- [ ]`)语法跟踪。 + +**目标:** 为 `cfw-auth` 的 Better Auth 热路径加入低风险性能配置和安全的请求耗时观测。 + +**方案概览:** 先抽出性能配置解析函数,再把 session cookie cache 和 API key `deferUpdates` 接入 Better Auth 配置。随后在 Worker 入口增加不改写响应的结构化观测包装,补测试和运维文档,最后验证 migration、typecheck、测试全部通过。 + +**技术栈:** Cloudflare Workers、Hono、Better Auth、D1、TypeScript、Vitest、Wrangler + +--- + +## 文件结构 + +- 新建:`src/performance.ts` + - 负责解析性能相关 env:session cookie cache TTL、API key defer 开关。 + - 不依赖 Better Auth、Hono 或 Worker 请求对象,便于单元测试。 + +- 新建:`src/observability.ts` + - 负责 `/api/auth/*` 请求的耗时观测。 + - 提供 `withAuthRequestLogging(request, handler)`,只包装 Response,不读取请求体,不记录敏感 header。 + +- 修改:`src/env.ts` + - 在 `Env` 接口中加入 `BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE` 和 `API_KEY_DEFER_UPDATES`。 + +- 修改:`src/auth.ts` + - 使用 `authPerformanceConfig(env)` 设置 Better Auth `session.cookieCache`。 + - 继续保留现有 `advanced.backgroundTasks.handler`。 + +- 修改:`src/plugins.ts` + - 使用 `authPerformanceConfig(env).apiKeyDeferUpdates` 为两个 `apiKey` config 设置 `deferUpdates`。 + +- 修改:`src/index.ts` + - 在 `/api/auth/*` handler 中使用 `withAuthRequestLogging(...)` 包裹 Better Auth handler。 + +- 修改:`wrangler.jsonc` + - 增加非 secret 默认变量。 + - 增加 Cloudflare `observability` 配置。 + - 不强制增加 `placement.mode = "smart"`。 + +- 修改:`tests/auth-config.test.ts` + - 覆盖性能配置解析、Better Auth session cookie cache、API key defer 开关。 + +- 修改:`tests/auth-worker.test.ts` + - 覆盖观测日志字段、敏感字段过滤、响应不被改写。 + +- 修改:`docs/auth-operations.md` + - 记录性能配置、观测、生产验证和后续 Smart Placement 决策门槛。 + +## 注意事项 + +- 当前工作树已经有账号中心插件相关未提交改动。执行本计划时不要回退那些改动。 +- 性能配置不应改变 Better Auth schema;`pnpm db:check` 应保持 migration up to date。 +- Better Auth 1.6.x 的 `session.cookieCache` 支持 `enabled` 和 `maxAge`。不要启用 `refreshCache`,它更适合 stateless/DB-less 场景。 +- `@better-auth/api-key` 的 `deferUpdates` 要求主 Better Auth 配置存在 `advanced.backgroundTasks.handler`。当前 `src/auth.ts` 已通过 Worker `waitUntil` 提供该 handler。 + +### 任务 1:抽出性能配置解析 + +**文件:** +- 新建:`src/performance.ts` +- 修改:`src/env.ts` +- 测试:`tests/auth-config.test.ts` + +- [ ] **步骤 1:先写失败测试** + +在 `tests/auth-config.test.ts` 的 import 中加入: + +```ts +import { authPerformanceConfig } from "../src/performance"; +``` + +在 `auth env helpers` describe 后追加: + +```ts +describe("auth performance config", () => { + it("uses conservative defaults", () => { + expect(authPerformanceConfig(env)).toEqual({ + sessionCookieCacheMaxAge: 300, + apiKeyDeferUpdates: true, + }); + }); + + it("parses session cookie cache max age from env", () => { + expect( + authPerformanceConfig({ + ...env, + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "60", + }).sessionCookieCacheMaxAge, + ).toBe(60); + }); + + it("falls back for invalid session cookie cache max age", () => { + expect( + authPerformanceConfig({ + ...env, + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "0", + }).sessionCookieCacheMaxAge, + ).toBe(300); + expect( + authPerformanceConfig({ + ...env, + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "not-a-number", + }).sessionCookieCacheMaxAge, + ).toBe(300); + }); + + it("allows disabling api key deferred updates", () => { + expect(authPerformanceConfig({ ...env, API_KEY_DEFER_UPDATES: "false" })).toMatchObject({ + apiKeyDeferUpdates: false, + }); + expect(authPerformanceConfig({ ...env, API_KEY_DEFER_UPDATES: "true" })).toMatchObject({ + apiKeyDeferUpdates: true, + }); + }); +}); +``` + +- [ ] **步骤 2:运行测试,确认它先失败** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts +``` + +预期:FAIL,错误包含 `Failed to resolve import "../src/performance"` 或 `Cannot find module '../src/performance'`。 + +- [ ] **步骤 3:编写最小实现** + +在 `src/env.ts` 的 `Env` interface 中加入: + +```ts + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE?: string; + API_KEY_DEFER_UPDATES?: string; +``` + +新建 `src/performance.ts`: + +```ts +import { type Env } from "./env"; + +export interface AuthPerformanceConfig { + sessionCookieCacheMaxAge: number; + apiKeyDeferUpdates: boolean; +} + +const defaultSessionCookieCacheMaxAge = 300; + +export function authPerformanceConfig(env: Env): AuthPerformanceConfig { + return { + sessionCookieCacheMaxAge: positiveIntegerEnv( + env.BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE, + defaultSessionCookieCacheMaxAge, + ), + apiKeyDeferUpdates: env.API_KEY_DEFER_UPDATES !== "false", + }; +} + +function positiveIntegerEnv(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} +``` + +- [ ] **步骤 4:再次运行测试,确认通过** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts +``` + +预期:PASS。 + +- [ ] **步骤 5:提交** + +```bash +git add src/env.ts src/performance.ts tests/auth-config.test.ts +git commit -m "feat: add auth performance config" +``` + +### 任务 2:接入 session cookie cache + +**文件:** +- 修改:`src/auth.ts` +- 测试:`tests/auth-config.test.ts` + +- [ ] **步骤 1:先写失败测试** + +在 `tests/auth-config.test.ts` 顶部加入: + +```ts +import { createAuth } from "../src/auth"; +``` + +在 `auth plugins` describe 后追加: + +```ts +describe("auth runtime performance options", () => { + it("enables session cookie cache with configured max age", () => { + const auth = createAuth({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "60", + }); + + expect(auth.options.session?.cookieCache).toEqual({ + enabled: true, + maxAge: 60, + }); + }); +}); +``` + +- [ ] **步骤 2:运行测试,确认它先失败** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts +``` + +预期:FAIL,断言显示 `auth.options.session?.cookieCache` 为 `undefined`。 + +- [ ] **步骤 3:编写最小实现** + +在 `src/auth.ts` 中加入 import: + +```ts +import { authPerformanceConfig } from "./performance"; +``` + +在 `createAuth` 函数开头加入: + +```ts + const performance = authPerformanceConfig(env); +``` + +在 `betterAuth({ ... })` 配置中,放在 `trustedOrigins` 后面加入: + +```ts + session: { + cookieCache: { + enabled: true, + maxAge: performance.sessionCookieCacheMaxAge, + }, + }, +``` + +完整目标形状: + +```ts +export function createAuth(env: Env, runtime: AuthRuntime = {}) { + const performance = authPerformanceConfig(env); + + return betterAuth({ + database: env.DB, + secret: env.BETTER_AUTH_SECRET ?? "development-secret-change-before-production", + baseURL: env.BETTER_AUTH_URL, + trustedOrigins: trustedOrigins(env), + session: { + cookieCache: { + enabled: true, + maxAge: performance.sessionCookieCacheMaxAge, + }, + }, + socialProviders: createSocialProviders(env), + // keep existing options unchanged + }); +} +``` + +- [ ] **步骤 4:再次运行测试,确认通过** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts +``` + +预期:PASS。 + +- [ ] **步骤 5:确认 migration 没有被性能配置影响** + +运行: + +```bash +pnpm db:check +``` + +预期:PASS,并输出 committed migration is up to date。 + +- [ ] **步骤 6:提交** + +```bash +git add src/auth.ts tests/auth-config.test.ts +git commit -m "feat: enable auth session cookie cache" +``` + +### 任务 3:接入 API key 延迟更新开关 + +**文件:** +- 修改:`src/plugins.ts` +- 测试:`tests/auth-config.test.ts` + +- [ ] **步骤 1:先写失败测试** + +在 `tests/auth-config.test.ts` 的 `auth plugins` describe 中追加: + +```ts + it("enables api key deferred updates by default", () => { + const plugins = createAuthPlugins({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + }); + const apiKeyPlugin = plugins.find((plugin) => plugin.id === "api-key"); + + expect(apiKeyPlugin?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + configId: "default", + deferUpdates: true, + }), + expect.objectContaining({ + configId: "organization", + deferUpdates: true, + }), + ]), + ); + }); + + it("allows disabling api key deferred updates", () => { + const plugins = createAuthPlugins({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + API_KEY_DEFER_UPDATES: "false", + }); + const apiKeyPlugin = plugins.find((plugin) => plugin.id === "api-key"); + + expect(apiKeyPlugin?.options).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + configId: "default", + deferUpdates: false, + }), + expect.objectContaining({ + configId: "organization", + deferUpdates: false, + }), + ]), + ); + }); +``` + +- [ ] **步骤 2:运行测试,确认它先失败** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts +``` + +预期:FAIL,`deferUpdates` 为 `undefined`。 + +- [ ] **步骤 3:编写最小实现** + +在 `src/plugins.ts` 中加入 import: + +```ts +import { authPerformanceConfig } from "./performance"; +``` + +在 `createAuthPlugins` 函数开头加入: + +```ts + const performance = authPerformanceConfig(env); +``` + +在两个 `apiKey` 配置对象中都加入: + +```ts + deferUpdates: performance.apiKeyDeferUpdates, +``` + +目标形状示例: + +```ts + { + configId: "default", + defaultPrefix: "cfw_", + requireName: true, + enableMetadata: true, + deferUpdates: performance.apiKeyDeferUpdates, + rateLimit: { + enabled: true, + timeWindow: 1_000 * 60 * 60 * 24, + maxRequests: 1_000, + }, + }, +``` + +- [ ] **步骤 4:再次运行测试,确认通过** + +运行: + +```bash +pnpm vitest run tests/auth-config.test.ts +``` + +预期:PASS。 + +- [ ] **步骤 5:提交** + +```bash +git add src/plugins.ts tests/auth-config.test.ts +git commit -m "feat: defer api key counter updates" +``` + +### 任务 4:新增认证请求观测包装 + +**文件:** +- 新建:`src/observability.ts` +- 修改:`src/index.ts` +- 测试:`tests/auth-worker.test.ts` + +- [ ] **步骤 1:先写失败测试** + +在 `tests/auth-worker.test.ts` import 中改为: + +```ts +import { afterEach, describe, expect, it, vi } from "vitest"; +``` + +在 `const env` 后加入: + +```ts +afterEach(() => { + vi.restoreAllMocks(); +}); +``` + +在 `cfw-auth worker` describe 中追加: + +```ts + it("logs auth request timing without changing the response", async () => { + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + const response = await worker.fetch( + new Request("http://auth.local/api/auth/reference", { + headers: { + "cf-ray": "test-ray", + Authorization: "Bearer secret-token", + Cookie: "better-auth.session_token=secret-cookie", + }, + }), + env, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type") ?? "").toContain("text/html"); + expect(log).toHaveBeenCalledTimes(1); + + const [rawMessage] = log.mock.calls[0] ?? []; + expect(typeof rawMessage).toBe("string"); + const message = String(rawMessage); + const event = JSON.parse(message) as Record; + + expect(event).toMatchObject({ + event: "auth_request", + method: "GET", + path: "/api/auth/reference", + status: 200, + cfRay: "test-ray", + }); + expect(typeof event.durationMs).toBe("number"); + expect(message).not.toContain("secret-token"); + expect(message).not.toContain("secret-cookie"); + expect(message.toLowerCase()).not.toContain("authorization"); + expect(message.toLowerCase()).not.toContain("cookie"); + }); +``` + +- [ ] **步骤 2:运行测试,确认它先失败** + +运行: + +```bash +pnpm vitest run tests/auth-worker.test.ts +``` + +预期:FAIL,`console.log` 没有被调用。 + +- [ ] **步骤 3:编写最小实现** + +新建 `src/observability.ts`: + +```ts +export type AuthRequestHandler = () => Response | Promise; + +export async function withAuthRequestLogging( + request: Request, + handler: AuthRequestHandler, +): Promise { + const startedAt = Date.now(); + let response: Response; + + try { + response = await handler(); + return response; + } finally { + const status = response?.status ?? 500; + logAuthRequest(request, status, Date.now() - startedAt); + } +} + +function logAuthRequest(request: Request, status: number, durationMs: number): void { + try { + const event = { + event: "auth_request", + method: request.method, + path: new URL(request.url).pathname, + status, + durationMs, + colo: request.cf?.colo, + cfRay: request.headers.get("cf-ray") ?? undefined, + }; + + console.log(JSON.stringify(event)); + } catch { + // Observability must not affect auth responses. + } +} +``` + +修改 `src/index.ts`,加入 import: + +```ts +import { withAuthRequestLogging } from "./observability"; +``` + +把现有 auth handler: + +```ts +app.on(["POST", "GET"], "/api/auth/*", (c) => { + const auth = createAuth(c.env, { + waitUntil: (promise) => { + c.executionCtx.waitUntil(promise); + }, + }); + return auth.handler(c.req.raw); +}); +``` + +改成: + +```ts +app.on(["POST", "GET"], "/api/auth/*", (c) => { + const auth = createAuth(c.env, { + waitUntil: (promise) => { + c.executionCtx.waitUntil(promise); + }, + }); + return withAuthRequestLogging(c.req.raw, () => auth.handler(c.req.raw)); +}); +``` + +- [ ] **步骤 4:再次运行测试,确认通过** + +运行: + +```bash +pnpm vitest run tests/auth-worker.test.ts +``` + +预期:PASS。 + +- [ ] **步骤 5:提交** + +```bash +git add src/index.ts src/observability.ts tests/auth-worker.test.ts +git commit -m "feat: log auth request latency" +``` + +### 任务 5:补充 Wrangler 和运维文档 + +**文件:** +- 修改:`wrangler.jsonc` +- 修改:`docs/auth-operations.md` + +- [ ] **步骤 1:更新 Wrangler 配置** + +在 `wrangler.jsonc` 的 `vars` 中加入: + +```jsonc + "BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE": "300", + "API_KEY_DEFER_UPDATES": "true" +``` + +在顶层加入: + +```jsonc + "observability": { + "enabled": true, + "head_sampling_rate": 0.1 + }, +``` + +目标结构示例: + +```jsonc +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "cfw-auth", + "main": "src/index.ts", + "compatibility_date": "2026-06-10", + "compatibility_flags": [ + "nodejs_compat" + ], + "observability": { + "enabled": true, + "head_sampling_rate": 0.1 + }, + "vars": { + "BETTER_AUTH_URL": "http://localhost:8788", + "TRUSTED_ORIGINS": "http://localhost:8787", + "MAIL_PROVIDER": "resend", + "MAIL_FROM": "noreply@example.com", + "CAPTCHA_PROVIDER": "cloudflare-turnstile", + "BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE": "300", + "API_KEY_DEFER_UPDATES": "true" + }, + "d1_databases": [ + { + "binding": "DB", + "database_name": "cfw-auth", + "database_id": "90fe25a4-9d22-43a6-9ade-3a15afc3ab47" + } + ] +} +``` + +- [ ] **步骤 2:更新运维文档** + +在 `docs/auth-operations.md` 的 `Configuration` 小节中,追加: + +```md +Performance-related non-secret defaults live in `wrangler.jsonc`: + +- `BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE`: Better Auth session cookie cache TTL in seconds. Default is `300`. Lower it if session revocation or role changes must propagate faster. +- `API_KEY_DEFER_UPDATES`: When `true`, API key request counters and timestamps are deferred through Better Auth background tasks and Worker `waitUntil`. + +Auth request latency is logged as structured JSON with: + +- `event=auth_request` +- `method` +- `path` +- `status` +- `durationMs` +- optional `colo` +- optional `cfRay` + +Do not log request bodies, cookies, authorization headers, API keys, emails, phone numbers, or OTP codes. +``` + +在 `Deployment` 小节后追加: + +```md +## Performance Validation + +After deployment, compare p50, p95, and p99 for: + +- `/api/auth/get-session` +- `/api/auth/api-key/verify` +- phone OTP endpoints +- organization invitation endpoints + +Enable Cloudflare Smart Placement only after logs show that latency is dominated by D1 or external provider round trips. Evaluate D1 read replication or secondary storage only after the first-stage cache and deferred-update changes are measured in production. +``` + +- [ ] **步骤 3:验证配置 schema 和文档改动** + +运行: + +```bash +pnpm typecheck +``` + +预期:PASS。 + +- [ ] **步骤 4:提交** + +```bash +git add wrangler.jsonc docs/auth-operations.md +git commit -m "docs: document auth performance operations" +``` + +### 任务 6:全量验证和最终提交检查 + +**文件:** +- 修改:无新文件,验证全项目状态。 + +- [ ] **步骤 1:运行 migration 检查** + +运行: + +```bash +pnpm db:check +``` + +预期:PASS,并显示 migration up to date。若失败且 diff 只来自性能配置,说明实现错误;性能配置不应影响 schema。 + +- [ ] **步骤 2:运行类型检查** + +运行: + +```bash +pnpm typecheck +``` + +预期:PASS。 + +- [ ] **步骤 3:运行测试** + +运行: + +```bash +pnpm test +``` + +预期:PASS。 + +- [ ] **步骤 4:查看最终 diff** + +运行: + +```bash +git status --short +git diff -- src/performance.ts src/observability.ts src/env.ts src/auth.ts src/plugins.ts src/index.ts tests/auth-config.test.ts tests/auth-worker.test.ts wrangler.jsonc docs/auth-operations.md +``` + +预期:只看到本计划相关文件改动。不要回退或改动当前工作树里的其它账号中心文件。 + +- [ ] **步骤 5:提交最终验证记录** + +如果前面每个任务都已提交,且这一步没有新增文件改动,则不需要创建空提交。若本任务中修正了测试或文档小问题,则提交: + +```bash +git add src/performance.ts src/observability.ts src/env.ts src/auth.ts src/plugins.ts src/index.ts tests/auth-config.test.ts tests/auth-worker.test.ts wrangler.jsonc docs/auth-operations.md +git commit -m "test: verify auth performance optimization" +``` + +## Spec 覆盖自检 + +- session cookie cache:任务 1、任务 2 覆盖。 +- API key `deferUpdates`:任务 1、任务 3 覆盖。 +- `/api/auth/*` 结构化耗时日志:任务 4 覆盖。 +- 敏感字段不记录:任务 4 覆盖。 +- 配置可关闭或调低:任务 1、任务 3、任务 5 覆盖。 +- 不改变迁移:任务 2、任务 6 覆盖。 +- 运维与生产验证说明:任务 5 覆盖。 + +## 计划自检结论 + +- 无未完成标记或空白段落。 +- 每个代码改动任务都有先失败测试、最小实现、验证命令和提交步骤。 +- 类型、函数名和配置名在所有任务中保持一致。 +- 计划只覆盖第一阶段低风险性能优化,没有混入存储层重构或 Smart Placement 强制启用。 diff --git a/migrations/0001_baseline_existing_auth.sql b/migrations/0001_baseline_existing_auth.sql new file mode 100644 index 0000000..48dc2a6 --- /dev/null +++ b/migrations/0001_baseline_existing_auth.sql @@ -0,0 +1,6 @@ +-- Baseline migration for an existing D1 auth database. +-- +-- This file intentionally does not create Better Auth tables. Production already +-- has an older schema, and real changes must be applied by later incremental +-- migrations. Keep the full generated target schema in docs/schema instead. +SELECT 1; diff --git a/migrations/0001_better_auth_account_center.sql b/migrations/0001_better_auth_account_center.sql deleted file mode 100644 index 391b2e1..0000000 --- a/migrations/0001_better_auth_account_center.sql +++ /dev/null @@ -1,25 +0,0 @@ -create table "user" ("id" text not null primary key, "name" text not null, "email" text not null unique, "emailVerified" integer not null, "image" text, "createdAt" date not null, "updatedAt" date not null, "twoFactorEnabled" integer, "lastLoginMethod" text, "role" text, "banned" integer, "banReason" text, "banExpires" date); - -create table "session" ("id" text not null primary key, "expiresAt" date not null, "token" text not null unique, "createdAt" date not null, "updatedAt" date not null, "ipAddress" text, "userAgent" text, "userId" text not null references "user" ("id") on delete cascade, "impersonatedBy" text); - -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" date, "refreshTokenExpiresAt" date, "scope" text, "password" text, "createdAt" date not null, "updatedAt" date not null); - -create table "verification" ("id" text not null primary key, "identifier" text not null, "value" text not null, "expiresAt" date not null, "createdAt" date not null, "updatedAt" date not null); - -create table "twoFactor" ("id" text not null primary key, "secret" text not null, "backupCodes" text not null, "userId" text not null references "user" ("id") on delete cascade, "verified" integer); - -create table "passkey" ("id" text not null primary key, "name" text, "publicKey" text not null, "userId" text not null references "user" ("id") on delete cascade, "credentialID" text not null, "counter" integer not null, "deviceType" text not null, "backedUp" integer not null, "transports" text, "createdAt" date, "aaguid" text); - -create index "session_userId_idx" on "session" ("userId"); - -create index "account_userId_idx" on "account" ("userId"); - -create index "verification_identifier_idx" on "verification" ("identifier"); - -create index "twoFactor_secret_idx" on "twoFactor" ("secret"); - -create index "twoFactor_userId_idx" on "twoFactor" ("userId"); - -create index "passkey_userId_idx" on "passkey" ("userId"); - -create index "passkey_credentialID_idx" on "passkey" ("credentialID"); \ No newline at end of file diff --git a/migrations/0002_add_better_auth_account_center.sql b/migrations/0002_add_better_auth_account_center.sql new file mode 100644 index 0000000..189dafd --- /dev/null +++ b/migrations/0002_add_better_auth_account_center.sql @@ -0,0 +1,106 @@ +-- Incremental migration from the older Better Auth core schema to the current +-- account-center target schema generated in docs/schema/better-auth-target.sql. +-- +-- Assumed existing tables from the older deployment: +-- user, session, account, verification, twoFactor, passkey +-- +-- Apply through Wrangler migrations so this file runs once per D1 database. + +ALTER TABLE "user" ADD COLUMN "username" text; +ALTER TABLE "user" ADD COLUMN "displayUsername" text; +ALTER TABLE "user" ADD COLUMN "phoneNumber" text; +ALTER TABLE "user" ADD COLUMN "phoneNumberVerified" integer; + +ALTER TABLE "session" ADD COLUMN "activeOrganizationId" text; +ALTER TABLE "session" ADD COLUMN "activeTeamId" text; + +CREATE TABLE IF NOT EXISTS "organization" ( + "id" text not null primary key, + "name" text not null, + "slug" text not null unique, + "logo" text, + "createdAt" date not null, + "metadata" text +); + +CREATE TABLE IF NOT EXISTS "team" ( + "id" text not null primary key, + "name" text not null, + "organizationId" text not null references "organization" ("id") on delete cascade, + "createdAt" date not null, + "updatedAt" date +); + +CREATE TABLE IF NOT EXISTS "teamMember" ( + "id" text not null primary key, + "teamId" text not null references "team" ("id") on delete cascade, + "userId" text not null references "user" ("id") on delete cascade, + "createdAt" date +); + +CREATE TABLE IF NOT EXISTS "member" ( + "id" text not null primary key, + "organizationId" text not null references "organization" ("id") on delete cascade, + "userId" text not null references "user" ("id") on delete cascade, + "role" text not null, + "createdAt" date not null +); + +CREATE TABLE IF NOT EXISTS "invitation" ( + "id" text not null primary key, + "organizationId" text not null references "organization" ("id") on delete cascade, + "email" text not null, + "role" text, + "teamId" text, + "status" text not null, + "expiresAt" date not null, + "createdAt" date not null, + "inviterId" text not null references "user" ("id") on delete cascade +); + +CREATE TABLE IF NOT EXISTS "apikey" ( + "id" text not null primary key, + "configId" text not null, + "name" text, + "start" text, + "referenceId" text not null, + "prefix" text, + "key" text not null, + "refillInterval" integer, + "refillAmount" integer, + "lastRefillAt" date, + "enabled" integer, + "rateLimitEnabled" integer, + "rateLimitTimeWindow" integer, + "rateLimitMax" integer, + "requestCount" integer, + "remaining" integer, + "lastRequest" date, + "expiresAt" date, + "createdAt" date not null, + "updatedAt" date not null, + "permissions" text, + "metadata" text +); + +CREATE TABLE IF NOT EXISTS "jwks" ( + "id" text not null primary key, + "publicKey" text not null, + "privateKey" text not null, + "createdAt" date not null, + "expiresAt" date +); + +CREATE UNIQUE INDEX IF NOT EXISTS "user_username_uidx" on "user" ("username"); +CREATE UNIQUE INDEX IF NOT EXISTS "user_phoneNumber_uidx" on "user" ("phoneNumber"); +CREATE UNIQUE INDEX IF NOT EXISTS "organization_slug_uidx" on "organization" ("slug"); +CREATE INDEX IF NOT EXISTS "team_organizationId_idx" on "team" ("organizationId"); +CREATE INDEX IF NOT EXISTS "teamMember_teamId_idx" on "teamMember" ("teamId"); +CREATE INDEX IF NOT EXISTS "teamMember_userId_idx" on "teamMember" ("userId"); +CREATE INDEX IF NOT EXISTS "member_organizationId_idx" on "member" ("organizationId"); +CREATE INDEX IF NOT EXISTS "member_userId_idx" on "member" ("userId"); +CREATE INDEX IF NOT EXISTS "invitation_organizationId_idx" on "invitation" ("organizationId"); +CREATE INDEX IF NOT EXISTS "invitation_email_idx" on "invitation" ("email"); +CREATE INDEX IF NOT EXISTS "apikey_configId_idx" on "apikey" ("configId"); +CREATE INDEX IF NOT EXISTS "apikey_referenceId_idx" on "apikey" ("referenceId"); +CREATE INDEX IF NOT EXISTS "apikey_key_idx" on "apikey" ("key"); diff --git a/package.json b/package.json index 419d976..67077db 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,15 @@ "deploy": "wrangler deploy", "db:apply:local": "wrangler d1 migrations apply cfw-auth --local", "db:check": "node scripts/check-better-auth-migration.mjs", - "db:generate": "auth generate --config src/auth.migration.ts --output migrations/0001_better_auth_account_center.sql -y", + "db:generate": "auth generate --config src/auth.migration.ts --output docs/schema/better-auth-target.sql -y", "test": "vitest run", "typecheck": "tsc --noEmit", "ready": "pnpm db:check && pnpm typecheck && pnpm test" }, "dependencies": { + "@better-auth/api-key": "^1.6.16", + "@better-auth/expo": "^1.6.16", + "@better-auth/i18n": "^1.6.16", "@better-auth/passkey": "^1.6.16", "better-auth": "^1.6.0", "hono": "^4.8.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57ae9ab..f391f8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,15 @@ importers: .: dependencies: + '@better-auth/api-key': + specifier: ^1.6.16 + version: 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)))(better-call@1.3.6(zod@4.4.3)) + '@better-auth/expo': + specifier: ^1.6.16 + version: 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(better-auth@1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0))) + '@better-auth/i18n': + specifier: ^1.6.16 + version: 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(better-auth@1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0))) '@better-auth/passkey': specifier: ^1.6.16 version: 1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(better-auth@1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)))(better-call@1.3.6(zod@4.4.3))(nanostores@1.3.0) @@ -198,6 +207,14 @@ packages: resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} + '@better-auth/api-key@1.6.16': + resolution: {integrity: sha512-3iEn1tcVsT9inPTrjinyciA8TT1cpls/uDd/7cQCN6hN4xy6l4VO11pnoMcetstdq6jOayWgyrDT6cPCl/hafA==} + peerDependencies: + '@better-auth/core': ^1.6.16 + '@better-auth/utils': 0.4.1 + better-auth: ^1.6.16 + better-call: 1.3.6 + '@better-auth/core@1.6.16': resolution: {integrity: sha512-a0+ZNaaYYxOdFXFXmOE36TgtYN8QDzSYDozaAH0zsiWB0oyljsENyCxHJSekysISftb0rFpVXNdw525aEAOa6w==} peerDependencies: @@ -225,6 +242,31 @@ packages: drizzle-orm: optional: true + '@better-auth/expo@1.6.16': + resolution: {integrity: sha512-uXnbu6d5EbBVMtmRTjyI5RAsJHcJYCtnfNar8C2RpcJecL4Fws3fhNSI8tkJHDEdNAZ2Hn70DNy2x4eb/t8irA==} + peerDependencies: + '@better-auth/core': ^1.6.16 + better-auth: ^1.6.16 + expo-constants: '>=17.0.0' + expo-linking: '>=7.0.0' + expo-network: '>=8.0.7' + expo-web-browser: '>=14.0.0' + peerDependenciesMeta: + expo-constants: + optional: true + expo-linking: + optional: true + expo-network: + optional: true + expo-web-browser: + optional: true + + '@better-auth/i18n@1.6.16': + resolution: {integrity: sha512-Mb4QtU7pd2NQyzaMpOcJzuMQ/o1E9BCJOlIeLfyDFZrHrnKrIPathqRLvRFCvJcgWUB7R1saf+Unjv8Sn5ikXQ==} + peerDependencies: + '@better-auth/core': ^1.6.16 + better-auth: ^1.6.16 + '@better-auth/kysely-adapter@1.6.16': resolution: {integrity: sha512-ys/feL1p6By3/rQlMZ8QTgf9K2tZAIp1p+fGqT2krIoG5r+UsH3gMkUdbHlYxLt790Bo+Njkiqt59P0BMNsi+g==} peerDependencies: @@ -1971,6 +2013,14 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@better-auth/api-key@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(better-auth@1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)))(better-call@1.3.6(zod@4.4.3))': + dependencies: + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-auth/utils': 0.4.1 + better-auth: 1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)) + better-call: 1.3.6(zod@4.4.3) + zod: 4.4.3 + '@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0)': dependencies: '@better-auth/utils': 0.4.1 @@ -1990,6 +2040,19 @@ snapshots: '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) '@better-auth/utils': 0.4.1 + '@better-auth/expo@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(better-auth@1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)))': + dependencies: + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + '@better-fetch/fetch': 1.2.2 + better-auth: 1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)) + better-call: 1.3.6(zod@4.4.3) + zod: 4.4.3 + + '@better-auth/i18n@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(better-auth@1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)))': + dependencies: + '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) + better-auth: 1.6.16(@cloudflare/workers-types@4.20260610.1)(vitest@3.2.6(@types/node@24.13.1)(jiti@2.7.0)) + '@better-auth/kysely-adapter@1.6.16(@better-auth/core@1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0))(@better-auth/utils@0.4.1)(kysely@0.29.2)': dependencies: '@better-auth/core': 1.6.16(@better-auth/utils@0.4.1)(@better-fetch/fetch@1.2.2)(@cloudflare/workers-types@4.20260610.1)(better-call@1.3.6(zod@4.4.3))(jose@6.2.3)(kysely@0.29.2)(nanostores@1.3.0) diff --git a/scripts/check-better-auth-migration.mjs b/scripts/check-better-auth-migration.mjs index da9192e..f0eb678 100644 --- a/scripts/check-better-auth-migration.mjs +++ b/scripts/check-better-auth-migration.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; const projectRoot = fileURLToPath(new URL("..", import.meta.url)); const expectedFile = fileURLToPath( - new URL("../migrations/0001_better_auth_account_center.sql", import.meta.url), + new URL("../docs/schema/better-auth-target.sql", import.meta.url), ); function run(command, args) { diff --git a/src/auth.migration.ts b/src/auth.migration.ts index a8fc173..9b769a1 100644 --- a/src/auth.migration.ts +++ b/src/auth.migration.ts @@ -1,15 +1,25 @@ import { DatabaseSync } from "node:sqlite"; +import { apiKey } from "@better-auth/api-key"; +import { expo } from "@better-auth/expo"; +import { i18n } from "@better-auth/i18n"; import { passkey } from "@better-auth/passkey"; import { betterAuth } from "better-auth"; import type { BetterAuthPlugin } from "better-auth"; import { admin, + bearer, + captcha, emailOTP, + genericOAuth, haveIBeenPwned, + jwt, lastLoginMethod, multiSession, openAPI, + organization, + phoneNumber, twoFactor, + username, } from "better-auth/plugins"; import { passwordPolicy } from "./password"; @@ -17,6 +27,24 @@ const migrationBaseURL = "http://localhost:8788"; export const migrationPlugins: BetterAuthPlugin[] = [ openAPI(), + i18n({ + translations: { + en: {}, + zh: { + USER_NOT_FOUND: "用户不存在。", + INVALID_EMAIL_OR_PASSWORD: "邮箱或密码无效。", + EMAIL_NOT_VERIFIED: "邮箱尚未验证。", + INVALID_TOKEN: "令牌无效。", + INVALID_OTP: "验证码无效。", + OTP_EXPIRED: "验证码已过期。", + TWO_FACTOR_NOT_ENABLED: "未启用双因素认证。", + INVALID_PASSWORD: "密码无效。", + }, + }, + defaultLocale: "en", + detection: ["header", "cookie"], + localeCookie: "cfw_auth_locale", + }), haveIBeenPwned({ customPasswordCompromisedMessage: "This password has appeared in a data breach.", }), @@ -33,11 +61,87 @@ export const migrationPlugins: BetterAuthPlugin[] = [ multiSession({ maximumSessions: 10 }), lastLoginMethod({ storeInDatabase: true }), admin(), + captcha({ + provider: "cloudflare-turnstile", + secretKey: "migration-schema-generation-only", + endpoints: ["/sign-up/email", "/sign-in/email", "/forget-password"], + }), + genericOAuth({ + config: [ + { + providerId: "migration", + discoveryUrl: "https://example.com/.well-known/openid-configuration", + clientId: "migration-client-id", + clientSecret: "migration-client-secret", + scopes: ["openid", "email", "profile"], + }, + ], + }), passkey({ rpID: "localhost", rpName: "cfw-auth", origin: migrationBaseURL, }), + username({ + minUsernameLength: 3, + maxUsernameLength: 32, + validationOrder: { + username: "post-normalization", + }, + }), + phoneNumber({ + otpLength: 6, + expiresIn: 300, + allowedAttempts: 3, + requireVerification: true, + sendOTP: async () => {}, + sendPasswordResetOTP: async () => {}, + phoneNumberValidator: () => true, + signUpOnVerification: { + getTempEmail: (phoneNumberValue) => + `phone-${phoneNumberValue.replace(/\D/g, "")}@phone.cfw-auth.local`, + getTempName: (phoneNumberValue) => phoneNumberValue, + }, + }), + organization({ + teams: { + enabled: true, + defaultTeam: { + enabled: true, + }, + }, + requireEmailVerificationOnInvitation: true, + cancelPendingInvitationsOnReInvite: true, + sendInvitationEmail: async () => {}, + }), + apiKey([ + { + configId: "default", + defaultPrefix: "cfw_", + requireName: true, + enableMetadata: true, + rateLimit: { + enabled: true, + timeWindow: 1_000 * 60 * 60 * 24, + maxRequests: 1_000, + }, + }, + { + configId: "organization", + references: "organization", + defaultPrefix: "cfw_org_", + requireName: true, + enableMetadata: true, + rateLimit: { + enabled: true, + timeWindow: 1_000 * 60 * 60 * 24, + maxRequests: 10_000, + }, + }, + ]), + expo(), + jwt(), + bearer(), ]; export const auth = betterAuth({ diff --git a/src/auth.ts b/src/auth.ts index dd44861..41f298a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -3,14 +3,27 @@ import { buildAuthEmail, sendEmail } from "./email"; import { trustedOrigins, type Env } from "./env"; import { createSocialProviders } from "./oauth"; import { passwordPolicy } from "./password"; +import { authPerformanceConfig } from "./performance"; import { createAuthPlugins } from "./plugins"; -export function createAuth(env: Env) { +export interface AuthRuntime { + waitUntil?: (promise: Promise) => void; +} + +export function createAuth(env: Env, runtime: AuthRuntime = {}) { + const performance = authPerformanceConfig(env); + return betterAuth({ database: env.DB, secret: env.BETTER_AUTH_SECRET ?? "development-secret-change-before-production", baseURL: env.BETTER_AUTH_URL, trustedOrigins: trustedOrigins(env), + session: { + cookieCache: { + enabled: true, + maxAge: performance.sessionCookieCacheMaxAge, + }, + }, socialProviders: createSocialProviders(env), emailAndPassword: { enabled: true, @@ -29,6 +42,13 @@ export function createAuth(env: Env) { await sendEmail(env, buildAuthEmail({ kind: "verify-email", to: user.email, url })); }, }, + advanced: runtime.waitUntil + ? { + backgroundTasks: { + handler: runtime.waitUntil, + }, + } + : undefined, plugins: createAuthPlugins(env), }); } diff --git a/src/email.ts b/src/email.ts index a622fc3..9aaf256 100644 --- a/src/email.ts +++ b/src/email.ts @@ -1,6 +1,11 @@ import type { Env } from "./env"; -export type AuthEmailKind = "verify-email" | "reset-password" | "email-otp" | "magic-link"; +export type AuthEmailKind = + | "verify-email" + | "reset-password" + | "email-otp" + | "magic-link" + | "organization-invitation"; export interface AuthEmailInput { kind: AuthEmailKind; @@ -40,6 +45,14 @@ export function buildAuthEmail(input: AuthEmailInput): EmailMessage { }; } + if (input.kind === "organization-invitation") { + return { + to: input.to, + subject: "You have been invited to join an organization", + text: `Use this link to accept the invitation: ${input.url ?? ""}`, + }; + } + return { to: input.to, subject: "Verify your email", diff --git a/src/env.ts b/src/env.ts index 8728df6..f496c52 100644 --- a/src/env.ts +++ b/src/env.ts @@ -22,6 +22,14 @@ export interface Env { PASSKEY_ORIGIN?: string; ENABLE_JWT?: string; ENABLE_BEARER?: string; + EXPO_SCHEME?: string; + SMS_PROVIDER?: string; + SMS_WEBHOOK_URL?: string; + TWILIO_ACCOUNT_SID?: string; + TWILIO_AUTH_TOKEN?: string; + TWILIO_FROM?: string; + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE?: string; + API_KEY_DEFER_UPDATES?: string; } export function csvEnv(value: string | undefined): string[] { @@ -51,5 +59,9 @@ export function optionalEnv( } export function trustedOrigins(env: Env): string[] { - return csvEnv(env.TRUSTED_ORIGINS); + return [...csvEnv(env.TRUSTED_ORIGINS), ...expoOrigins(env)]; +} + +export function expoOrigins(env: Env): string[] { + return csvEnv(env.EXPO_SCHEME).map((scheme) => `${scheme}://`); } diff --git a/src/index.ts b/src/index.ts index 4b56c77..5aca853 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,11 @@ app.use( ); app.on(["POST", "GET"], "/api/auth/*", (c) => { - const auth = createAuth(c.env); + const auth = createAuth(c.env, { + waitUntil: (promise) => { + c.executionCtx.waitUntil(promise); + }, + }); return auth.handler(c.req.raw); }); diff --git a/src/password.ts b/src/password.ts index a13c8c8..f82c4e8 100644 --- a/src/password.ts +++ b/src/password.ts @@ -1,4 +1,4 @@ export const passwordPolicy = { - minPasswordLength: 12, + minPasswordLength: 6, maxPasswordLength: 128, }; diff --git a/src/performance.ts b/src/performance.ts new file mode 100644 index 0000000..2e70e8c --- /dev/null +++ b/src/performance.ts @@ -0,0 +1,31 @@ +import { type Env } from "./env"; + +export interface AuthPerformanceConfig { + sessionCookieCacheMaxAge: number; + apiKeyDeferUpdates: boolean; +} + +const defaultSessionCookieCacheMaxAge = 300; + +export function authPerformanceConfig(env: Env): AuthPerformanceConfig { + return { + sessionCookieCacheMaxAge: positiveIntegerEnv( + env.BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE, + defaultSessionCookieCacheMaxAge, + ), + apiKeyDeferUpdates: env.API_KEY_DEFER_UPDATES !== "false", + }; +} + +function positiveIntegerEnv(value: string | undefined, fallback: number): number { + if (!value) { + return fallback; + } + + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + return fallback; + } + + return parsed; +} diff --git a/src/plugins.ts b/src/plugins.ts index 9f0e767..ac47b96 100644 --- a/src/plugins.ts +++ b/src/plugins.ts @@ -1,3 +1,6 @@ +import { apiKey } from "@better-auth/api-key"; +import { expo } from "@better-auth/expo"; +import { i18n } from "@better-auth/i18n"; import { passkey } from "@better-auth/passkey"; import type { BetterAuthPlugin } from "better-auth"; import { @@ -10,16 +13,71 @@ import { lastLoginMethod, multiSession, openAPI, + organization, + phoneNumber, twoFactor, bearer, + username, } from "better-auth/plugins"; import { buildAuthEmail, sendEmail } from "./email"; -import { booleanEnv, type Env } from "./env"; +import { booleanEnv, trustedOrigins, type Env } from "./env"; import { createGenericOAuthProviders } from "./oauth"; +import { authPerformanceConfig } from "./performance"; +import { buildAuthSms, sendSms } from "./sms"; + +export function createApiKeyConfigurations(env: Env) { + const performance = authPerformanceConfig(env); + + return [ + { + configId: "default", + defaultPrefix: "cfw_", + requireName: true, + enableMetadata: true, + deferUpdates: performance.apiKeyDeferUpdates, + rateLimit: { + enabled: true, + timeWindow: 1_000 * 60 * 60 * 24, + maxRequests: 1_000, + }, + }, + { + configId: "organization", + references: "organization" as const, + defaultPrefix: "cfw_org_", + requireName: true, + enableMetadata: true, + deferUpdates: performance.apiKeyDeferUpdates, + rateLimit: { + enabled: true, + timeWindow: 1_000 * 60 * 60 * 24, + maxRequests: 10_000, + }, + }, + ]; +} export function createAuthPlugins(env: Env): BetterAuthPlugin[] { const plugins: BetterAuthPlugin[] = [ openAPI(), + i18n({ + translations: { + en: {}, + zh: { + USER_NOT_FOUND: "用户不存在。", + INVALID_EMAIL_OR_PASSWORD: "邮箱或密码无效。", + EMAIL_NOT_VERIFIED: "邮箱尚未验证。", + INVALID_TOKEN: "令牌无效。", + INVALID_OTP: "验证码无效。", + OTP_EXPIRED: "验证码已过期。", + TWO_FACTOR_NOT_ENABLED: "未启用双因素认证。", + INVALID_PASSWORD: "密码无效。", + }, + }, + defaultLocale: "en", + detection: ["header", "cookie"], + localeCookie: "cfw_auth_locale", + }), haveIBeenPwned({ customPasswordCompromisedMessage: "This password has appeared in a data breach.", }), @@ -38,6 +96,61 @@ export function createAuthPlugins(env: Env): BetterAuthPlugin[] { multiSession({ maximumSessions: 10 }), lastLoginMethod({ storeInDatabase: true }), admin(), + username({ + minUsernameLength: 3, + maxUsernameLength: 32, + validationOrder: { + username: "post-normalization", + }, + }), + phoneNumber({ + otpLength: 6, + expiresIn: 300, + allowedAttempts: 3, + requireVerification: true, + phoneNumberValidator: (phoneNumberValue) => /^\+[1-9]\d{7,14}$/.test(phoneNumberValue), + signUpOnVerification: { + getTempEmail: (phoneNumberValue) => + `phone-${phoneNumberValue.replace(/\D/g, "")}@phone.cfw-auth.local`, + getTempName: (phoneNumberValue) => phoneNumberValue, + }, + sendOTP: async ({ phoneNumber: phoneNumberValue, code }) => { + await sendSms( + env, + buildAuthSms({ kind: "phone-otp", to: phoneNumberValue, code }), + ); + }, + sendPasswordResetOTP: async ({ phoneNumber: phoneNumberValue, code }) => { + await sendSms( + env, + buildAuthSms({ kind: "phone-password-reset", to: phoneNumberValue, code }), + ); + }, + }), + organization({ + teams: { + enabled: true, + defaultTeam: { + enabled: true, + }, + }, + requireEmailVerificationOnInvitation: true, + cancelPendingInvitationsOnReInvite: true, + sendInvitationEmail: async (data) => { + const appOrigin = trustedOrigins(env)[0] ?? env.BETTER_AUTH_URL; + const url = `${appOrigin}/accept-invitation?id=${encodeURIComponent(data.id)}`; + await sendEmail( + env, + buildAuthEmail({ + kind: "organization-invitation", + to: data.email, + url, + }), + ); + }, + }), + apiKey(createApiKeyConfigurations(env)), + expo(), ]; if (env.CAPTCHA_PROVIDER === "cloudflare-turnstile" && env.CAPTCHA_SECRET_KEY) { diff --git a/src/sms.ts b/src/sms.ts new file mode 100644 index 0000000..7f4e764 --- /dev/null +++ b/src/sms.ts @@ -0,0 +1,92 @@ +import type { Env } from "./env"; + +export type AuthSmsKind = "phone-otp" | "phone-password-reset"; + +export interface AuthSmsInput { + kind: AuthSmsKind; + to: string; + code: string; +} + +export interface SmsMessage { + to: string; + text: string; +} + +export function buildAuthSms(input: AuthSmsInput): SmsMessage { + if (input.kind === "phone-password-reset") { + return { + to: input.to, + text: `Use ${input.code} to reset your password.`, + }; + } + + return { + to: input.to, + text: `Your verification code is ${input.code}.`, + }; +} + +export async function sendSms(env: Env, message: SmsMessage): Promise { + if (!env.SMS_PROVIDER) { + throw new Error("SMS_PROVIDER is not configured"); + } + + if (env.SMS_PROVIDER === "webhook") { + await sendWebhookSms(env, message); + return; + } + + if (env.SMS_PROVIDER === "twilio") { + await sendTwilioSms(env, message); + return; + } + + throw new Error(`Unsupported SMS_PROVIDER: ${env.SMS_PROVIDER}`); +} + +async function sendWebhookSms(env: Env, message: SmsMessage): Promise { + if (!env.SMS_WEBHOOK_URL) { + throw new Error("SMS_WEBHOOK_URL is required for webhook sms"); + } + + const response = await fetch(env.SMS_WEBHOOK_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); + + if (!response.ok) { + throw new Error(`SMS provider failed: ${response.status}`); + } +} + +async function sendTwilioSms(env: Env, message: SmsMessage): Promise { + if (!env.TWILIO_ACCOUNT_SID || !env.TWILIO_AUTH_TOKEN || !env.TWILIO_FROM) { + throw new Error("TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_FROM are required for twilio"); + } + + const body = new URLSearchParams({ + To: message.to, + From: env.TWILIO_FROM, + Body: message.text, + }); + const credentials = btoa(`${env.TWILIO_ACCOUNT_SID}:${env.TWILIO_AUTH_TOKEN}`); + const response = await fetch( + `https://api.twilio.com/2010-04-01/Accounts/${env.TWILIO_ACCOUNT_SID}/Messages.json`, + { + method: "POST", + headers: { + Authorization: `Basic ${credentials}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body, + }, + ); + + if (!response.ok) { + throw new Error(`SMS provider failed: ${response.status}`); + } +} diff --git a/tests/auth-config.test.ts b/tests/auth-config.test.ts index f69d9dd..983239f 100644 --- a/tests/auth-config.test.ts +++ b/tests/auth-config.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { createAuth } from "../src/auth"; import { booleanEnv, csvEnv, @@ -9,7 +10,9 @@ import { } from "../src/env"; import { buildAuthEmail, sendEmail } from "../src/email"; import { createGenericOAuthProviders, createSocialProviders } from "../src/oauth"; -import { createAuthPlugins } from "../src/plugins"; +import { authPerformanceConfig } from "../src/performance"; +import { createApiKeyConfigurations, createAuthPlugins } from "../src/plugins"; +import { buildAuthSms, sendSms } from "../src/sms"; const env: Env = { BETTER_AUTH_URL: "http://localhost:8788", @@ -21,6 +24,14 @@ describe("auth env helpers", () => { expect(trustedOrigins(env)).toEqual(["http://localhost:8787", "https://app.example.com"]); }); + it("adds Expo app schemes to trusted origins", () => { + expect(trustedOrigins({ ...env, EXPO_SCHEME: "cfwauth" })).toEqual([ + "http://localhost:8787", + "https://app.example.com", + "cfwauth://", + ]); + }); + it("parses optional csv values", () => { expect(csvEnv(" google, github ,, ")).toEqual(["google", "github"]); }); @@ -44,6 +55,48 @@ describe("auth env helpers", () => { }); }); +describe("auth performance config", () => { + it("uses conservative defaults", () => { + expect(authPerformanceConfig(env)).toEqual({ + sessionCookieCacheMaxAge: 300, + apiKeyDeferUpdates: true, + }); + }); + + it("parses session cookie cache max age from env", () => { + expect( + authPerformanceConfig({ + ...env, + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "60", + }).sessionCookieCacheMaxAge, + ).toBe(60); + }); + + it("falls back for invalid session cookie cache max age", () => { + expect( + authPerformanceConfig({ + ...env, + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "0", + }).sessionCookieCacheMaxAge, + ).toBe(300); + expect( + authPerformanceConfig({ + ...env, + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "not-a-number", + }).sessionCookieCacheMaxAge, + ).toBe(300); + }); + + it("allows disabling api key deferred updates", () => { + expect(authPerformanceConfig({ ...env, API_KEY_DEFER_UPDATES: "false" })).toMatchObject({ + apiKeyDeferUpdates: false, + }); + expect(authPerformanceConfig({ ...env, API_KEY_DEFER_UPDATES: "true" })).toMatchObject({ + apiKeyDeferUpdates: true, + }); + }); +}); + describe("auth email adapter", () => { it("builds a verification email", () => { const message = buildAuthEmail({ @@ -73,6 +126,24 @@ describe("auth email adapter", () => { }); }); +describe("auth sms adapter", () => { + it("builds a phone verification sms", () => { + const message = buildAuthSms({ + kind: "phone-otp", + to: "+15555550100", + code: "123456", + }); + expect(message.to).toBe("+15555550100"); + expect(message.text).toContain("123456"); + }); + + it("fails clearly when no sms provider is configured", async () => { + await expect(sendSms(env, { to: "+15555550100", text: "123456" })).rejects.toThrow( + "SMS_PROVIDER is not configured", + ); + }); +}); + describe("auth plugins", () => { it("does not enable captcha without provider config", () => { const plugins = createAuthPlugins({ @@ -100,6 +171,14 @@ describe("auth plugins", () => { expect(plugins.map((plugin) => plugin.id)).toContain("email-otp"); }); + it("enables i18n plugin by default", () => { + const plugins = createAuthPlugins({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + }); + expect(plugins.map((plugin) => plugin.id)).toContain("i18n"); + }); + it("enables account security and admin plugins", () => { const plugins = createAuthPlugins({ BETTER_AUTH_URL: "http://localhost:8788", @@ -110,6 +189,17 @@ describe("auth plugins", () => { ); }); + it("enables account center plugins by default", () => { + const plugins = createAuthPlugins({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + EXPO_SCHEME: "cfwauth", + }); + expect(plugins.map((plugin) => plugin.id)).toEqual( + expect.arrayContaining(["username", "phone-number", "organization", "api-key", "expo"]), + ); + }); + it("enables jwt and bearer only when requested", () => { const disabled = createAuthPlugins({ BETTER_AUTH_URL: "http://localhost:8788", @@ -126,6 +216,62 @@ describe("auth plugins", () => { }); expect(enabled.map((plugin) => plugin.id)).toEqual(expect.arrayContaining(["jwt", "bearer"])); }); + + it("enables api key deferred updates by default", () => { + const configurations = createApiKeyConfigurations({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + }); + + expect(configurations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + configId: "default", + deferUpdates: true, + }), + expect.objectContaining({ + configId: "organization", + deferUpdates: true, + }), + ]), + ); + }); + + it("allows disabling api key deferred updates", () => { + const configurations = createApiKeyConfigurations({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + API_KEY_DEFER_UPDATES: "false", + }); + + expect(configurations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + configId: "default", + deferUpdates: false, + }), + expect.objectContaining({ + configId: "organization", + deferUpdates: false, + }), + ]), + ); + }); +}); + +describe("auth runtime performance options", () => { + it("enables session cookie cache with configured max age", () => { + const auth = createAuth({ + BETTER_AUTH_URL: "http://localhost:8788", + TRUSTED_ORIGINS: "http://localhost:8787", + BETTER_AUTH_SESSION_COOKIE_CACHE_MAX_AGE: "60", + }); + + expect(auth.options.session?.cookieCache).toEqual({ + enabled: true, + maxAge: 60, + }); + }); }); describe("oauth config", () => { @@ -190,12 +336,22 @@ describe("Better Auth migration config", () => { expect(pluginIds).toEqual( expect.arrayContaining([ "open-api", + "i18n", "email-otp", "two-factor", "multi-session", "last-login-method", "admin", + "captcha", + "generic-oauth", "passkey", + "username", + "phone-number", + "organization", + "api-key", + "expo", + "jwt", + "bearer", ]), ); });