auth.ts 13 KB

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