/** * Better Auth configuration for SonicJS — via the better-auth-cloudflare shim. * * A fresh auth instance is built per request (Workers lifecycle). The existing * `auth_user` table is used as Better Auth's user model. Legacy SonicJS PBKDF2 * hashes are verified and transparently upgraded to scrypt on first login. KV * (CACHE_KV) is used as session secondary storage so getSession does not hit D1 * on every request. * * Extend via config.auth.extendBetterAuth in createSonicJSApp() to add social * providers, magic link, 2FA, etc. */ /** Send an email via the SonicJS email plugin (Resend). * Loads apiKey/fromEmail/fromName from the `plugins` table at runtime. * Falls back to console.log when the plugin is unconfigured (local dev). */ async function sendViaEmailPlugin( db: D1Database, to: string, subject: string, html: string ): Promise { try { const row = (await db .prepare("SELECT settings FROM plugins WHERE id = 'email'") .first()) as { settings: string } | null if (row?.settings) { const { apiKey, resendApiKey, fromEmail, fromName } = JSON.parse(row.settings) as { apiKey?: string; resendApiKey?: string; fromEmail?: string; fromName?: string } const key = resendApiKey || apiKey if (key && fromEmail) { await fetch('https://api.resend.com/emails', { method: 'POST', headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: `${fromName ?? 'SonicJS'} <${fromEmail}>`, to: [to], subject, html, }), }) return } } } catch { /* fall through to dev log */ } console.log(`[email-dev] To:${to} | Subject:${subject}`) } import { betterAuth } from 'better-auth' import { withCloudflare } from 'better-auth-cloudflare' import { hashPassword as baHashPassword, verifyPassword as baVerifyPassword } from 'better-auth/crypto' import { APIError } from 'better-auth/api' import { magicLink } from 'better-auth/plugins/magic-link' import { emailOTP } from 'better-auth/plugins/email-otp' import { organization } from 'better-auth/plugins/organization' import { twoFactor } from 'better-auth/plugins/two-factor' import { drizzle } from 'drizzle-orm/d1' import { authUser, authSession, authAccount, authVerification, authTwoFactor, authTenant, authTenantMember, authTenantInvitation, authTenantTeam } from '../db/schema' import { isRegistrationEnabled, isFirstUserRegistration } from '../services/auth-validation' import { getTwoFactorPolicy } from './two-factor-settings' import { createSecondaryStorage } from './secondary-storage' import type { Bindings } from '../app' /** * Verify a password against a SonicJS legacy PBKDF2 hash: * pbkdf2::: (PBKDF2-SHA256, 256-bit) * Mirrors AuthManager.verifyPassword in middleware/auth.ts. */ async function verifyLegacyPbkdf2(password: string, stored: string): Promise { const parts = stored.split(':') if (parts.length !== 4) return false const iterations = parseInt(parts[1]!, 10) const saltBytes = parts[2]!.match(/.{2}/g) if (!saltBytes || !Number.isFinite(iterations)) return false const salt = new Uint8Array(saltBytes.map((b) => parseInt(b, 16))) const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']) const bits = await crypto.subtle.deriveBits({ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' }, km, 256) const actual = Array.from(new Uint8Array(bits)).map((b) => b.toString(16).padStart(2, '0')).join('') const expected = parts[3]! if (actual.length !== expected.length) return false let diff = 0 for (let i = 0; i < actual.length; i++) diff |= actual.charCodeAt(i) ^ expected.charCodeAt(i) return diff === 0 } /** * Build the default Better Auth options used by SonicJS (through the CF shim). * Exported so apps can extend via config.auth.extendBetterAuth. */ export function getDefaultAuthOptions(env: Bindings, requestBaseURL?: string) { const db = drizzle(env.DB) const tenantOrganization = organization({ // Tenants are created by TenantService only (see the /auth/organization/* block in app.ts). allowUserToCreateOrganization: false, schema: { organization: { modelName: 'auth_tenant', additionalFields: { status: { type: 'string', required: false, defaultValue: 'active', input: true }, domain: { type: 'string', required: false, input: true }, notes: { type: 'string', required: false, defaultValue: '', input: true }, }, }, // `fields` values are drizzle PROPERTY keys (`tenantId`), not column names (`tenant_id`): // the drizzle adapter resolves a BA field to a property on the table object. Better Auth // 1.7.3+ validates this at startup and rejects every auth request on a mismatch. member: { modelName: 'auth_tenant_member', fields: { organizationId: 'tenantId' }, }, invitation: { modelName: 'auth_tenant_invitation', fields: { organizationId: 'tenantId' }, }, team: { modelName: 'auth_tenant_team', fields: { organizationId: 'tenantId' }, }, }, }) // Every HTTP path the organization plugin mounts, derived from the instance so a Better Auth // upgrade that adds an endpoint is covered without editing a list. Closed via `disabledPaths` // (exact match on the normalized path, checked before routing) as a second layer behind the // `/auth/organization/*` block in app.ts: either one alone returns 404. `disabledPaths` only // gates HTTP requests, and SonicJS never calls the organization API server-side. const organizationPaths = Object.values(tenantOrganization.endpoints) .map((endpoint) => (endpoint as { path?: unknown }).path) .filter((path): path is string => typeof path === 'string' && (path === '/organization' || path.startsWith('/organization/'))) return { secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL || requestBaseURL, appName: 'SonicJS', ...withCloudflare( { autoDetectIpAddress: true, geolocationTracking: false, cf: {}, d1: { db, options: { // Keys MUST match modelName values — BA resolves by modelName, not by JS variable name. schema: { auth_user: authUser, auth_session: authSession, auth_account: authAccount, auth_verification: authVerification, auth_two_factor: authTwoFactor, auth_tenant: authTenant, auth_tenant_member: authTenantMember, auth_tenant_invitation: authTenantInvitation, auth_tenant_team: authTenantTeam }, }, }, // `kv` is deliberately NOT passed: SonicJS's own KV storage is set after the spread below // (see ./secondary-storage for why). }, { basePath: '/auth', emailAndPassword: { enabled: true, autoSignIn: true, // Transparent migration of SonicJS legacy PBKDF2 hashes: verify against // the old format on login, then re-hash to scrypt and persist. No // mass-rehash, no forced password resets. password: { verify: async ({ hash, password }: { hash: string; password: string }) => { if (hash.startsWith('pbkdf2:')) { const ok = await verifyLegacyPbkdf2(password, hash) if (ok) { const upgraded = await baHashPassword(password) await env.DB.prepare( "UPDATE auth_account SET password = ?, updated_at = ? WHERE password = ? AND provider_id = 'credential'" ) .bind(upgraded, Math.floor(Date.now() / 1000), hash) .run() } return ok } return baVerifyPassword({ hash, password }) }, }, }, user: { modelName: 'auth_user', // Field-mapping values are Drizzle *property keys* (camelCase), which // already match Better Auth's defaults for emailVerified/createdAt/ // updatedAt. Only `image` differs (SonicJS uses `avatar`). fields: { image: 'avatar', }, additionalFields: { role: { type: 'string', required: false, defaultValue: 'viewer', input: false }, firstName: { type: 'string', required: false, defaultValue: '', input: true }, lastName: { type: 'string', required: false, defaultValue: '', input: true }, isSuperAdmin: { type: 'boolean', required: false, defaultValue: false, input: false }, }, }, session: { modelName: 'auth_session', // Drizzle property keys already match Better Auth defaults (userId, // expiresAt, ipAddress, …) — no field overrides needed. expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // refresh once per day }, account: { modelName: 'auth_account' }, verification: { modelName: 'auth_verification', // REQUIRED from Better Auth 1.7, not a preference. With secondary storage configured and // this flag off, consuming a single-use value (2FA challenge, magic link, email OTP, // password reset) calls `secondaryStorage.getAndDelete`, which KV cannot do atomically. // `true` consumes against `auth_verification` in D1 with a single // `DELETE … WHERE id IN (SELECT … LIMIT 1) RETURNING` (D1 has no transactions here): // atomic, and strongly consistent for a code read back moments after it was written. storeInDatabase: true, }, // Explicit, because Better Auth 1.7 defaults rate-limit storage to secondary storage (KV), // which has no atomic counter (our `increment` throws). Rate limiting is enabled when // NODE_ENV === 'production': off on Workers (nothing sets NODE_ENV there), on in the // Docker self-host image. 'memory' keeps the counters in-process. Buckets are keyed by the // client IP Better Auth reads from `cf-connecting-ip` / `x-real-ip` (set by withCloudflare). // In Docker nothing strips those headers, so a client can send a new value on every request // and get a fresh bucket; a client that sends neither shares one bucket per path. Same on // 1.6 (same headers). Fixing it means trusting only a proxy-set header — separate work. rateLimit: { storage: 'memory' }, databaseHooks: { user: { create: { before: async (userData: Record) => { const isFirst = await isFirstUserRegistration(env.DB) if (!isFirst) { const enabled = await isRegistrationEnabled(env.DB) if (!enabled) { throw new APIError('BAD_REQUEST', { message: 'Registration is currently disabled.' }) } } const d = userData as { name?: string; email?: string; firstName?: string; lastName?: string } const name = (d.name ?? 'User').toString() const parts = name.trim().split(/\s+/) // Prefer explicitly-provided fields (registration form); fall back // to values derived from name/email. const firstName = d.firstName || parts[0] || 'User' const lastName = d.lastName || parts.slice(1).join(' ') || firstName return { data: { ...userData, name, firstName, lastName, role: 'viewer' } } }, after: async (user: { id: string }) => { // Assign dynamic RBAC membership. The first real user receives // Administrator so fresh installs can enter the portal; later // self-registered users receive Viewer. try { // RBAC roles/assignments are document-backed (services/rbac.ts). // First real portal admin → Administrator; later users → Viewer. // setUserRoles (via addUserRoleByName) also projects auth_user.role. const { RbacService } = await import('../services/rbac') const rbac = new RbacService(env.DB) const roleName = (await rbac.countPortalAdmins(user.id)) === 0 ? 'admin' : 'viewer' await rbac.addUserRoleByName(user.id, roleName) } catch { /* rbac docs may not be seeded yet on older schemas — non-fatal */ } }, }, }, }, // ── Phase 4: BA-native login methods ───────────────────────────────────── // Magic-link and Email-OTP replace the standalone SonicJS plugins that // minted JWT cookies. Social providers replace the bespoke oauth-providers // plugin. All are gated on the relevant env vars / email service config // so they activate only when configured. plugins: [ // Magic-link passwordless auth. Sends a one-time link to the user's inbox; // the link resolves to a BA session. Requires a working email service. magicLink({ sendMagicLink: async ({ email, url }: { email: string; url: string }, _request: any) => { await sendViaEmailPlugin( env.DB, email, 'Your sign-in link', `

Sign in to SonicJS

Click the link below to sign in. Expires in 15 minutes.

Sign in

Or copy: ${url}

` ) }, expiresIn: 15 * 60, }), // Email OTP — 6-digit code sent to inbox. Replaces the otp-login-plugin. emailOTP({ sendVerificationOTP: async (params: { email: string; otp: string; type: string }, _request: any) => { await sendViaEmailPlugin( env.DB, params.email, 'Your sign-in code', `

Your one-time code

${params.otp}

Expires in 10 minutes. Do not share this code.

` ) }, otpLength: 6, expiresIn: 10 * 60, }), // Second factor — TOTP + single-use backup codes + per-ACCOUNT lockout. // // Composed UNCONDITIONALLY, and that is the security decision, not laziness. If this // were gated on the plugin row being active, deactivating the plugin would silently // downgrade every enrolled account to password-only: sign-in would stop asking for a // code, nothing would error, and /admin/profile would still read "Enabled". Plugin // status therefore gates the enrolment SURFACE only (routes 404, sidebar hides) — never // verification. A gate that needs an `EXISTS` probe would also put a D1 round-trip on // `createAuth`, which is synchronous and runs on every authenticated request. // // NOTE no `schema.*.fields` maps. The drizzle adapter resolves a BA field to a drizzle // PROPERTY KEY, and `authTwoFactor`/`authUser` already declare `userId`, `backupCodes`, // `failedVerificationCount`, `lockedUntil`, `twoFactorEnabled` — the snake_case column // names live on the drizzle columns. Adding maps here would double-map and break both // reads and writes. (The sibling Infowall port needs them because it is on the kysely // adapter, which takes raw column names.) twoFactor({ // Read synchronously — BA snapshots these onto its plugin options at construction // time and `verify-two-factor.mjs` pulls the lockout config back off that object, so // a lazily-resolved value would never be seen. Loaded once per isolate from the // plugin's onBoot; defaults (the strict end of every knob) apply until then. ...(() => { const policy = getTwoFactorPolicy() return { issuer: policy.issuer, // Per-ACCOUNT, so rotating IPs does not help. This is the control that matters // once an attacker already holds a valid password: a TOTP code is only a million // possibilities, and per-IP rate limiting does not bound a distributed guesser. accountLockout: { enabled: true, maxFailedAttempts: policy.maxFailedAttempts, durationSeconds: policy.lockoutDurationSeconds, }, backupCodeOptions: { amount: policy.backupCodeCount }, } })(), twoFactorTable: 'auth_two_factor', // Trusted-device remember-me is switched OFF, and it takes an explicit `0` to do it. // // Leaving the option unset does NOT disable the feature: BA reads // `options?.trustDeviceMaxAge ?? 2592e3` (two-factor/index.mjs, and again in // verify-two-factor.mjs), i.e. it defaults to THIRTY DAYS, and `verifyTOTPBodySchema` // accepts `trustDevice` from the request body. So any client — not just the page shipped // here, which never sends it — could post // `{code:'', trustDevice:true}` once and then sign in with the password // alone for a month, refreshed on every sign-in. One phished code or one stolen backup // code would convert into a month-long password-only bypass, invisible in normal use. // // `0` is not nullish, so it wins the `??`: the cookie is written with `Max-Age=0` and the // trust record's `expiresAt` is `Date.now() + 0`, which can never satisfy the sign-in // hook's `expiresAt > new Date()` check. A trusted-device cookie is a second bypass // surface to reason about and this release does not take it on. // // Break-it proof: __tests__/services/two-factor-roundtrip.test.ts goes red without this // line — the second password sign-in returns a session instead of a challenge. trustDeviceMaxAge: 0, // BA's own /two-factor/* rate limit (3 per 10s) is left alone: a `rateLimit.customRules` // entry REPLACES a plugin's rule rather than stacking with it. Note that rule is NOT // active on Workers — `rateLimit.enabled` defaults to BA's `isProduction`, which reads // `process.env.NODE_ENV`, and wrangler.toml sets only ENVIRONMENT. It IS active in the // Docker self-host image, whose Dockerfile sets NODE_ENV=production. What actually // bounds TOTP guessing on Workers is the per-account lockout configured above // (D1-backed, atomic via `incrementOne`) plus BA's in-code `beginAttempt(5)` per // challenge. Turning BA rate limiting on globally is a separate, // repo-wide decision — it would apply to every /auth/* endpoint. }), tenantOrganization, ], } ), // Our own KV session cache — set AFTER the spread so the shim cannot overwrite it. // Omit the key entirely when there is no KV binding, so BA never sees a present-but-undefined value. ...(env.CACHE_KV ? { secondaryStorage: createSecondaryStorage(env.CACHE_KV) } : {}), disabledPaths: organizationPaths, // ── Phase 4: Social providers ───────────────────────────────────────── // Activated when the relevant env vars are set. Replaces the bespoke // oauth-providers SonicJS plugin. Set via wrangler secret put / .dev.vars. socialProviders: { ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ? { github: { clientId: env.GITHUB_CLIENT_ID, clientSecret: env.GITHUB_CLIENT_SECRET } } : {}), ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET ? { google: { clientId: env.GOOGLE_CLIENT_ID, clientSecret: env.GOOGLE_CLIENT_SECRET } } : {}), }, } } export type BetterAuthDefaultOptions = ReturnType export type ExtendBetterAuth = (opts: BetterAuthDefaultOptions) => BetterAuthDefaultOptions /** Create a Better Auth instance for this request. */ export function createAuth(env: Bindings, extendBetterAuth?: ExtendBetterAuth, requestBaseURL?: string) { // Hard-fail rather than sign sessions with an undefined/blank secret. The // secret must be provided via `wrangler secret put BETTER_AUTH_SECRET` // (prod/preview) or a gitignored `.dev.vars` (local) — never committed. if (!env.BETTER_AUTH_SECRET || env.BETTER_AUTH_SECRET.length < 16) { throw new Error( 'BETTER_AUTH_SECRET is missing or too short. Set it as a Wrangler secret ' + '(wrangler secret put BETTER_AUTH_SECRET) or in a gitignored .dev.vars for local dev. ' + 'Refusing to initialize auth without a strong signing secret.' ) } const defaults = getDefaultAuthOptions(env, requestBaseURL) const options = extendBetterAuth ? extendBetterAuth(defaults) : defaults if (defaults.disabledPaths?.length && (!options.disabledPaths || options.disabledPaths.length < defaults.disabledPaths.length)) { console.warn( '[sonicjs/auth] extendBetterAuth removed or reduced disabledPaths. ' + 'Organization endpoints are still blocked by app.ts, but the BA-level guard is weakened.' ) } return betterAuth(options as Parameters[0]) } export type SonicJSAuth = ReturnType