auth.ts 12 KB

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