auth.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. import { passkey } from '@better-auth/passkey'
  2. import { betterAuth } from 'better-auth'
  3. import { prismaAdapter } from 'better-auth/adapters/prisma'
  4. import { APIError, createAuthMiddleware } from 'better-auth/api'
  5. import { nextCookies } from 'better-auth/next-js'
  6. import { bearer } from 'better-auth/plugins/bearer'
  7. import { twoFactor } from 'better-auth/plugins/two-factor'
  8. import { db } from './db'
  9. import { logAudit } from './audit'
  10. import { noteDevice, sendNewDeviceMail } from '@/lib/known-devices'
  11. import { sendAccountMail } from '@/lib/account-mail'
  12. import { isDemoMode } from './demo'
  13. import { googleSignInConfig } from './auth-providers'
  14. import { scopedSessionMayCall, sessionTokensInHeaders } from './session-scope'
  15. import {
  16. AUTH_IP_HEADERS,
  17. auditAccountSecurityRoute,
  18. auditTwoFactorChange,
  19. rememberPasskeyBeforeRemoval,
  20. } from './account-security-audit'
  21. const baseURL = process.env.NEXT_PUBLIC_APP_URL
  22. const google = googleSignInConfig()
  23. const isProduction = baseURL?.startsWith('https://')
  24. /**
  25. * Origins allowed to sign in.
  26. *
  27. * Production trusts only the app's own URL. The technician app is native and
  28. * sends no Origin header, so it needs nothing added here.
  29. *
  30. * Development also trusts the Expo dev server, which serves the technician app
  31. * in a browser. Without this, signing in from Expo web is refused with
  32. * "Invalid origin" and the app looks like it rejected the password, when what
  33. * actually happened is a CSRF check doing its job.
  34. *
  35. * Wildcarded on the port rather than pinned, because Expo walks up from 8081
  36. * whenever a port is busy and a pinned list goes stale the first time two dev
  37. * servers overlap. Better Auth matches these as glob patterns.
  38. */
  39. const EXPO_DEV_ORIGINS = [
  40. 'http://localhost:*',
  41. 'http://127.0.0.1:*',
  42. // Expo also serves on the machine's LAN address, which is the same host the
  43. // workshop is reached on during development. Derived rather than hardcoded
  44. // so this keeps working on a different network.
  45. ...devLanOrigin(),
  46. ...(process.env.EXPO_DEV_ORIGIN ? [process.env.EXPO_DEV_ORIGIN] : []),
  47. ]
  48. function devLanOrigin(): string[] {
  49. if (!baseURL) return []
  50. try {
  51. const { hostname } = new URL(baseURL)
  52. if (hostname === 'localhost' || hostname === '127.0.0.1') return []
  53. return [`http://${hostname}:*`]
  54. } catch {
  55. return []
  56. }
  57. }
  58. const trustedOrigins = [
  59. ...(baseURL ? [baseURL] : []),
  60. ...(process.env.NODE_ENV === 'production' ? [] : EXPO_DEV_ORIGINS),
  61. ]
  62. export const auth = betterAuth({
  63. baseURL,
  64. basePath: '/api/public/auth',
  65. trustedOrigins,
  66. database: prismaAdapter(db, {
  67. provider: 'postgresql',
  68. }),
  69. emailVerification: {
  70. sendOnSignUp: true,
  71. sendVerificationEmail: async ({ user, url }) => {
  72. // Only send if email verification is required
  73. const setting = await db.systemSetting.findUnique({
  74. where: { key: 'email.verificationRequired' },
  75. })
  76. if (setting?.value !== 'true') return
  77. // Invited addresses are verified like any other. This used to skip the
  78. // mail when a pending invitation existed for the address, relying on
  79. // acceptInvitation to mark the user verified; but an invitation only
  80. // proves the inviter typed the address, and any admin anywhere could
  81. // suppress somebody's verification mail just by inviting them.
  82. // Server-side rate limit: 60 seconds between verification emails per user
  83. const cooldownKey = `email-verify-cooldown:${user.id}`
  84. const existingCooldown = await db.verification.findUnique({
  85. where: { identifier: cooldownKey },
  86. })
  87. if (existingCooldown && existingCooldown.expiresAt > new Date()) return
  88. // Set cooldown record
  89. await db.verification.upsert({
  90. where: { identifier: cooldownKey },
  91. create: {
  92. identifier: cooldownKey,
  93. value: '1',
  94. expiresAt: new Date(Date.now() + 60_000),
  95. },
  96. update: {
  97. expiresAt: new Date(Date.now() + 60_000),
  98. },
  99. })
  100. try {
  101. await sendAccountMail({
  102. to: user.email,
  103. subject: 'Verify your Torqvoice email',
  104. name: user.name,
  105. paragraphs: ['Please confirm this is your email address by opening the link below.'],
  106. link: { text: 'Verify your email', url },
  107. notes: ["If you didn't create a Torqvoice account, you can ignore this mail."],
  108. })
  109. } catch (error) {
  110. console.error('[emailVerification] Failed to send verification email:', error)
  111. }
  112. },
  113. },
  114. // better-auth allows three sign-ins per ten seconds in production. The
  115. // end-to-end suite signs in far more often than that, on purpose, so its
  116. // server runs with the limiter off. Nothing else sets this variable.
  117. rateLimit: {
  118. enabled: process.env.NODE_ENV === 'production' && process.env.AUTH_RATE_LIMIT !== 'off',
  119. },
  120. socialProviders: google
  121. ? {
  122. google: {
  123. clientId: google.clientId,
  124. clientSecret: google.clientSecret,
  125. // Always show the chooser: a workshop laptop is shared, and a
  126. // silent sign-in with whatever Google account is open is wrong
  127. // more often than it is convenient.
  128. prompt: 'select_account',
  129. },
  130. }
  131. : undefined,
  132. account: {
  133. accountLinking: {
  134. enabled: true,
  135. // A Google sign-in whose email matches a password account attaches to
  136. // that account rather than creating a second person with the same
  137. // email, but only when both sides have proved the address. Google is
  138. // deliberately not a trusted provider: better-auth links a trusted
  139. // provider's account without looking at email_verified at all, and
  140. // anyone can create a Google account with somebody else's address on
  141. // it. Trusted, that signed a stranger into the workshop that owns the
  142. // address.
  143. //
  144. // The local account has to be verified too. Google's word covers the
  145. // person now signing in; it says nothing about who made the password
  146. // account. Left unverified, that account can be anyone's: sign up with
  147. // a stranger's address and a password of your own, and when they later
  148. // press "Continue with Google" they are signed into your account and
  149. // build their workshop behind a password you hold. So an unverified
  150. // password account is not joined; the person is sent back to sign-in
  151. // with a message (see sign-in-form.tsx) and gets in with the password,
  152. // where verifying the address makes the Google route open up.
  153. // e2e/specs/cloud/google-sign-in.spec.ts holds all three halves.
  154. requireLocalEmailVerified: true,
  155. },
  156. },
  157. emailAndPassword: {
  158. enabled: true,
  159. // A reset is how a person recovers from a stolen password; leaving the
  160. // thief's sessions alive would make it theatre. Change-password passes
  161. // revokeOtherSessions from the form for the same reason.
  162. revokeSessionsOnPasswordReset: true,
  163. sendResetPassword: async ({ user, url }) => {
  164. await sendAccountMail({
  165. to: user.email,
  166. subject: 'Reset your Torqvoice password',
  167. name: user.name,
  168. paragraphs: [
  169. 'We received a request to reset the password on your Torqvoice account. Open the link below to choose a new one.',
  170. ],
  171. link: { text: 'Reset your password', url },
  172. notes: [
  173. "If you didn't ask for this, you can ignore this mail and your password stays as it is.",
  174. 'The link expires shortly.',
  175. ],
  176. })
  177. },
  178. },
  179. session: {
  180. expiresIn: 60 * 60 * 24 * 7, // 7 days
  181. updateAge: 60 * 60 * 24, // 1 day
  182. // No cookie cache. It saved one session lookup per request and in return
  183. // let a revoked session keep working for up to five minutes: a phone
  184. // signed out from the devices list stayed signed in, and a password
  185. // change did not end the other browser until the cache ran out. The
  186. // session table is read on every request now; membership already was.
  187. cookieCache: {
  188. enabled: false,
  189. },
  190. // The workshop a passwordless phone sign-in is bound to (see
  191. // lib/session-scope.ts). Declared so getSession hands it back with the
  192. // session, and so the setup and one-time code routes can write it in the
  193. // same insert that creates the session. Never accepted from a caller.
  194. additionalFields: {
  195. organizationId: { type: 'string', required: false, input: false },
  196. },
  197. },
  198. hooks: {
  199. // A session bound to one workshop gets nothing from the auth handler but
  200. // its own session and a sign-out. The rest of it is the account's: the
  201. // other sessions and their tokens, passkeys, the password, the name.
  202. before: createAuthMiddleware(async (ctx) => {
  203. await rememberPasskeyBeforeRemoval(ctx).catch((error) =>
  204. console.error('[auth] could not read the passkey being removed:', error)
  205. )
  206. if (scopedSessionMayCall(ctx.path)) return
  207. const tokens = [
  208. ...sessionTokensInHeaders(ctx.request?.headers, ctx.context.authCookies.sessionToken.name),
  209. ...sessionTokensInHeaders(ctx.headers, ctx.context.authCookies.sessionToken.name),
  210. ]
  211. if (tokens.length === 0) return
  212. const scoped = await db.session.findFirst({
  213. where: { token: { in: tokens }, organizationId: { not: null } },
  214. select: { id: true },
  215. })
  216. if (scoped) {
  217. throw new APIError('FORBIDDEN', {
  218. message: 'This sign-in only works in the technician app.',
  219. })
  220. }
  221. }),
  222. // Passkeys added and removed, and sign-ins with a backup code, for the
  223. // audit log. Best effort: the change has happened either way.
  224. after: createAuthMiddleware(async (ctx) => {
  225. await auditAccountSecurityRoute(ctx).catch((error) =>
  226. console.error('[audit] account security event failed:', error)
  227. )
  228. }),
  229. },
  230. advanced: {
  231. useSecureCookies: isProduction,
  232. ipAddress: {
  233. // Only headers a proxy we control has overwritten. The proxy sets
  234. // x-real-ip from the real connection; cf-connecting-ip is whatever the
  235. // caller typed unless Cloudflare genuinely fronts every request, which
  236. // it does not while production runs grey-clouded. Trusting it let a
  237. // caller change one digit and become a different person to every
  238. // per-address limit in the product. See lib/rate-limit.ts.
  239. ipAddressHeaders: AUTH_IP_HEADERS,
  240. },
  241. },
  242. databaseHooks: {
  243. session: {
  244. create: {
  245. after: async (session, ctx) => {
  246. await db.user.update({
  247. where: { id: session.userId },
  248. data: { lastLogin: new Date() },
  249. })
  250. // Which device this is, and a mail when the account has not seen
  251. // it before. Its first device is recorded without a word: that is
  252. // the sign-up, or an account from before devices were tracked. The
  253. // demo's one shared account is every visitor's browser and sends no
  254. // mail, so it is not tracked at all.
  255. const sighting = isDemoMode
  256. ? null
  257. : await noteDevice(
  258. {
  259. id: session.id,
  260. userId: session.userId,
  261. userAgent: ((session as Record<string, unknown>).userAgent as string) ?? null,
  262. ipAddress: ((session as Record<string, unknown>).ipAddress as string) ?? null,
  263. },
  264. ctx
  265. ).catch((error) => {
  266. console.error('[auth] could not record the device:', error)
  267. return null
  268. })
  269. if (sighting?.isNew && !sighting.isFirst) {
  270. const account = await db.user.findUnique({
  271. where: { id: session.userId },
  272. select: { email: true, name: true },
  273. })
  274. if (account?.email) {
  275. sendNewDeviceMail({
  276. to: account.email,
  277. name: account.name,
  278. label: sighting.label,
  279. ip: ((session as Record<string, unknown>).ipAddress as string) ?? null,
  280. at: new Date(),
  281. }).catch((error) => console.error('[auth] new-device mail failed:', error))
  282. }
  283. }
  284. // Audit: log successful login. A session bound to a workshop is
  285. // logged there, not in whichever workshop the account joined first.
  286. const boundTo = (session as Record<string, unknown>).organizationId
  287. const membership = await db.organizationMember.findFirst({
  288. where: {
  289. userId: session.userId,
  290. ...(typeof boundTo === 'string' && boundTo ? { organizationId: boundTo } : {}),
  291. },
  292. select: { organizationId: true },
  293. })
  294. logAudit(
  295. { userId: session.userId, organizationId: membership?.organizationId ?? '' },
  296. {
  297. action: 'auth.login',
  298. message: 'User logged in',
  299. ip: ((session as Record<string, unknown>).ipAddress as string) ?? null,
  300. userAgent: ((session as Record<string, unknown>).userAgent as string) ?? null,
  301. }
  302. ).catch(() => {
  303. /* best-effort */
  304. })
  305. },
  306. },
  307. },
  308. user: {
  309. update: {
  310. // Two-factor sign-in turned on or off, for the audit log.
  311. after: async (user, ctx) => {
  312. await auditTwoFactorChange(user, ctx).catch((error) =>
  313. console.error('[audit] two-factor change failed:', error)
  314. )
  315. },
  316. },
  317. create: {
  318. before: async (user) => {
  319. // Block registration if disabled via system settings (always disabled in demo mode)
  320. const setting = isDemoMode
  321. ? null
  322. : await db.systemSetting.findUnique({
  323. where: { key: 'registration.disabled' },
  324. })
  325. if (isDemoMode || setting?.value === 'true') {
  326. // Allow registration if there's a pending invitation for this email
  327. const invitation = await db.teamInvitation.findFirst({
  328. where: {
  329. email: user.email,
  330. status: 'pending',
  331. expiresAt: { gt: new Date() },
  332. },
  333. })
  334. if (!invitation) {
  335. return false
  336. }
  337. }
  338. return { data: user }
  339. },
  340. after: async (user) => {
  341. // Auto-promote the first registered user to super admin
  342. const count = await db.user.count()
  343. if (count === 1) {
  344. await db.user.update({
  345. where: { id: user.id },
  346. data: { isSuperAdmin: true, termsAcceptedAt: new Date() },
  347. })
  348. } else {
  349. await db.user.update({
  350. where: { id: user.id },
  351. data: { termsAcceptedAt: new Date() },
  352. })
  353. }
  354. },
  355. },
  356. },
  357. },
  358. plugins: [
  359. // Lets the technician app authenticate with `Authorization: Bearer <token>`
  360. // instead of a cookie. Session lookup, expiry and revocation stay inside
  361. // Better Auth rather than being reimplemented against the session table,
  362. // so signing out on the web really does kill the phone's session too.
  363. bearer(),
  364. twoFactor({ issuer: 'Torqvoice' }),
  365. passkey({
  366. rpID: baseURL ? new URL(baseURL).hostname : 'localhost',
  367. rpName: 'Torqvoice',
  368. origin: baseURL || 'http://localhost:3000',
  369. }),
  370. nextCookies(), // Must be last plugin
  371. ],
  372. })