import { randomBytes } from 'node:crypto'; import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { genericOAuth, magicLink } from 'better-auth/plugins'; import { getDb } from '../db/client.js'; import { accounts, sessions, users, verifications } from '../db/schema.js'; import { env } from '../env.js'; import { ensurePersonalOrg } from '../services/orgs.js'; import { log } from './logger.js'; import { sendEmailChangeConfirmation, sendEmailVerification, sendMagicLink, sendPasswordReset, } from './email.js'; /** * Resolve the Better Auth signing secret. Refuses to boot in non-development * when BRIVEN_BETTER_AUTH_SECRET is unset — historically there was a * hardcoded literal fallback in this slot, which would let anyone reading * the open-core source forge sessions in a prod deploy that forgot the env * var. In dev we generate an ephemeral per-process value so dev workflows * keep working; sessions don't survive a restart. */ function resolveAuthSecret(): string { if (env.BRIVEN_BETTER_AUTH_SECRET) { return env.BRIVEN_BETTER_AUTH_SECRET; } if (env.BRIVEN_ENV === 'development') { log.warn( 'BRIVEN_BETTER_AUTH_SECRET not set — using ephemeral per-process secret. Sessions will not survive restart.', ); return randomBytes(32).toString('hex'); } throw new Error( 'BRIVEN_BETTER_AUTH_SECRET is required outside development. Set a value of at least 32 chars.', ); } /** * Better Auth instance. Per BUILD_PLAN Phase 1 week 1-2 we wire all three * auth methods from day one: email + password, magic link via mittera.eu, * and Google OAuth — so j can sign into the dashboard on day one. * * All cookies are HTTP-only and SameSite=strict. Session TTL is 30 days; the * sliding-refresh refresh window is 7 days (session is extended on any * authenticated request inside that window). */ export const auth = betterAuth({ appName: 'briven', secret: resolveAuthSecret(), baseURL: env.BRIVEN_API_ORIGIN, basePath: '/v1/auth', trustedOrigins: env.BRIVEN_TRUSTED_ORIGINS.split(',') .map((o) => o.trim()) .filter(Boolean), // Map Better Auth's singular model names onto our pluralised tables // (CLAUDE.md §6.1: DB tables are snake_case + plural). database: drizzleAdapter(getDb(), { provider: 'pg', schema: { user: users, session: sessions, account: accounts, verification: verifications, }, }), advanced: { cookiePrefix: 'briven', useSecureCookies: env.BRIVEN_ENV === 'production', // Cross-subdomain cookie: `.` lets the session cookie // set on api. be read by and every other subdomain // (docs, realtime). Skip in non-prod where browsers reject `.localhost`. crossSubDomainCookies: env.BRIVEN_ENV === 'production' && env.BRIVEN_DOMAIN ? { enabled: true, domain: `.${env.BRIVEN_DOMAIN}` } : { enabled: false }, defaultCookieAttributes: { // 'lax' is required for OAuth callbacks. With 'strict', the state // cookie set when the user clicks "sign in with google" wouldn't // be sent when Google redirects back to api.briven.tech/v1/auth/ // callback/google (the browser treats it as a cross-site nav from // accounts.google.com → api.briven.tech and strips strict cookies). // The result: every OAuth callback hits state_mismatch. // // 'lax' allows the cookie on top-level GET navigations (which is // exactly what OAuth callbacks are) while still blocking cross-site // POSTs that would defeat CSRF protection. CSRF on POST routes is // additionally guarded by the origin-check middleware // (apps/api/src/middleware/csrf.ts), so we lose nothing here. sameSite: 'lax', httpOnly: true, }, }, session: { expiresIn: 60 * 60 * 24 * 30, // 30 days updateAge: 60 * 60 * 24 * 7, // refresh if older than 7 days }, emailAndPassword: { enabled: true, // why: invite-only beta until BRIVEN_OPEN_SIGNUPS flips. Existing // users still sign in; only first-time signup is gated. The // per-method flags (here + on each social provider + on the magic // link plugin) are the same toggle to keep the override surface // small. disableSignUp: !env.BRIVEN_OPEN_SIGNUPS, requireEmailVerification: env.BRIVEN_ENV === 'production', minPasswordLength: 10, maxPasswordLength: 128, autoSignIn: true, sendResetPassword: async ({ user, url }) => { await sendPasswordReset(user.email, url); }, }, emailVerification: { sendOnSignUp: true, autoSignInAfterVerification: true, sendVerificationEmail: async ({ user, url }) => { await sendEmailVerification(user.email, url); }, }, // Authenticated users can change their sign-in email from the dashboard // Settings page. Better Auth exposes POST /v1/auth/change-email; the // confirmation link is sent to the CURRENT (already-verified) email so // a hijacked browser can't silently re-point the login email. The new // address only becomes the sign-in email after the user clicks the link // delivered to the old mailbox. user: { changeEmail: { enabled: true, sendChangeEmailConfirmation: async ({ user, newEmail, url }) => { await sendEmailChangeConfirmation(user.email, newEmail, url); }, }, }, // Google + GitHub use Better Auth's built-in socialProviders config. // Konnos (Git at code.konnos.org) uses the genericOAuth plugin // since that Git host isn't on Better Auth's hard-coded list. socialProviders: { ...(env.BRIVEN_GOOGLE_CLIENT_ID && env.BRIVEN_GOOGLE_CLIENT_SECRET ? { google: { clientId: env.BRIVEN_GOOGLE_CLIENT_ID, clientSecret: env.BRIVEN_GOOGLE_CLIENT_SECRET, disableSignUp: !env.BRIVEN_OPEN_SIGNUPS, }, } : {}), ...(env.BRIVEN_GITHUB_CLIENT_ID && env.BRIVEN_GITHUB_CLIENT_SECRET ? { github: { clientId: env.BRIVEN_GITHUB_CLIENT_ID, clientSecret: env.BRIVEN_GITHUB_CLIENT_SECRET, disableSignUp: !env.BRIVEN_OPEN_SIGNUPS, }, } : {}), ...(env.BRIVEN_DISCORD_CLIENT_ID && env.BRIVEN_DISCORD_CLIENT_SECRET ? { discord: { clientId: env.BRIVEN_DISCORD_CLIENT_ID, clientSecret: env.BRIVEN_DISCORD_CLIENT_SECRET, disableSignUp: !env.BRIVEN_OPEN_SIGNUPS, }, } : {}), }, plugins: [ magicLink({ expiresIn: 60 * 10, // 10 minutes // Same gate as emailAndPassword.disableSignUp — magic-link sign-IN // for existing users is allowed; first-time signup is rejected // when BRIVEN_OPEN_SIGNUPS is false. disableSignUp: !env.BRIVEN_OPEN_SIGNUPS, sendMagicLink: async ({ email, url }) => { await sendMagicLink(email, url); }, }), // Konnos (Git at code.konnos.org) OAuth — endpoints follow the host's // OAuth shape: /login/oauth/authorize, /login/oauth/access_token, // /api/v1/user. Userinfo returns {id, login, email, full_name, avatar_url}; // mapProfileToUser adapts it to Better Auth's expected shape. ...(env.BRIVEN_KONNOS_CLIENT_ID && env.BRIVEN_KONNOS_CLIENT_SECRET ? [ genericOAuth({ config: [ { providerId: 'konnos', clientId: env.BRIVEN_KONNOS_CLIENT_ID, clientSecret: env.BRIVEN_KONNOS_CLIENT_SECRET, authorizationUrl: `${env.BRIVEN_KONNOS_ISSUER}/login/oauth/authorize`, tokenUrl: `${env.BRIVEN_KONNOS_ISSUER}/login/oauth/access_token`, userInfoUrl: `${env.BRIVEN_KONNOS_ISSUER}/api/v1/user`, scopes: ['read:user'], disableSignUp: !env.BRIVEN_OPEN_SIGNUPS, mapProfileToUser: (profile) => ({ id: String(profile.id), email: profile.email, name: profile.full_name || profile.login, image: profile.avatar_url, emailVerified: true, }), }, ], }), ] : []), ], // - `before`: invite-only beta gate. When BRIVEN_OPEN_SIGNUPS=false, // reject signups whose email isn't on the platform allowlist. The // environment-driven `disableSignUp` set on every provider above is // the broad "no public signups" switch; this `before` hook is the // "but THESE specific emails are allowed" carve-out so admins can // invite users one by one without flipping the global toggle. // - `after`: auto-create the personal org + mark the allowlist entry // as accepted so the dashboard can show pending vs claimed invites. databaseHooks: { user: { create: { before: async (user) => { // DB-backed override (platform_settings.openSignups) takes // precedence over the env var; the env stays as the bootstrap // default until the first dashboard flip writes a row. const { getOpenSignupsFlag } = await import('../services/platform-settings.js'); const openSignups = await getOpenSignupsFlag(); if (openSignups) return; const email = user.email?.toLowerCase().trim(); if (!email) { throw new Error('signup_allowlist_required: email missing on user.create'); } const { isEmailAllowed } = await import('../services/signup-allowlist.js'); const allowed = await isEmailAllowed(email); if (!allowed) { // Throwing aborts Better Auth's signup flow — the caller // gets a clean error response. The string lands in // logs/audit per Better Auth's own error path. throw new Error( 'signup_not_allowlisted: this email is not on the invite-only beta allowlist', ); } }, after: async (user) => { try { await ensurePersonalOrg({ userId: user.id, email: user.email, name: user.name ?? null, }); } catch (err) { log.error('personal_org_create_after_signup_failed', { userId: user.id, error: err instanceof Error ? err.message : String(err), }); } // Stamp the allowlist row when signups are gated. Reads the // same DB-backed flag the before-hook used, so a mid-flow // flag flip stays consistent. const { getOpenSignupsFlag } = await import('../services/platform-settings.js'); const openSignups = await getOpenSignupsFlag(); if (!openSignups) { try { const { markAllowlistAccepted } = await import( '../services/signup-allowlist.js' ); await markAllowlistAccepted(user.email); } catch (err) { log.warn('allowlist_accepted_stamp_failed', { userId: user.id, error: err instanceof Error ? err.message : String(err), }); } } }, }, }, }, logger: { disabled: false, level: env.BRIVEN_LOG_LEVEL, log: (level, msg, ...rest) => { const fields = rest.length > 0 ? { extra: rest } : undefined; switch (level) { case 'error': log.error(`auth: ${msg}`, fields); break; case 'warn': log.warn(`auth: ${msg}`, fields); break; case 'info': log.info(`auth: ${msg}`, fields); break; default: log.debug(`auth: ${msg}`, fields); } }, }, }); export type Session = typeof auth.$Infer.Session.session; export type User = typeof auth.$Infer.Session.user;