with-api-auth.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import { NextResponse } from 'next/server'
  2. import { runAsActor } from '@/lib/realtime/actor.server'
  3. import { ZodError } from 'zod'
  4. import { auth } from './auth'
  5. import { db } from './db'
  6. import { hasAllPermissions, type PermissionInput } from './permissions'
  7. import { rateLimit } from './rate-limit'
  8. import { DocumentLockedError } from './document-lock'
  9. import { sessionOrganizationScope } from './session-scope'
  10. /**
  11. * Request wrapper for the token-authenticated API the technician app talks to.
  12. *
  13. * Deliberately not a copy of `withAuth`. That one serves server actions: it
  14. * reads a cookie, returns `{ success, data }` and lets the error text through
  15. * to a caller that is our own UI. This one faces a client we do not control on
  16. * a device we do not own, so it authenticates from a header, answers in HTTP
  17. * status codes, and never lets an internal message reach the wire.
  18. *
  19. * What it does NOT do is reimplement session handling. The bearer plugin is
  20. * registered in `./auth`, so `auth.api.getSession` resolves an
  21. * `Authorization: Bearer <token>` header through Better Auth's own machinery.
  22. * Querying the session table directly would work right up until a session is
  23. * revoked, a password changes, or a token is rotated, and then it would keep
  24. * on working, which is the bug.
  25. */
  26. export type ApiAuthContext = {
  27. userId: string
  28. organizationId: string
  29. role: string
  30. isSuperAdmin: boolean
  31. isAdmin: boolean
  32. /**
  33. * Every technician row this user owns in the active organization. A person
  34. * can hold more than one (a shop that lanes its board by speciality gives
  35. * the same mechanic a row per lane), so jobs are always looked up with
  36. * `in: technicianIds`, never by a single id.
  37. */
  38. technicianIds: string[]
  39. }
  40. type WithApiAuthOptions = {
  41. requiredPermissions?: PermissionInput[]
  42. /**
  43. * Refuse the request unless the user is an active technician in the active
  44. * organization. The technician app has no screen that means anything for an
  45. * office user, and an empty job list would read as "no work today" rather
  46. * than "you are not set up as a technician".
  47. */
  48. requireTechnician?: boolean
  49. /** Per-IP-and-path budget. Defaults to the shared 30-per-minute. */
  50. rateLimit?: { limit?: number; windowMs?: number }
  51. }
  52. /** Machine-readable codes so the app can branch without parsing prose. */
  53. export type ApiErrorCode =
  54. | 'unauthorized'
  55. | 'forbidden'
  56. | 'not_technician'
  57. | 'no_organization'
  58. | 'not_found'
  59. | 'invalid_request'
  60. | 'conflict'
  61. | 'document_locked'
  62. | 'server_error'
  63. export function apiError(status: number, code: ApiErrorCode, message: string, extra?: unknown) {
  64. return NextResponse.json(
  65. { error: { code, message, ...(extra ? { details: extra } : {}) } },
  66. { status }
  67. )
  68. }
  69. export function apiOk<T>(data: T, status = 200) {
  70. return NextResponse.json({ data }, { status })
  71. }
  72. export async function withApiAuth(
  73. request: Request,
  74. handler: (ctx: ApiAuthContext) => Promise<NextResponse>,
  75. options: WithApiAuthOptions = {}
  76. ): Promise<NextResponse> {
  77. // Cheap rejections first: a flood of unauthenticated requests should cost a
  78. // map lookup, not a database round trip.
  79. //
  80. // Note this limiter is per-process and in-memory. Behind more than one app
  81. // container the effective budget is the limit times the container count.
  82. // Good enough to blunt a runaway client; not a defence against a determined
  83. // attacker, which would need a shared store.
  84. const limited = rateLimit(request, options.rateLimit)
  85. if (limited) return limited
  86. const authHeader = request.headers.get('authorization')
  87. if (!authHeader?.toLowerCase().startsWith('bearer ')) {
  88. return apiError(401, 'unauthorized', 'Missing bearer token.')
  89. }
  90. let session: Awaited<ReturnType<typeof auth.api.getSession>>
  91. try {
  92. session = await auth.api.getSession({ headers: request.headers })
  93. } catch {
  94. // A malformed token is a client mistake, not a server fault.
  95. return apiError(401, 'unauthorized', 'Invalid or expired token.')
  96. }
  97. if (!session?.user?.id) {
  98. return apiError(401, 'unauthorized', 'Invalid or expired token.')
  99. }
  100. const userId = session.user.id
  101. // A session minted from a setup code or a one-time code is bound to the
  102. // workshop that minted it (lib/session-scope.ts). It is that workshop and
  103. // nothing else, and it carries none of the account's platform rights.
  104. const scope = sessionOrganizationScope(session)
  105. const user = await db.user.findUnique({
  106. where: { id: userId },
  107. select: { isSuperAdmin: true },
  108. })
  109. const isSuperAdmin = !scope && (user?.isSuperAdmin ?? false)
  110. // The app names which workshop it is talking to, because a user may belong
  111. // to several. The header only selects; it never grants. Membership is what
  112. // decides, and an id the user is not a member of falls through to their
  113. // default rather than erroring, so a stale header cannot lock the app out.
  114. //
  115. // A bound session does not get to select. Its workshop is the only answer,
  116. // and if the person has left it there is nothing to fall back to.
  117. const requestedOrgId = scope ?? request.headers.get('x-org-id')
  118. const memberSelect = {
  119. organizationId: true,
  120. role: true,
  121. roleId: true,
  122. customRole: {
  123. select: { isAdmin: true, permissions: { select: { action: true, subject: true } } },
  124. },
  125. } as const
  126. let membership = requestedOrgId
  127. ? await db.organizationMember.findFirst({
  128. where: { userId, organizationId: requestedOrgId },
  129. select: memberSelect,
  130. })
  131. : null
  132. // 401 rather than 403: this session can never work again, and the app
  133. // treats a 401 as the cue to sign in afresh.
  134. if (!membership && scope) {
  135. return apiError(401, 'unauthorized', 'You are no longer a member of this workshop.')
  136. }
  137. if (!membership) {
  138. membership = await db.organizationMember.findFirst({
  139. where: { userId },
  140. select: memberSelect,
  141. })
  142. }
  143. if (!membership?.organizationId) {
  144. return apiError(403, 'no_organization', 'This account is not a member of any workshop.')
  145. }
  146. const organizationId = membership.organizationId
  147. const isOwnerOrAdmin = membership.role === 'owner' || membership.role === 'admin'
  148. const roleIsAdmin = membership.customRole?.isAdmin === true
  149. // Mirrors `withAuth` exactly, and must continue to: an API that disagrees
  150. // with the web app about who may do what is a hole shaped like a client.
  151. if (!isSuperAdmin && options.requiredPermissions?.length) {
  152. if (!isOwnerOrAdmin && !roleIsAdmin) {
  153. const granted = membership.customRole?.permissions ?? []
  154. if (!hasAllPermissions(granted, options.requiredPermissions)) {
  155. return apiError(403, 'forbidden', 'Your role does not allow this.')
  156. }
  157. }
  158. }
  159. const technicians = await db.technician.findMany({
  160. where: { organizationId, userId, isActive: true },
  161. select: { id: true },
  162. })
  163. const technicianIds = technicians.map((t) => t.id)
  164. if (options.requireTechnician && technicianIds.length === 0) {
  165. return apiError(
  166. 403,
  167. 'not_technician',
  168. 'This account is not set up as a technician in this workshop. Ask the workshop to add you.'
  169. )
  170. }
  171. try {
  172. // Inside the actor, so a write from the phone tells the desk's screens
  173. // that it came from the app and who made it (lib/realtime).
  174. return await runAsActor({ userId, name: null, source: 'app' }, () =>
  175. handler({
  176. userId,
  177. organizationId,
  178. role: isSuperAdmin ? 'super_admin' : (membership.role ?? 'member'),
  179. isSuperAdmin,
  180. isAdmin: isSuperAdmin || isOwnerOrAdmin || roleIsAdmin,
  181. technicianIds,
  182. })
  183. )
  184. } catch (err) {
  185. // Zod messages describe the caller's own payload, so they are safe and
  186. // genuinely useful to return. Everything else is ours and stays here.
  187. // A locked document is a refusal, not a fault: the technician needs to be
  188. // told the job is locked (and to see the office about it), not to retry a
  189. // request that can never succeed. The message names the rule that applied
  190. // and is written for the caller, so it is safe to return.
  191. if (err instanceof DocumentLockedError) {
  192. return apiError(409, 'document_locked', err.message)
  193. }
  194. if (err instanceof ZodError) {
  195. return apiError(
  196. 400,
  197. 'invalid_request',
  198. 'The request body was not valid.',
  199. err.issues.map((e) => ({ path: e.path.join('.'), message: e.message }))
  200. )
  201. }
  202. if (err instanceof SyntaxError) {
  203. return apiError(400, 'invalid_request', 'The request body was not valid JSON.')
  204. }
  205. console.error('[api]', request.method, new URL(request.url).pathname, err)
  206. // In development the app is the fastest place to read the failure: the
  207. // person holding the phone is the person who can fix it, and making them
  208. // go and find the server console for every 500 is a slow way to debug a
  209. // client that runs somewhere else. Production says nothing, because an
  210. // internal message is a map of the server drawn for whoever asked.
  211. if (process.env.NODE_ENV !== 'production') {
  212. return apiError(
  213. 500,
  214. 'server_error',
  215. err instanceof Error ? err.message : 'Something went wrong. Try again.'
  216. )
  217. }
  218. return apiError(500, 'server_error', 'Something went wrong. Try again.')
  219. }
  220. }