auth.ts313 lines · main
1import { randomBytes } from 'node:crypto';
2
3import { betterAuth } from 'better-auth';
4import { drizzleAdapter } from 'better-auth/adapters/drizzle';
5import { genericOAuth, magicLink } from 'better-auth/plugins';
6
7import { getDb } from '../db/client.js';
8import { accounts, sessions, users, verifications } from '../db/schema.js';
9import { env } from '../env.js';
10import { ensurePersonalOrg } from '../services/orgs.js';
11import { log } from './logger.js';
12import {
13 sendEmailChangeConfirmation,
14 sendEmailVerification,
15 sendMagicLink,
16 sendPasswordReset,
17} from './email.js';
18
19/**
20 * Resolve the Better Auth signing secret. Refuses to boot in non-development
21 * when BRIVEN_BETTER_AUTH_SECRET is unset — historically there was a
22 * hardcoded literal fallback in this slot, which would let anyone reading
23 * the open-core source forge sessions in a prod deploy that forgot the env
24 * var. In dev we generate an ephemeral per-process value so dev workflows
25 * keep working; sessions don't survive a restart.
26 */
27function resolveAuthSecret(): string {
28 if (env.BRIVEN_BETTER_AUTH_SECRET) {
29 return env.BRIVEN_BETTER_AUTH_SECRET;
30 }
31 if (env.BRIVEN_ENV === 'development') {
32 log.warn(
33 'BRIVEN_BETTER_AUTH_SECRET not set — using ephemeral per-process secret. Sessions will not survive restart.',
34 );
35 return randomBytes(32).toString('hex');
36 }
37 throw new Error(
38 'BRIVEN_BETTER_AUTH_SECRET is required outside development. Set a value of at least 32 chars.',
39 );
40}
41
42/**
43 * Better Auth instance. Per BUILD_PLAN Phase 1 week 1-2 we wire all three
44 * auth methods from day one: email + password, magic link via mittera.eu,
45 * and Google OAuth — so j can sign into the dashboard on day one.
46 *
47 * All cookies are HTTP-only and SameSite=strict. Session TTL is 30 days; the
48 * sliding-refresh refresh window is 7 days (session is extended on any
49 * authenticated request inside that window).
50 */
51export const auth = betterAuth({
52 appName: 'briven',
53 secret: resolveAuthSecret(),
54 baseURL: env.BRIVEN_API_ORIGIN,
55 basePath: '/v1/auth',
56 trustedOrigins: env.BRIVEN_TRUSTED_ORIGINS.split(',')
57 .map((o) => o.trim())
58 .filter(Boolean),
59
60 // Map Better Auth's singular model names onto our pluralised tables
61 // (CLAUDE.md §6.1: DB tables are snake_case + plural).
62 database: drizzleAdapter(getDb(), {
63 provider: 'pg',
64 schema: {
65 user: users,
66 session: sessions,
67 account: accounts,
68 verification: verifications,
69 },
70 }),
71
72 advanced: {
73 cookiePrefix: 'briven',
74 useSecureCookies: env.BRIVEN_ENV === 'production',
75 // Cross-subdomain cookie: `.<BRIVEN_DOMAIN>` lets the session cookie
76 // set on api.<domain> be read by <domain> and every other subdomain
77 // (docs, realtime). Skip in non-prod where browsers reject `.localhost`.
78 crossSubDomainCookies:
79 env.BRIVEN_ENV === 'production' && env.BRIVEN_DOMAIN
80 ? { enabled: true, domain: `.${env.BRIVEN_DOMAIN}` }
81 : { enabled: false },
82 defaultCookieAttributes: {
83 // 'lax' is required for OAuth callbacks. With 'strict', the state
84 // cookie set when the user clicks "sign in with google" wouldn't
85 // be sent when Google redirects back to api.briven.tech/v1/auth/
86 // callback/google (the browser treats it as a cross-site nav from
87 // accounts.google.com → api.briven.tech and strips strict cookies).
88 // The result: every OAuth callback hits state_mismatch.
89 //
90 // 'lax' allows the cookie on top-level GET navigations (which is
91 // exactly what OAuth callbacks are) while still blocking cross-site
92 // POSTs that would defeat CSRF protection. CSRF on POST routes is
93 // additionally guarded by the origin-check middleware
94 // (apps/api/src/middleware/csrf.ts), so we lose nothing here.
95 sameSite: 'lax',
96 httpOnly: true,
97 },
98 },
99
100 session: {
101 expiresIn: 60 * 60 * 24 * 30, // 30 days
102 updateAge: 60 * 60 * 24 * 7, // refresh if older than 7 days
103 },
104
105 emailAndPassword: {
106 enabled: true,
107 // why: invite-only beta until BRIVEN_OPEN_SIGNUPS flips. Existing
108 // users still sign in; only first-time signup is gated. The
109 // per-method flags (here + on each social provider + on the magic
110 // link plugin) are the same toggle to keep the override surface
111 // small.
112 disableSignUp: !env.BRIVEN_OPEN_SIGNUPS,
113 requireEmailVerification: env.BRIVEN_ENV === 'production',
114 minPasswordLength: 10,
115 maxPasswordLength: 128,
116 autoSignIn: true,
117 sendResetPassword: async ({ user, url }) => {
118 await sendPasswordReset(user.email, url);
119 },
120 },
121
122 emailVerification: {
123 sendOnSignUp: true,
124 autoSignInAfterVerification: true,
125 sendVerificationEmail: async ({ user, url }) => {
126 await sendEmailVerification(user.email, url);
127 },
128 },
129
130 // Authenticated users can change their sign-in email from the dashboard
131 // Settings page. Better Auth exposes POST /v1/auth/change-email; the
132 // confirmation link is sent to the CURRENT (already-verified) email so
133 // a hijacked browser can't silently re-point the login email. The new
134 // address only becomes the sign-in email after the user clicks the link
135 // delivered to the old mailbox.
136 user: {
137 changeEmail: {
138 enabled: true,
139 sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
140 await sendEmailChangeConfirmation(user.email, newEmail, url);
141 },
142 },
143 },
144
145 // Google + GitHub use Better Auth's built-in socialProviders config.
146 // Konnos (Forgejo at code.konnos.org) uses the genericOAuth plugin
147 // since Forgejo isn't on Better Auth's hard-coded list.
148 socialProviders: {
149 ...(env.BRIVEN_GOOGLE_CLIENT_ID && env.BRIVEN_GOOGLE_CLIENT_SECRET
150 ? {
151 google: {
152 clientId: env.BRIVEN_GOOGLE_CLIENT_ID,
153 clientSecret: env.BRIVEN_GOOGLE_CLIENT_SECRET,
154 disableSignUp: !env.BRIVEN_OPEN_SIGNUPS,
155 },
156 }
157 : {}),
158 ...(env.BRIVEN_GITHUB_CLIENT_ID && env.BRIVEN_GITHUB_CLIENT_SECRET
159 ? {
160 github: {
161 clientId: env.BRIVEN_GITHUB_CLIENT_ID,
162 clientSecret: env.BRIVEN_GITHUB_CLIENT_SECRET,
163 disableSignUp: !env.BRIVEN_OPEN_SIGNUPS,
164 },
165 }
166 : {}),
167 ...(env.BRIVEN_DISCORD_CLIENT_ID && env.BRIVEN_DISCORD_CLIENT_SECRET
168 ? {
169 discord: {
170 clientId: env.BRIVEN_DISCORD_CLIENT_ID,
171 clientSecret: env.BRIVEN_DISCORD_CLIENT_SECRET,
172 disableSignUp: !env.BRIVEN_OPEN_SIGNUPS,
173 },
174 }
175 : {}),
176 },
177
178 plugins: [
179 magicLink({
180 expiresIn: 60 * 10, // 10 minutes
181 // Same gate as emailAndPassword.disableSignUp — magic-link sign-IN
182 // for existing users is allowed; first-time signup is rejected
183 // when BRIVEN_OPEN_SIGNUPS is false.
184 disableSignUp: !env.BRIVEN_OPEN_SIGNUPS,
185 sendMagicLink: async ({ email, url }) => {
186 await sendMagicLink(email, url);
187 },
188 }),
189 // Konnos (Forgejo) OAuth — endpoints follow Forgejo's gitea-compatible
190 // shape: /login/oauth/authorize, /login/oauth/access_token,
191 // /api/v1/user. Forgejo's userinfo endpoint returns
192 // {id, login, email, full_name, avatar_url}; mapProfileToUser
193 // adapts it to Better Auth's expected shape.
194 ...(env.BRIVEN_KONNOS_CLIENT_ID && env.BRIVEN_KONNOS_CLIENT_SECRET
195 ? [
196 genericOAuth({
197 config: [
198 {
199 providerId: 'konnos',
200 clientId: env.BRIVEN_KONNOS_CLIENT_ID,
201 clientSecret: env.BRIVEN_KONNOS_CLIENT_SECRET,
202 authorizationUrl: `${env.BRIVEN_KONNOS_ISSUER}/login/oauth/authorize`,
203 tokenUrl: `${env.BRIVEN_KONNOS_ISSUER}/login/oauth/access_token`,
204 userInfoUrl: `${env.BRIVEN_KONNOS_ISSUER}/api/v1/user`,
205 scopes: ['read:user'],
206 disableSignUp: !env.BRIVEN_OPEN_SIGNUPS,
207 mapProfileToUser: (profile) => ({
208 id: String(profile.id),
209 email: profile.email,
210 name: profile.full_name || profile.login,
211 image: profile.avatar_url,
212 emailVerified: true,
213 }),
214 },
215 ],
216 }),
217 ]
218 : []),
219 ],
220
221 // - `before`: invite-only beta gate. When BRIVEN_OPEN_SIGNUPS=false,
222 // reject signups whose email isn't on the platform allowlist. The
223 // environment-driven `disableSignUp` set on every provider above is
224 // the broad "no public signups" switch; this `before` hook is the
225 // "but THESE specific emails are allowed" carve-out so admins can
226 // invite users one by one without flipping the global toggle.
227 // - `after`: auto-create the personal org + mark the allowlist entry
228 // as accepted so the dashboard can show pending vs claimed invites.
229 databaseHooks: {
230 user: {
231 create: {
232 before: async (user) => {
233 // DB-backed override (platform_settings.openSignups) takes
234 // precedence over the env var; the env stays as the bootstrap
235 // default until the first dashboard flip writes a row.
236 const { getOpenSignupsFlag } = await import('../services/platform-settings.js');
237 const openSignups = await getOpenSignupsFlag();
238 if (openSignups) return;
239 const email = user.email?.toLowerCase().trim();
240 if (!email) {
241 throw new Error('signup_allowlist_required: email missing on user.create');
242 }
243 const { isEmailAllowed } = await import('../services/signup-allowlist.js');
244 const allowed = await isEmailAllowed(email);
245 if (!allowed) {
246 // Throwing aborts Better Auth's signup flow — the caller
247 // gets a clean error response. The string lands in
248 // logs/audit per Better Auth's own error path.
249 throw new Error(
250 'signup_not_allowlisted: this email is not on the invite-only beta allowlist',
251 );
252 }
253 },
254 after: async (user) => {
255 try {
256 await ensurePersonalOrg({
257 userId: user.id,
258 email: user.email,
259 name: user.name ?? null,
260 });
261 } catch (err) {
262 log.error('personal_org_create_after_signup_failed', {
263 userId: user.id,
264 error: err instanceof Error ? err.message : String(err),
265 });
266 }
267 // Stamp the allowlist row when signups are gated. Reads the
268 // same DB-backed flag the before-hook used, so a mid-flow
269 // flag flip stays consistent.
270 const { getOpenSignupsFlag } = await import('../services/platform-settings.js');
271 const openSignups = await getOpenSignupsFlag();
272 if (!openSignups) {
273 try {
274 const { markAllowlistAccepted } = await import(
275 '../services/signup-allowlist.js'
276 );
277 await markAllowlistAccepted(user.email);
278 } catch (err) {
279 log.warn('allowlist_accepted_stamp_failed', {
280 userId: user.id,
281 error: err instanceof Error ? err.message : String(err),
282 });
283 }
284 }
285 },
286 },
287 },
288 },
289
290 logger: {
291 disabled: false,
292 level: env.BRIVEN_LOG_LEVEL,
293 log: (level, msg, ...rest) => {
294 const fields = rest.length > 0 ? { extra: rest } : undefined;
295 switch (level) {
296 case 'error':
297 log.error(`auth: ${msg}`, fields);
298 break;
299 case 'warn':
300 log.warn(`auth: ${msg}`, fields);
301 break;
302 case 'info':
303 log.info(`auth: ${msg}`, fields);
304 break;
305 default:
306 log.debug(`auth: ${msg}`, fields);
307 }
308 },
309 },
310});
311
312export type Session = typeof auth.$Infer.Session.session;
313export type User = typeof auth.$Infer.Session.user;