auth.ts 14 KB

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