cached-session.ts 3.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798
  1. import { cache } from 'react'
  2. import { cookies, headers } from 'next/headers'
  3. import { auth } from './auth'
  4. import { db } from './db'
  5. import { SCOPED_SESSION_MESSAGE, sessionOrganizationScope } from './session-scope'
  6. export const getCachedSession = cache(async () => {
  7. return auth.api.getSession({ headers: await headers() })
  8. })
  9. export const getCachedMembership = cache(async (userId: string) => {
  10. // A session minted from a setup code or a one-time code is bound to the
  11. // workshop that minted it, and neither the header nor the cookie below may
  12. // name another (lib/session-scope.ts).
  13. const scope = await getCachedSessionScope()
  14. if (scope) return resolveMembership(userId, scope, { bound: true })
  15. const cookieStore = await cookies()
  16. // Which workshop the caller means.
  17. //
  18. // The web app says so with a cookie. The technician app cannot: it holds a
  19. // bearer token and sends no cookies at all, so it names the workshop in a
  20. // header instead. Reading only the cookie meant every part of the app
  21. // outside `withApiAuth` silently resolved a multi-workshop user to whichever
  22. // membership came back first — so a technician viewing a photo from their
  23. // second workshop was refused it, and their live-update socket subscribed to
  24. // the wrong one.
  25. //
  26. // Neither value grants anything. Both only select, and the membership lookup
  27. // below is what decides.
  28. const activeOrgHeader = (await headers()).get('x-org-id') ?? undefined
  29. const activeOrgCookie = activeOrgHeader ?? cookieStore.get('active-org-id')?.value
  30. return resolveMembership(userId, activeOrgCookie)
  31. })
  32. const MEMBERSHIP_SELECT = {
  33. organizationId: true,
  34. role: true,
  35. roleId: true,
  36. customRole: {
  37. select: { isAdmin: true, permissions: { select: { action: true, subject: true } } },
  38. },
  39. } as const
  40. /**
  41. * The workshop a person is acting in: the one they named when they belong to
  42. * it, otherwise one they do belong to.
  43. *
  44. * The one place this is decided. The live-update socket used to decide it for
  45. * itself and treated the cookie as a requirement instead of a preference, so
  46. * a browser holding a stale `active-org-id` (left over from somebody else's
  47. * sign-in on the same machine) used the whole app normally and was refused
  48. * its socket: no live updates and nobody's chips, with nothing to say why.
  49. *
  50. * The name is only ever a preference. What is returned is always a membership
  51. * this person really has, so nothing here can place anybody in a workshop
  52. * they are not a member of.
  53. */
  54. export async function resolveMembership(
  55. userId: string,
  56. preferredOrganizationId?: string,
  57. options: { bound?: boolean } = {}
  58. ) {
  59. if (preferredOrganizationId) {
  60. const preferred = await db.organizationMember.findFirst({
  61. where: { userId, organizationId: preferredOrganizationId },
  62. select: MEMBERSHIP_SELECT,
  63. })
  64. if (preferred) return preferred
  65. }
  66. // A session bound to a workshop has no other to fall back to. If the person
  67. // has left it, the session is good for nothing, and saying so is better
  68. // than quietly placing them in a workshop that never vouched for it.
  69. if (options.bound) return null
  70. return db.organizationMember.findFirst({ where: { userId }, select: MEMBERSHIP_SELECT })
  71. }
  72. /**
  73. * The workshop the current request's session is bound to, or null for an
  74. * ordinary sign-in.
  75. */
  76. export async function getCachedSessionScope(): Promise<string | null> {
  77. return sessionOrganizationScope(await getCachedSession())
  78. }
  79. /**
  80. * Refuses a session bound to one workshop.
  81. *
  82. * For actions that belong to the account rather than to a workshop: changing
  83. * the email, deleting the account, starting a new workshop. A workshop that
  84. * signed a phone in without a password can vouch for the person inside that
  85. * workshop and nowhere else, and these reach everywhere.
  86. */
  87. export async function assertAccountSession(): Promise<void> {
  88. if (await getCachedSessionScope()) throw new Error(SCOPED_SESSION_MESSAGE)
  89. }