auth.ts313 lines · main
| 1 | import { randomBytes } from 'node:crypto'; |
| 2 | |
| 3 | import { betterAuth } from 'better-auth'; |
| 4 | import { drizzleAdapter } from 'better-auth/adapters/drizzle'; |
| 5 | import { genericOAuth, magicLink } from 'better-auth/plugins'; |
| 6 | |
| 7 | import { getDb } from '../db/client.js'; |
| 8 | import { accounts, sessions, users, verifications } from '../db/schema.js'; |
| 9 | import { env } from '../env.js'; |
| 10 | import { ensurePersonalOrg } from '../services/orgs.js'; |
| 11 | import { log } from './logger.js'; |
| 12 | import { |
| 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 | */ |
| 27 | function 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 | */ |
| 51 | export 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 | |
| 312 | export type Session = typeof auth.$Infer.Session.session; |
| 313 | export type User = typeof auth.$Infer.Session.user; |