From 6a233ac8b30a7d6685a99bd208452f0802c234e1 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Wed, 21 Oct 2026 19:40:54 +1100 Subject: [PATCH 1/9] feat: native app sign-in and push backend Bearer-token sessions for the React Native app, email-code / Google (browser handoff with PKCE) / Apple sign-in under /api/mobile/auth, Expo push alongside Web Push with per-session device tokens, and getAppConfig for the app. Root tsconfig, lint, prettier and Docker ignore the mobile/ folder. --- .dockerignore | 3 + .eslintrc.cjs | 1 + .prettierignore | 2 + package.json | 2 + pnpm-lock.yaml | 45 ++ .../migration.sql | 28 + prisma/schema.prisma | 31 +- src/env.js | 11 + src/pages/api/mobile/auth/apple.ts | 23 + src/pages/api/mobile/auth/email/index.ts | 13 + src/pages/api/mobile/auth/email/verify.ts | 12 + src/pages/api/mobile/auth/exchange.ts | 12 + src/pages/api/mobile/auth/handoff.ts | 32 + src/pages/api/mobile/auth/signout.ts | 25 + src/pages/api/mobile/auth/start.ts | 70 ++ src/server/api/routers/user.ts | 42 ++ .../api/services/notificationService.ts | 77 ++- src/server/api/trpc.ts | 25 +- src/server/auth.ts | 151 +++-- src/server/expoPush.ts | 116 ++++ src/server/mobileAuth.ts | 624 ++++++++++++++++++ src/server/mobileAuthTokens.ts | 162 +++++ src/tests/expoPush.integration.test.ts | 331 ++++++++++ src/tests/expoPush.test.ts | 111 ++++ src/tests/mobileAuth.integration.test.ts | 518 +++++++++++++++ src/tests/mobileAuth.test.ts | 274 ++++++++ src/utils/expoPush.ts | 67 ++ tsconfig.json | 2 +- 28 files changed, 2702 insertions(+), 108 deletions(-) create mode 100644 .prettierignore create mode 100644 prisma/migrations/20261021021229_add_device_push_token/migration.sql create mode 100644 src/pages/api/mobile/auth/apple.ts create mode 100644 src/pages/api/mobile/auth/email/index.ts create mode 100644 src/pages/api/mobile/auth/email/verify.ts create mode 100644 src/pages/api/mobile/auth/exchange.ts create mode 100644 src/pages/api/mobile/auth/handoff.ts create mode 100644 src/pages/api/mobile/auth/signout.ts create mode 100644 src/pages/api/mobile/auth/start.ts create mode 100644 src/server/expoPush.ts create mode 100644 src/server/mobileAuth.ts create mode 100644 src/server/mobileAuthTokens.ts create mode 100644 src/tests/expoPush.integration.test.ts create mode 100644 src/tests/expoPush.test.ts create mode 100644 src/tests/mobileAuth.integration.test.ts create mode 100644 src/tests/mobileAuth.test.ts create mode 100644 src/utils/expoPush.ts diff --git a/.dockerignore b/.dockerignore index c50f5a8..9f1d79e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -55,3 +55,6 @@ prisma/seed.ts creds package-lock.json + +# React Native app, built separately +mobile diff --git a/.eslintrc.cjs b/.eslintrc.cjs index c946e46..0a87eee 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -4,6 +4,7 @@ const config = { parserOptions: { project: true, }, + ignorePatterns: ["mobile/"], plugins: ["@typescript-eslint"], extends: [ "next/core-web-vitals", diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..621ff22 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,2 @@ +# React Native app, formatted with its own config (mobile/.prettierrc.json) +mobile/ diff --git a/package.json b/package.json index cc2471f..e35e93c 100644 --- a/package.json +++ b/package.json @@ -55,9 +55,11 @@ "clsx": "^2.1.0", "cmdk": "^0.2.0", "date-fns": "^3.3.1", + "expo-server-sdk": "^7.2.0", "framer-motion": "^11.0.3", "geist": "^1.7.2", "input-otp": "^1.2.3", + "jose": "^6", "lucide-react": "^0.312.0", "marked": "^18.1.0", "nanoid": "^5.1.16", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d6b2b9..7fd5304 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -106,6 +106,9 @@ dependencies: date-fns: specifier: ^3.3.1 version: 3.3.1 + expo-server-sdk: + specifier: ^7.2.0 + version: 7.2.0 framer-motion: specifier: ^11.0.3 version: 11.0.3(react-dom@18.2.0)(react@18.2.0) @@ -115,6 +118,9 @@ dependencies: input-otp: specifier: ^1.2.3 version: 1.2.3(react-dom@18.2.0)(react@18.2.0) + jose: + specifier: ^6 + version: 6.2.13 lucide-react: specifier: ^0.312.0 version: 0.312.0(react@18.2.0) @@ -4873,6 +4879,10 @@ packages: graceful-fs: 4.2.11 tapable: 2.2.1 + /err-code@2.0.3: + resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} + dev: false + /es-abstract-get@1.0.0: resolution: {integrity: sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==} engines: {node: '>= 0.4'} @@ -5452,6 +5462,15 @@ packages: engines: {node: '>=0.8.x'} dev: false + /expo-server-sdk@7.2.0: + resolution: {integrity: sha512-np157Ylo2X+aX3VYazXoXNc/akSskzEzHmVkxlGlkzOTVkSRgzBk93BX/8fjl6+O7KfimyWx5JJmoa+D5KgXdQ==} + engines: {node: '>=22.12.0'} + dependencies: + promise-limit: 2.7.0 + promise-retry: 2.0.1 + undici: 7.30.1 + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -6341,6 +6360,10 @@ packages: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} dev: false + /jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} + dev: false + /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -7271,6 +7294,18 @@ packages: optionalDependencies: fsevents: 2.3.3 + /promise-limit@2.7.0: + resolution: {integrity: sha512-7nJ6v5lnJsXwGprnGXga4wx6d1POjvi5Qmf1ivTRxTjH4Z/9Czja/UCMLVmB9N93GeWOU93XaFaEt6jbuoagNw==} + dev: false + + /promise-retry@2.0.1: + resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} + engines: {node: '>=10'} + dependencies: + err-code: 2.0.3 + retry: 0.12.0 + dev: false + /prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} dependencies: @@ -7504,6 +7539,11 @@ packages: supports-preserve-symlinks-flag: 1.0.0 dev: true + /retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + dev: false + /reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -8380,6 +8420,11 @@ packages: /undici-types@5.26.5: resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + /undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} + engines: {node: '>=20.18.1'} + dev: false + /unicode-canonical-property-names-ecmascript@2.0.0: resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} engines: {node: '>=4'} diff --git a/prisma/migrations/20261021021229_add_device_push_token/migration.sql b/prisma/migrations/20261021021229_add_device_push_token/migration.sql new file mode 100644 index 0000000..a68c81c --- /dev/null +++ b/prisma/migrations/20261021021229_add_device_push_token/migration.sql @@ -0,0 +1,28 @@ +-- CreateTable +CREATE TABLE "DevicePushToken" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "token" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "deviceName" TEXT, + "sessionId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "DevicePushToken_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "DevicePushToken_token_key" ON "DevicePushToken"("token"); + +-- CreateIndex +CREATE INDEX "DevicePushToken_userId_idx" ON "DevicePushToken"("userId"); + +-- CreateIndex +CREATE INDEX "DevicePushToken_sessionId_idx" ON "DevicePushToken"("sessionId"); + +-- AddForeignKey +ALTER TABLE "DevicePushToken" ADD CONSTRAINT "DevicePushToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "DevicePushToken" ADD CONSTRAINT "DevicePushToken_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "Session"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 04602ee..2e112e9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -35,11 +35,12 @@ model Account { } model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId Int - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + id String @id @default(cuid()) + sessionToken String @unique + userId Int + expires DateTime + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + devicePushTokens DevicePushToken[] } model User { @@ -67,6 +68,7 @@ model User { friendships Friendship[] @relation("UserFriendships") friendOf Friendship[] @relation("FriendFriendships") recurringExpenses RecurringExpense[] + devicePushTokens DevicePushToken[] } model VerificationToken { @@ -309,6 +311,25 @@ model PushNotification { subscription String } +/// Expo push tokens of the native app, one per installation. +model DevicePushToken { + id Int @id @default(autoincrement()) + userId Int + token String @unique + /// `ios` or `android` + platform String + deviceName String? + /// The app session that registered it, so the token goes when that session ends + sessionId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + session Session? @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([sessionId]) +} + model RateLimit { key String @id count Int diff --git a/src/env.js b/src/env.js index 85fa0b7..5f23ab5 100644 --- a/src/env.js +++ b/src/env.js @@ -39,6 +39,13 @@ export const env = createEnv({ AUTHENTIK_ID: z.string().optional(), AUTHENTIK_SECRET: z.string().optional(), AUTHENTIK_ISSUER: z.string().optional(), + /** + * Audience for Sign in with Apple identity tokens from the native app: the app's own bundle id. + * Comma separated to also accept Expo Go's `host.exp.Exponent` locally, which is ignored unless + * NODE_ENV is development: any Expo Go project can get tokens for it, so never rely on it in + * production. Apple sign-in is off when unset. + */ + APPLE_BUNDLE_ID: z.string().optional(), R2_ACCESS_KEY: z.string().optional(), R2_SECRET_KEY: z.string().optional(), R2_BUCKET: z.string().optional(), @@ -47,6 +54,8 @@ export const env = createEnv({ WEB_PUSH_EMAIL: z.string().optional(), WEB_PUSH_PRIVATE_KEY: z.string().optional(), WEB_PUSH_PUBLIC_KEY: z.string().optional(), + /** Access token for Expo's enhanced push security, needed once it's turned on for the project. */ + EXPO_ACCESS_TOKEN: z.string().optional(), FEEDBACK_EMAIL: z.string().optional(), DISCORD_WEBHOOK_URL: z.string().optional(), ADSENSE_CLIENT: z.string().startsWith('ca-pub-').optional(), @@ -97,6 +106,7 @@ export const env = createEnv({ AUTHENTIK_ID: process.env.AUTHENTIK_ID, AUTHENTIK_SECRET: process.env.AUTHENTIK_SECRET, AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER, + APPLE_BUNDLE_ID: process.env.APPLE_BUNDLE_ID, R2_ACCESS_KEY: process.env.R2_ACCESS_KEY, R2_SECRET_KEY: process.env.R2_SECRET_KEY, R2_BUCKET: process.env.R2_BUCKET, @@ -105,6 +115,7 @@ export const env = createEnv({ WEB_PUSH_EMAIL: process.env.WEB_PUSH_EMAIL, WEB_PUSH_PRIVATE_KEY: process.env.WEB_PUSH_PRIVATE_KEY, WEB_PUSH_PUBLIC_KEY: process.env.WEB_PUSH_PUBLIC_KEY, + EXPO_ACCESS_TOKEN: process.env.EXPO_ACCESS_TOKEN, FEEDBACK_EMAIL: process.env.FEEDBACK_EMAIL, DISCORD_WEBHOOK_URL: process.env.DISCORD_WEBHOOK_URL, ADSENSE_CLIENT: process.env.ADSENSE_CLIENT, diff --git a/src/pages/api/mobile/auth/apple.ts b/src/pages/api/mobile/auth/apple.ts new file mode 100644 index 0000000..78d115a --- /dev/null +++ b/src/pages/api/mobile/auth/apple.ts @@ -0,0 +1,23 @@ +import { z } from 'zod'; + +import { getClientIp } from '~/server/clientIp'; +import { mobileAuthRoute, parseInput, signInWithApple } from '~/server/mobileAuth'; + +const namePart = z.string().max(100).nullish(); + +const bodySchema = z.object({ + identityToken: z.string().min(1).max(8192), + // expo-apple-authentication's AppleAuthenticationFullName, or an already formatted name + fullName: z + .union([ + z.string().max(300), + z.object({ givenName: namePart, middleName: namePart, familyName: namePart }), + ]) + .nullish(), +}); + +export default mobileAuthRoute('POST', async (req, res) => { + const { identityToken, fullName } = parseInput(bodySchema, req.body); + + res.status(200).json(await signInWithApple(identityToken, fullName, getClientIp(req))); +}); diff --git a/src/pages/api/mobile/auth/email/index.ts b/src/pages/api/mobile/auth/email/index.ts new file mode 100644 index 0000000..f18e4f7 --- /dev/null +++ b/src/pages/api/mobile/auth/email/index.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; + +import { getClientIp } from '~/server/clientIp'; +import { mobileAuthRoute, parseInput, sendEmailCode } from '~/server/mobileAuth'; + +const bodySchema = z.object({ email: z.string().max(320) }); + +export default mobileAuthRoute('POST', async (req, res) => { + const { email } = parseInput(bodySchema, req.body); + + await sendEmailCode(email, getClientIp(req)); + res.status(200).json({ ok: true }); +}); diff --git a/src/pages/api/mobile/auth/email/verify.ts b/src/pages/api/mobile/auth/email/verify.ts new file mode 100644 index 0000000..296661c --- /dev/null +++ b/src/pages/api/mobile/auth/email/verify.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { getClientIp } from '~/server/clientIp'; +import { mobileAuthRoute, parseInput, verifyEmailCode } from '~/server/mobileAuth'; + +const bodySchema = z.object({ email: z.string().max(320), code: z.string().min(1).max(64) }); + +export default mobileAuthRoute('POST', async (req, res) => { + const { email, code } = parseInput(bodySchema, req.body); + + res.status(200).json(await verifyEmailCode(email, code, getClientIp(req))); +}); diff --git a/src/pages/api/mobile/auth/exchange.ts b/src/pages/api/mobile/auth/exchange.ts new file mode 100644 index 0000000..a7500ef --- /dev/null +++ b/src/pages/api/mobile/auth/exchange.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +import { getClientIp } from '~/server/clientIp'; +import { exchangeHandoffCode, mobileAuthRoute, parseInput } from '~/server/mobileAuth'; + +const bodySchema = z.object({ code: z.string().min(1).max(128), verifier: z.string().max(128) }); + +export default mobileAuthRoute('POST', async (req, res) => { + const { code, verifier } = parseInput(bodySchema, req.body); + + res.status(200).json(await exchangeHandoffCode(code, verifier, getClientIp(req))); +}); diff --git a/src/pages/api/mobile/auth/handoff.ts b/src/pages/api/mobile/auth/handoff.ts new file mode 100644 index 0000000..a60a2a0 --- /dev/null +++ b/src/pages/api/mobile/auth/handoff.ts @@ -0,0 +1,32 @@ +import { + consumeRememberedChallenge, + createHandoffCode, + getHandoffSessionUserId, + mobileAuthRoute, + parseHandoffParams, +} from '~/server/mobileAuth'; +import { appendQueryParams } from '~/server/mobileAuthTokens'; + +/** + * Where the browser lands after signing in for the app: hands the app a one-time code. + */ +export default mobileAuthRoute('GET', async (req, res) => { + const { challenge, redirectUrl } = parseHandoffParams(req.query); + const remembered = consumeRememberedChallenge(req, res); + + if (challenge !== remembered?.challenge) { + res.redirect(302, appendQueryParams(redirectUrl, { error: 'invalid_state' })); + return; + } + + // Only a session from the sign-in this /start began, never one the browser already had + const userId = await getHandoffSessionUserId(req, remembered.issuedAt); + + if (null === userId) { + res.redirect(302, appendQueryParams(redirectUrl, { error: 'unauthenticated' })); + return; + } + + const code = await createHandoffCode(challenge, userId); + res.redirect(302, appendQueryParams(redirectUrl, { code })); +}); diff --git a/src/pages/api/mobile/auth/signout.ts b/src/pages/api/mobile/auth/signout.ts new file mode 100644 index 0000000..d8bd1af --- /dev/null +++ b/src/pages/api/mobile/auth/signout.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +import { + MobileAuthError, + deleteMobileSession, + mobileAuthRoute, + parseInput, +} from '~/server/mobileAuth'; +import { getBearerToken } from '~/server/mobileAuthTokens'; + +// The device's push token, so it stops getting notifications even if unregistering it failed +const bodySchema = z.object({ pushToken: z.string().max(255).optional() }); + +export default mobileAuthRoute('POST', async (req, res) => { + const token = getBearerToken(req.headers.authorization); + + if (!token) { + throw new MobileAuthError(401, 'Missing bearer token'); + } + + const { pushToken } = parseInput(bodySchema, req.body ?? {}); + + await deleteMobileSession(token, pushToken); + res.status(200).json({ ok: true }); +}); diff --git a/src/pages/api/mobile/auth/start.ts b/src/pages/api/mobile/auth/start.ts new file mode 100644 index 0000000..e39feaf --- /dev/null +++ b/src/pages/api/mobile/auth/start.ts @@ -0,0 +1,70 @@ +import { z } from 'zod'; + +import { env } from '~/env'; +import { + MobileAuthError, + mobileAuthRoute, + parseHandoffParams, + parseInput, + rememberChallenge, + signOutBrowserSession, +} from '~/server/mobileAuth'; + +const escapeHtml = (value: string) => + value.replace( + /[&<>"']/g, + (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!, + ); + +/** + * next-auth only starts OAuth from a POST with a CSRF token, so this page fetches one and submits + * the same form the web sign-in button would. next-auth passes the action's query on to the + * provider, so Google always asks which account to use. + */ +const renderSignInPage = (provider: string, callbackUrl: string) => ` + + + + + + Signing in to SplitPro + + +

Signing in to SplitPro…

+
+ + +
+ + +`; + +const querySchema = z.object({ provider: z.literal('google') }); + +export default mobileAuthRoute('GET', async (req, res) => { + const { provider } = parseInput(querySchema, req.query); + const { challenge, redirect } = parseHandoffParams(req.query); + + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { + throw new MobileAuthError(503, 'Google sign-in is not configured'); + } + + const callbackUrl = `/api/mobile/auth/handoff?${new URLSearchParams({ challenge, redirect }).toString()}`; + + await signOutBrowserSession(req, res); + rememberChallenge(res, challenge); + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.status(200).send(renderSignInPage(provider, callbackUrl)); +}); diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index 9b41b10..fa5966c 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -23,6 +23,7 @@ import { getDocumentUploadUrl } from '~/server/storage'; import { FILE_SIZE_LIMIT } from '~/lib/constants'; import { sendFeedbackEmail, sendInviteEmail } from '~/server/mailer'; import { pushNotification } from '~/server/notification'; +import { DEVICE_PLATFORMS, isValidExpoPushToken } from '~/utils/expoPush'; import { toFixedNumber, toUIString } from '~/utils/numbers'; import { SplitwiseGroupSchema, SplitwiseUserSchema } from '~/types'; import { env } from '~/env'; @@ -494,6 +495,41 @@ export const userRouter = createTRPCRouter({ }); }), + /** + * Saves the native app's Expo push token, taking it over if another account had it. It's tied to + * the app session that sent it, so it goes away when that session ends. + */ + registerPushToken: protectedProcedure + .input( + z.object({ + token: z.string().max(255).refine(isValidExpoPushToken, 'Invalid Expo push token'), + platform: z.enum(DEVICE_PLATFORMS), + deviceName: z.string().trim().max(100).nullish(), + }), + ) + .mutation(async ({ input, ctx }) => { + const device = { + userId: ctx.session.user.id, + sessionId: ctx.mobileSessionId ?? null, + platform: input.platform, + deviceName: input.deviceName ?? null, + }; + await db.devicePushToken.upsert({ + where: { token: input.token }, + create: { token: input.token, ...device }, + update: device, + }); + }), + + /** Stops notifications to this device, e.g. before signing out of the app. */ + unregisterPushToken: protectedProcedure + .input(z.object({ token: z.string().max(255) })) + .mutation(async ({ input, ctx }) => { + await db.devicePushToken.deleteMany({ + where: { token: input.token, userId: ctx.session.user.id }, + }); + }), + deleteFriend: protectedProcedure .input(z.object({ friendId: z.number() })) .mutation(async ({ input, ctx }) => { @@ -571,6 +607,12 @@ export const userRouter = createTRPCRouter({ return env.WEB_PUSH_PUBLIC_KEY; }), + /** What the native app needs from the server config, which the web reads in getServerSideProps. */ + getAppConfig: protectedProcedure.query(() => ({ + invitesEnabled: env.ENABLE_SENDING_INVITES, + storagePublicUrl: env.R2_PUBLIC_URL ? env.R2_PUBLIC_URL : null, + })), + getAdConfig: protectedProcedure.query(() => { if (env.ADSENSE_DEMO && 'production' !== env.NODE_ENV) { return { demo: true } as const; diff --git a/src/server/api/services/notificationService.ts b/src/server/api/services/notificationService.ts index f46a2c2..18a4c15 100644 --- a/src/server/api/services/notificationService.ts +++ b/src/server/api/services/notificationService.ts @@ -1,14 +1,32 @@ import { SplitType } from '@prisma/client'; import { db } from '~/server/db'; +import { getDevicePushTokens, sendExpoPushNotifications } from '~/server/expoPush'; import { pushNotification } from '~/server/notification'; +import { type NativePushData, getExpensePushUrl } from '~/utils/expoPush'; import { toFixedNumber } from '~/utils/numbers'; +/** Every notification goes out here, to the users' web subscriptions and native app devices. */ +const sendToUsers = async (userIds: number[], pushData: NativePushData) => { + const [subscriptions, deviceTokens] = await Promise.all([ + db.pushNotification.findMany({ where: { userId: { in: userIds } } }), + getDevicePushTokens(userIds), + ]); + const webPushData = { title: pushData.title, message: pushData.message }; + + await Promise.all([ + ...subscriptions.map((s) => pushNotification(s.subscription, webPushData)), + sendExpoPushNotifications(deviceTokens, pushData), + ]); +}; + export async function sendExpensePushNotification(expenseId: string) { const expense = await db.expense.findUnique({ where: { id: expenseId, }, select: { + id: true, + groupId: true, paidBy: true, amount: true, currency: true, @@ -56,14 +74,6 @@ export async function sendExpensePushNotification(expenseId: string) { ? expense.expenseParticipants.map((p) => p.userId).filter((e) => e !== expense.deletedBy) : expense.expenseParticipants.map((p) => p.userId).filter((e) => e !== expense.addedBy); - const subscriptions = await db.pushNotification.findMany({ - where: { - userId: { - in: participants, - }, - }, - }); - const pushData = expense.deletedBy ? { title: `${expense.deletedByUser?.name ?? expense.deletedByUser?.email}`, @@ -84,9 +94,7 @@ export async function sendExpensePushNotification(expenseId: string) { message: `${expense.paidByUser.name ?? expense.paidByUser.email} paid ${expense.currency} ${toFixedNumber(expense.amount)} for ${expense.name}`, }; - const pushNotifications = subscriptions.map((s) => pushNotification(s.subscription, pushData)); - - await Promise.all(pushNotifications); + await sendToUsers(participants, { ...pushData, url: getExpensePushUrl(expense) }); } /** Tells the other members of a group that its default split was changed or removed. */ @@ -108,38 +116,30 @@ export async function sendDefaultSplitPushNotification( } const user = group.groupUsers.find((groupUser) => groupUser.userId === changedBy)?.user; - const subscriptions = await db.pushNotification.findMany({ - where: { - userId: { - in: group.groupUsers - .map((groupUser) => groupUser.userId) - .filter((userId) => userId !== changedBy), - }, + await sendToUsers( + group.groupUsers.map((groupUser) => groupUser.userId).filter((userId) => userId !== changedBy), + { + title: group.name, + message: `${user?.name ?? user?.email ?? 'A member'} ${isSet ? 'changed' : 'removed'} the default split`, + url: `/groups/${groupId}`, }, - }); - - const pushData = { - title: group.name, - message: `${user?.name ?? user?.email ?? 'A member'} ${isSet ? 'changed' : 'removed'} the default split`, - }; - - await Promise.all(subscriptions.map((s) => pushNotification(s.subscription, pushData))); + ); } -const sendToUser = async (userId: number, pushData: { title: string; message: string }) => { - const subscriptions = await db.pushNotification.findMany({ where: { userId } }); - await Promise.all(subscriptions.map((s) => pushNotification(s.subscription, pushData))); -}; - /** Tells the creator of a recurring expense that it was paused or stopped, and why. */ export async function sendRecurringStatusPushNotification(recurringId: number) { const rule = await db.recurringExpense.findUnique({ where: { id: recurringId }, select: { createdById: true, + groupId: true, status: true, statusReason: true, - occurrences: { orderBy: { occurrenceDate: 'desc' }, take: 1, select: { name: true } }, + occurrences: { + orderBy: { occurrenceDate: 'desc' }, + take: 1, + select: { id: true, name: true }, + }, }, }); @@ -148,9 +148,15 @@ export async function sendRecurringStatusPushNotification(recurringId: number) { return; } - await sendToUser(rule.createdById, { - title: `${rule.occurrences[0]?.name ?? 'A recurring expense'} ${'PAUSED' === rule.status ? 'paused' : 'stopped'}`, + const latest = rule.occurrences[0]; + await sendToUsers([rule.createdById], { + title: `${latest?.name ?? 'A recurring expense'} ${'PAUSED' === rule.status ? 'paused' : 'stopped'}`, message: rule.statusReason ?? '', + url: latest + ? `/expenses/${latest.id}` + : null === rule.groupId + ? '/activity' + : `/groups/${rule.groupId}`, }); } @@ -161,9 +167,10 @@ export async function sendRecurringGroupDeletedPushNotification( ) { await Promise.all( rules.map((rule) => - sendToUser(rule.createdById, { + sendToUsers([rule.createdById], { title: `${rule.name} stopped`, message: `The group ${groupName} was deleted`, + url: '/groups', }), ), ); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index 994897b..e6c3abe 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -16,6 +16,8 @@ import { z, ZodError } from 'zod'; import { getServerAuthSession } from '~/server/auth'; import { getClientIp } from '~/server/clientIp'; import { db } from '~/server/db'; +import { findMobileSession } from '~/server/mobileAuth'; +import { getBearerToken, hasBearerAuthorization } from '~/server/mobileAuthTokens'; /** * 1. CONTEXT @@ -27,6 +29,8 @@ import { db } from '~/server/db'; interface CreateContextOptions { session: Session | null; + /** The id of the app's Session row, when the request came with a bearer token. */ + mobileSessionId?: string; ip?: string; } @@ -40,9 +44,12 @@ interface CreateContextOptions { * * @see https://create.t3.gg/en/usage/trpc#-serverapitrpcts */ -const createInnerTRPCContext = (opts: CreateContextOptions) => { +const createInnerTRPCContext = ( + opts: CreateContextOptions, +): Omit & { ip: string; db: typeof db } => { return { session: opts.session, + mobileSessionId: opts.mobileSessionId, ip: opts.ip ?? 'unknown', db, }; @@ -57,11 +64,21 @@ const createInnerTRPCContext = (opts: CreateContextOptions) => { export const createTRPCContext = async (opts: CreateNextContextOptions) => { const { req, res } = opts; - // Get the session from the server using the getServerSession wrapper function - const session = await getServerAuthSession({ req, res }); + // Bearer token for the native app, used exclusively when sent; cookie session for the web + const { authorization } = req.headers; + + if (hasBearerAuthorization(authorization)) { + const mobileSession = await findMobileSession(getBearerToken(authorization)); + + return createInnerTRPCContext({ + session: mobileSession?.session ?? null, + mobileSessionId: mobileSession?.id, + ip: getClientIp(req), + }); + } return createInnerTRPCContext({ - session, + session: await getServerAuthSession({ req, res }), ip: getClientIp(req), }); }; diff --git a/src/server/auth.ts b/src/server/auth.ts index 4212f35..789baab 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,7 +1,13 @@ import { PrismaAdapter } from '@next-auth/prisma-adapter'; import { randomInt } from 'crypto'; import { type GetServerSidePropsContext } from 'next'; -import { getServerSession, type DefaultSession, type NextAuthOptions } from 'next-auth'; +import { + getServerSession, + type DefaultSession, + type NextAuthOptions, + type Session, + type User, +} from 'next-auth'; import { Adapter, AdapterUser } from 'next-auth/adapters'; import DiscordProvider from 'next-auth/providers/discord'; import GoogleProvider from 'next-auth/providers/google'; @@ -60,6 +66,68 @@ export async function deleteVerificationTokens(identifier: string) { await db.verificationToken.deleteMany({ where: { identifier } }); } +export const generateEmailToken = () => + Array.from( + { length: EMAIL_TOKEN_LENGTH }, + () => EMAIL_TOKEN_ALPHABET[randomInt(EMAIL_TOKEN_ALPHABET.length)], + ).join(''); + +const BLOCKED_SIGNUP_EMAIL_DOMAINS = [ + // Yahoo domains + 'yahoo.com', + 'yahoo.co.uk', + 'yahoo.co.in', + 'yahoo.ca', + 'yahoo.com.au', + 'yahoo.fr', + 'yahoo.de', + 'yahoo.es', + 'yahoo.it', + 'yahoo.co.jp', + 'ymail.com', + 'rocketmail.com', + 'yahoo.in', + 'yahoo.com.br', + 'att.net', + 'aol.com', + // Hotmail/Outlook domains + 'hotmail.com', + 'hotmail.co.uk', + 'hotmail.fr', + 'hotmail.de', + 'hotmail.it', + 'hotmail.es', + 'hotmail.ca', + 'outlook.com', + 'outlook.in', + 'live.com', + 'live.co.uk', + 'msn.com', + 'windowslive.com', +]; + +export const EMAIL_DOMAIN_BLOCKED_MESSAGE = + 'This email domain is not supported for signup. Please use Google login instead.'; + +/** + * Throws for new signups from blocked email domains. Existing users can always sign in. + */ +export const assertEmailSignInAllowed = async (email: string) => { + const existingUser = await db.user.findUnique({ + where: { email: email.toLowerCase() }, + }); + + if (existingUser) { + return; + } + + const emailDomain = email.split('@')[1]?.toLowerCase() ?? ''; + + if (BLOCKED_SIGNUP_EMAIL_DOMAINS.includes(emailDomain)) { + throw new Error(EMAIL_DOMAIN_BLOCKED_MESSAGE); + } +}; + /** * Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * object and keep type safety. @@ -147,6 +215,20 @@ const SplitProPrismaAdapter = (...args: Parameters): Adapt }; }; +export const authAdapter = SplitProPrismaAdapter(db); + +/** + * The session the app sees, for both cookie sessions and native app bearer sessions. + */ +export const buildSession = (session: DefaultSession, user: User): Session => ({ + ...session, + user: { + ...session.user, + id: user.id, + currency: user.currency, + }, +}); + /** * Options for NextAuth.js used to configure adapters, providers, callbacks, etc. * @@ -158,16 +240,10 @@ export const authOptions: NextAuthOptions = { verifyRequest: '/auth/verify-request', }, callbacks: { - session: ({ session, user }) => ({ - ...session, - user: { - ...session.user, - id: user.id, - currency: user.currency, - }, - }), + // `user` is typed as an AdapterUser with a string id, but our ids are numbers + session: ({ session, user }) => buildSession(session, user as unknown as User), }, - adapter: SplitProPrismaAdapter(db), + adapter: authAdapter, providers: getProviders(), events: { createUser: async ({ user }) => { @@ -250,55 +326,7 @@ function getProviders() { maxAge: EMAIL_TOKEN_MAX_AGE_SECONDS, normalizeIdentifier: normalizeEmailIdentifier, async sendVerificationRequest({ identifier: email, url, token }) { - // Check if user already exists - allow existing users to sign in - const existingUser = await db.user.findUnique({ - where: { email: email.toLowerCase() }, - }); - - // Only block certain email domains for NEW signups, not existing users - if (!existingUser) { - const emailDomain = email.split('@')[1]?.toLowerCase() ?? ''; - - const blockedEmailDomains = [ - // Yahoo domains - 'yahoo.com', - 'yahoo.co.uk', - 'yahoo.co.in', - 'yahoo.ca', - 'yahoo.com.au', - 'yahoo.fr', - 'yahoo.de', - 'yahoo.es', - 'yahoo.it', - 'yahoo.co.jp', - 'ymail.com', - 'rocketmail.com', - 'yahoo.in', - 'yahoo.com.br', - 'att.net', - 'aol.com', - // Hotmail/Outlook domains - 'hotmail.com', - 'hotmail.co.uk', - 'hotmail.fr', - 'hotmail.de', - 'hotmail.it', - 'hotmail.es', - 'hotmail.ca', - 'outlook.com', - 'outlook.in', - 'live.com', - 'live.co.uk', - 'msn.com', - 'windowslive.com', - ]; - - if (blockedEmailDomains.includes(emailDomain)) { - throw new Error( - 'This email domain is not supported for signup. Please use Google login instead.', - ); - } - } + await assertEmailSignInAllowed(email); const result = await sendSignUpEmail(email, token, url); if (!result) { @@ -306,10 +334,7 @@ function getProviders() { } }, async generateVerificationToken() { - return Array.from( - { length: EMAIL_TOKEN_LENGTH }, - () => EMAIL_TOKEN_ALPHABET[randomInt(EMAIL_TOKEN_ALPHABET.length)], - ).join(''); + return generateEmailToken(); }, }), ); diff --git a/src/server/expoPush.ts b/src/server/expoPush.ts new file mode 100644 index 0000000..6440f0f --- /dev/null +++ b/src/server/expoPush.ts @@ -0,0 +1,116 @@ +import { Expo, type ExpoPushReceipt, type ExpoPushTicket } from 'expo-server-sdk'; +import { env } from '~/env'; +import { db } from '~/server/db'; +import { + type NativePushData, + type PendingPushReceipt, + buildExpoPushMessages, + readPushReceipts, + readPushTickets, +} from '~/utils/expoPush'; + +/** Expo suggests waiting this long before asking how delivery went. */ +const RECEIPT_DELAY_MS = 15 * 60_000; + +export const expo = new Expo({ accessToken: env.EXPO_ACCESS_TOKEN }); + +const logErrors = (results: Array) => + results.forEach((result) => { + if ('error' === result.status) { + console.error('Expo push error', result.message, result.details); + } + }); + +/** Leaves tokens registered again since the send alone, the app is back on that device. */ +const deleteTokens = async (tokens: string[], sentAt: Date) => { + if (0 === tokens.length) { + return; + } + + await db.devicePushToken.deleteMany({ + where: { token: { in: tokens }, updatedAt: { lt: sentAt } }, + }); +}; + +/** + * The users' device tokens. Tokens whose app session expired are skipped and deleted: that app is + * signed out, even if it never got to tell us. + */ +export const getDevicePushTokens = async (userIds: number[]) => { + const devices = await db.devicePushToken.findMany({ + where: { userId: { in: userIds } }, + select: { id: true, token: true, session: { select: { expires: true } } }, + }); + const now = new Date(); + const isExpired = (device: (typeof devices)[number]) => + !!device.session && device.session.expires < now; + const expiredIds = devices.filter(isExpired).map((device) => device.id); + + if (0 < expiredIds.length) { + // Rechecked, in case the token was registered again by a live session meanwhile + await db.devicePushToken + .deleteMany({ where: { id: { in: expiredIds }, session: { expires: { lt: now } } } }) + .catch((error) => console.error('Error deleting expired Expo push tokens', error)); + } + + return devices.filter((device) => !isExpired(device)).map((device) => device.token); +}; + +export const checkReceipts = async (pending: PendingPushReceipt[], sentAt: Date) => { + const chunks = expo.chunkPushNotificationReceiptIds(pending.map((receipt) => receipt.id)); + const unregistered = await Promise.all( + chunks.map(async (ids) => { + try { + const receipts = await expo.getPushNotificationReceiptsAsync(ids); + logErrors(Object.values(receipts)); + return readPushReceipts(pending, receipts); + } catch (error) { + console.error('Error fetching Expo push receipts', error); + return []; + } + }), + ); + + await deleteTokens(unregistered.flat(), sentAt); +}; + +/** Sends to the native app and forgets tokens Expo says were unregistered. Never throws. */ +export const sendExpoPushNotifications = async (tokens: string[], data: NativePushData) => { + if (0 === tokens.length) { + return; + } + + const sentAt = new Date(); + const chunks = expo.chunkPushNotifications(buildExpoPushMessages(tokens, data)); + const results = await Promise.all( + chunks.map(async (chunk) => { + try { + const tickets = await expo.sendPushNotificationsAsync(chunk); + logErrors(tickets); + return readPushTickets( + chunk.flatMap((message) => message.to), + tickets, + ); + } catch (error) { + console.error('Error sending Expo push notifications', error); + return { unregistered: [], receipts: [] }; + } + }), + ); + + try { + await deleteTokens( + results.flatMap((result) => result.unregistered), + sentAt, + ); + } catch (error) { + console.error('Error deleting unregistered Expo push tokens', error); + } + + const pending = results.flatMap((result) => result.receipts); + if (0 < pending.length) { + setTimeout(() => { + checkReceipts(pending, sentAt).catch(console.error); + }, RECEIPT_DELAY_MS).unref(); + } +}; diff --git a/src/server/mobileAuth.ts b/src/server/mobileAuth.ts new file mode 100644 index 0000000..a74236b --- /dev/null +++ b/src/server/mobileAuth.ts @@ -0,0 +1,624 @@ +import { type User as DbUser, Prisma } from '@prisma/client'; +import { JOSEError } from 'jose/errors'; +import { createRemoteJWKSet, jwtVerify } from 'jose'; +import { type NextApiRequest, type NextApiResponse } from 'next'; +import { type Account, type Session } from 'next-auth'; +import { type Adapter, type AdapterAccount, type AdapterUser } from 'next-auth/adapters'; +import { z } from 'zod'; + +import { env } from '~/env'; +import { + EMAIL_AUTH_LIMITS, + EMAIL_TOKEN_MAX_AGE_SECONDS, + assertEmailSignInAllowed, + authAdapter, + authOptions, + buildSession, + deleteVerificationTokens, + generateEmailToken, + normalizeEmailIdentifier, +} from '~/server/auth'; +import { db } from '~/server/db'; +import { sendSignUpEmail } from '~/server/mailer'; +import { type RateLimitRule, consumeRateLimits } from '~/server/rateLimit'; + +import { + type AppleFullName, + HANDOFF_IDENTIFIER_PREFIX, + VERIFIER_PATTERN, + formatAppleFullName, + generateOpaqueToken, + handoffIdentifier, + handoffUserId, + hashVerificationToken, + isJsonContentType, + isValidChallenge, + isValidHandoffCode, + parseAppleAudiences, + parseMobileRedirect, + pkceChallenge, + readChallengeCookie, + signChallengeCookie, +} from './mobileAuthTokens'; + +const MINUTE = 60; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +// Deliberately longer than the web's 30 days: like other apps, the app stays signed in while it's +// used, with the expiry sliding forward on use below. +export const MOBILE_SESSION_MAX_AGE_SECONDS = 180 * DAY; +// Like next-auth's updateAge: extend an active session at most once a day +const MOBILE_SESSION_UPDATE_AGE_SECONDS = DAY; +const HANDOFF_CODE_MAX_AGE_SECONDS = 5 * MINUTE; +// next-auth's default, which the web uses +const WEB_SESSION_MAX_AGE_SECONDS = authOptions.session?.maxAge ?? 30 * DAY; + +const isDevelopment = () => 'development' === env.NODE_ENV; +const handoffIdentifierFilter = { startsWith: `${HANDOFF_IDENTIFIER_PREFIX}:` }; + +export const MOBILE_AUTH_LIMITS = { + exchangePerIpHour: { name: 'mobile-exchange:ip:1h', limit: 30, windowSeconds: HOUR }, + applePerIpHour: { name: 'mobile-apple:ip:1h', limit: 30, windowSeconds: HOUR }, +} satisfies Record; + +const APPLE_ISSUER = 'https://appleid.apple.com'; +const appleJwks = createRemoteJWKSet(new URL(`${APPLE_ISSUER}/auth/keys`)); + +// The Prisma adapter is typed with string ids, ours are numbers. createUser and linkAccount are +// typed via next-auth's optional @auth/core peer, which resolves to `any` without it. +const adapter = authAdapter as Omit, 'createUser' | 'linkAccount'> & { + createUser: ( + user: Pick, + ) => Promise; + linkAccount: (account: AdapterAccount) => Promise; +}; +const toDbUser = (user: AdapterUser | null) => user as unknown as DbUser | null; +const adapterUserId = (userId: number) => userId as unknown as string; + +export class MobileAuthError extends Error { + constructor( + public status: number, + message: string, + public retryAfterSeconds?: number, + ) { + super(message); + } +} + +export type MobileSignInResult = { + token: string; + user: Session['user']; +}; + +const fromNow = (seconds: number) => new Date(Date.now() + seconds * 1000); + +const getAuthSecret = () => { + const secret = authOptions.secret ?? env.NEXTAUTH_SECRET; + if (!secret) { + throw new MobileAuthError(503, 'Sign-in is not configured'); + } + return secret; +}; + +const rateLimitedError = (retryAfterSeconds: number) => { + const minutes = Math.max(1, Math.ceil(retryAfterSeconds / 60)); + return new MobileAuthError( + 429, + `Too many attempts. Try again in ${minutes} minute${1 === minutes ? '' : 's'}.`, + retryAfterSeconds, + ); +}; + +const assertRateLimits = async (checks: [RateLimitRule, string][]) => { + const result = await consumeRateLimits(checks); + if (!result.allowed) { + throw rateLimitedError(result.retryAfterSeconds); + } +}; + +const tryNormalizeEmail = (email: string) => { + try { + return normalizeEmailIdentifier(email); + } catch { + return null; + } +}; + +/** + * Signs up a user, or returns the one a concurrent sign-in with the same email just created. + */ +const createUser = async ( + user: Pick & { email: string }, +) => { + // The adapter refuses new users on invite only instances too, checked here for a clean 403 + if (env.INVITE_ONLY) { + throw new MobileAuthError(403, 'This instance is Invite Only'); + } + + try { + const created = toDbUser(await adapter.createUser(user))!; + await authOptions.events?.createUser?.({ user: created }); + return { user: created, isNewUser: true }; + } catch (error) { + if (!(error instanceof Prisma.PrismaClientKnownRequestError) || 'P2002' !== error.code) { + throw error; + } + + const existingUser = await db.user.findUnique({ where: { email: user.email } }); + if (!existingUser) { + throw error; + } + return { user: existingUser, isNewUser: false }; + } +}; + +const toSession = (user: DbUser, expires: Date) => + buildSession( + { + user: { name: user.name, email: user.email, image: user.image }, + expires: expires.toISOString(), + }, + user, + ); + +/** + * Native app sessions are ordinary Session rows, sent as a bearer token instead of a cookie. + */ +const createMobileSession = async (userId: number): Promise => { + const sessionToken = generateOpaqueToken(); + const expires = fromNow(MOBILE_SESSION_MAX_AGE_SECONDS); + + await adapter.createSession({ sessionToken, userId: adapterUserId(userId), expires }); + // Reloaded so changes from the createUser event (the default name) are included + const user = await db.user.findUniqueOrThrow({ where: { id: userId } }); + + return { token: sessionToken, user: toSession(user, expires).user }; +}; + +const completeSignIn = async (user: DbUser, account: Account, isNewUser: boolean) => { + await authOptions.events?.signIn?.({ user, account, isNewUser }); + return createMobileSession(user.id); +}; + +/** + * The app session for a bearer token, with the id of its Session row (push tokens are linked to + * it). An expired session is deleted, which also removes the push tokens it registered. + */ +export const findMobileSession = async ( + sessionToken: string | null, +): Promise<{ id: string; session: Session } | null> => { + if (!sessionToken) { + return null; + } + + const session = await db.session.findUnique({ where: { sessionToken }, include: { user: true } }); + + if (!session) { + return null; + } + + if (session.expires.getTime() < Date.now()) { + await db.session.deleteMany({ where: { sessionToken } }); + return null; + } + + const updateDueAt = + session.expires.getTime() - + MOBILE_SESSION_MAX_AGE_SECONDS * 1000 + + MOBILE_SESSION_UPDATE_AGE_SECONDS * 1000; + + if (updateDueAt > Date.now()) { + return { id: session.id, session: toSession(session.user, session.expires) }; + } + + const expires = fromNow(MOBILE_SESSION_MAX_AGE_SECONDS); + await db.session.update({ where: { sessionToken }, data: { expires } }); + return { id: session.id, session: toSession(session.user, expires) }; +}; + +export const getMobileSession = async (sessionToken: string | null) => + (await findMobileSession(sessionToken))?.session ?? null; + +/** + * Ends the app session, and stops notifications to the device if the app sends its push token. + */ +export const deleteMobileSession = async (sessionToken: string, pushToken?: string) => { + const session = pushToken + ? await db.session.findUnique({ where: { sessionToken }, select: { userId: true } }) + : null; + + await db.$transaction([ + ...(session && pushToken + ? [db.devicePushToken.deleteMany({ where: { token: pushToken, userId: session.userId } })] + : []), + db.session.deleteMany({ where: { sessionToken } }), + ]); +}; + +/** + * Sends the same sign-in code as the web email provider, stored the way next-auth stores it. + */ +export const sendEmailCode = async (rawEmail: string, ip: string) => { + const email = tryNormalizeEmail(rawEmail); + + if (!email) { + await assertRateLimits([ + [EMAIL_AUTH_LIMITS.sendPerIpHour, ip], + [EMAIL_AUTH_LIMITS.sendPerIpDay, ip], + ]); + throw new MobileAuthError(400, 'Invalid email address'); + } + + await assertRateLimits([ + [EMAIL_AUTH_LIMITS.sendPerEmail15m, email], + [EMAIL_AUTH_LIMITS.sendPerEmailDay, email], + [EMAIL_AUTH_LIMITS.sendPerIpHour, ip], + [EMAIL_AUTH_LIMITS.sendPerIpDay, ip], + ]); + + try { + await assertEmailSignInAllowed(email); + } catch (error) { + throw new MobileAuthError( + 403, + error instanceof Error ? error.message : 'Sign up is not allowed', + ); + } + + const token = generateEmailToken(); + await adapter.createVerificationToken({ + identifier: email, + token: hashVerificationToken(token, getAuthSecret()), + expires: fromNow(EMAIL_TOKEN_MAX_AGE_SECONDS), + }); + + // The same link the web sends, so it still works when opened in a browser + const url = new URL('/api/auth/callback/email', env.NEXTAUTH_URL); + url.searchParams.set('callbackUrl', new URL('/balances', env.NEXTAUTH_URL).toString()); + url.searchParams.set('token', token); + url.searchParams.set('email', email); + + if (!(await sendSignUpEmail(email, token, url.toString()))) { + throw new MobileAuthError(502, 'Failed to send email'); + } +}; + +/** + * Mirrors next-auth's email callback: consume the code, then sign in or sign up. + */ +export const verifyEmailCode = async (rawEmail: string, rawCode: string, ip: string) => { + const email = tryNormalizeEmail(rawEmail); + + if (!email) { + throw new MobileAuthError(400, 'Invalid email address'); + } + + // Shares its counters with the web callback, so guesses can't be split across both + const ipResult = await consumeRateLimits([[EMAIL_AUTH_LIMITS.verifyPerIpHour, ip]]); + const emailResult = await consumeRateLimits([[EMAIL_AUTH_LIMITS.verifyPerEmail, email]]); + + if (!emailResult.allowed) { + // Too many guesses for this code: burn it, a new code has to be requested + await deleteVerificationTokens(email); + throw rateLimitedError(emailResult.retryAfterSeconds); + } + + if (!ipResult.allowed) { + throw rateLimitedError(ipResult.retryAfterSeconds); + } + + const verificationToken = await adapter.useVerificationToken({ + identifier: email, + token: hashVerificationToken(rawCode.trim().toUpperCase(), getAuthSecret()), + }); + + if (!verificationToken || verificationToken.expires.getTime() < Date.now()) { + throw new MobileAuthError(400, 'Invalid or expired code'); + } + + const account: Account = { providerAccountId: email, type: 'email', provider: 'email' }; + const existingUser = toDbUser(await adapter.getUserByEmail(email)); + + if (existingUser) { + const user = toDbUser( + await adapter.updateUser({ id: adapterUserId(existingUser.id), emailVerified: new Date() }), + )!; + await authOptions.events?.updateUser?.({ user }); + return completeSignIn(user, account, false); + } + + const { user, isNewUser } = await createUser({ + email, + name: null, + image: null, + emailVerified: new Date(), + }); + return completeSignIn(user, account, isNewUser); +}; + +/** + * One-time code handed to the app after a browser sign-in, redeemable only with the PKCE verifier. + */ +export const createHandoffCode = async (challenge: string, userId: number) => { + const code = generateOpaqueToken(); + + // Opportunistic cleanup of codes the app never exchanged + db.verificationToken + .deleteMany({ where: { identifier: handoffIdentifierFilter, expires: { lt: new Date() } } }) + .catch(console.error); + + await db.verificationToken.create({ + data: { + identifier: handoffIdentifier(challenge, userId), + token: hashVerificationToken(code, getAuthSecret()), + expires: fromNow(HANDOFF_CODE_MAX_AGE_SECONDS), + }, + }); + + return code; +}; + +export const exchangeHandoffCode = async (code: string, verifier: string, ip: string) => { + await assertRateLimits([[MOBILE_AUTH_LIMITS.exchangePerIpHour, ip]]); + + if (!VERIFIER_PATTERN.test(verifier)) { + throw new MobileAuthError(400, 'Invalid verifier'); + } + + if (!isValidHandoffCode(code)) { + throw new MobileAuthError(400, 'Invalid or expired code'); + } + + // Scoped to handoff codes, so a guess can never consume someone's email sign-in code + const where = { + token: hashVerificationToken(code, getAuthSecret()), + identifier: handoffIdentifierFilter, + }; + const verificationToken = await db.verificationToken.findFirst({ where }); + + if (!verificationToken) { + throw new MobileAuthError(400, 'Invalid or expired code'); + } + + // One use only, even when the verifier is wrong. The count guards against a concurrent exchange. + const { count } = await db.verificationToken.deleteMany({ where }); + const userId = handoffUserId(verificationToken.identifier, pkceChallenge(verifier)); + + if (1 !== count || null === userId || verificationToken.expires.getTime() < Date.now()) { + throw new MobileAuthError(400, 'Invalid or expired code'); + } + + return createMobileSession(userId); +}; + +const verifyAppleIdentityToken = async (identityToken: string, audience: string[]) => { + try { + const { payload } = await jwtVerify(identityToken, appleJwks, { + issuer: APPLE_ISSUER, + audience, + }); + return payload; + } catch (error) { + if (error instanceof JOSEError) { + throw new MobileAuthError(401, 'Invalid Apple identity token'); + } + throw error; + } +}; + +/** + * Finds the user by their linked Apple account, falling back to their verified email the first + * time. Apple only includes the email reliably on the first sign-in, and it may be a private relay + * address, which is a normal deliverable address as far as we are concerned. + */ +export const signInWithApple = async ( + identityToken: string, + fullName: AppleFullName, + ip: string, +) => { + const audience = parseAppleAudiences(env.APPLE_BUNDLE_ID ?? '', isDevelopment()); + + if (0 === audience.length) { + throw new MobileAuthError(503, 'Sign in with Apple is not configured'); + } + + await assertRateLimits([[MOBILE_AUTH_LIMITS.applePerIpHour, ip]]); + + const payload = await verifyAppleIdentityToken(identityToken, audience); + + if (!payload.sub) { + throw new MobileAuthError(401, 'Invalid Apple identity token'); + } + + const account: Account = { providerAccountId: payload.sub, type: 'oauth', provider: 'apple' }; + const linkedUser = toDbUser(await adapter.getUserByAccount(account)); + + if (linkedUser) { + return completeSignIn(linkedUser, account, false); + } + + const emailVerified = true === payload.email_verified || 'true' === payload.email_verified; + const email = + 'string' === typeof payload.email && emailVerified ? tryNormalizeEmail(payload.email) : null; + + if (!email) { + throw new MobileAuthError( + 400, + 'Apple did not share a verified email address. Sign in with email or Google instead.', + ); + } + + const existingUser = toDbUser(await adapter.getUserByEmail(email)); + const { user, isNewUser } = existingUser + ? { user: existingUser, isNewUser: false } + : await createUser({ + email, + name: formatAppleFullName(fullName), + image: null, + emailVerified: new Date(), + }); + + await adapter.linkAccount({ ...account, userId: adapterUserId(user.id) }); + await authOptions.events?.linkAccount?.({ user, account, profile: user }); + + return completeSignIn(user, account, isNewUser); +}; + +const isSecureOrigin = () => env.NEXTAUTH_URL.startsWith('https://'); +const challengeCookieName = () => `${isSecureOrigin() ? '__Secure-' : ''}splitpro.mobile-challenge`; +const CHALLENGE_COOKIE_MAX_AGE_SECONDS = 15 * MINUTE; +// next-auth's session cookie, under either name it picks depending on NEXTAUTH_URL +const SESSION_COOKIES = [ + { name: 'next-auth.session-token', secure: false }, + { name: '__Secure-next-auth.session-token', secure: true }, +]; + +/** + * Validates the challenge and app redirect passed through the browser sign-in. + */ +export const parseHandoffParams = (query: unknown) => { + const { challenge, redirect } = parseInput( + z.object({ challenge: z.string(), redirect: z.string() }), + query, + ); + + if (!isValidChallenge(challenge)) { + throw new MobileAuthError(400, 'Invalid challenge'); + } + + const redirectUrl = parseMobileRedirect(redirect, isDevelopment()); + + if (!redirectUrl) { + throw new MobileAuthError(400, 'redirect is not an allowed app URI'); + } + + return { challenge, redirect, redirectUrl }; +}; + +const appendSetCookie = (res: NextApiResponse, cookie: string) => { + const existing = res.getHeader('Set-Cookie'); + const cookies = Array.isArray(existing) ? existing : existing ? [String(existing)] : []; + res.setHeader('Set-Cookie', [...cookies, cookie]); +}; + +const setChallengeCookie = (res: NextApiResponse, value: string, maxAgeSeconds: number) => { + const secure = isSecureOrigin() ? '; Secure' : ''; + appendSetCookie( + res, + `${challengeCookieName()}=${value}; Path=/api/mobile/auth; Max-Age=${maxAgeSeconds}; HttpOnly; SameSite=Lax${secure}`, + ); +}; + +/** + * Ties the handoff to a sign-in started from this browser, so a link straight to the handoff + * can't send a signed-in browser's code to whichever app claims the scheme. Signed, so it can't + * be planted without loading /start. + */ +export const rememberChallenge = (res: NextApiResponse, challenge: string) => + setChallengeCookie( + res, + signChallengeCookie(challenge, Date.now(), getAuthSecret()), + CHALLENGE_COOKIE_MAX_AGE_SECONDS, + ); + +export const consumeRememberedChallenge = (req: NextApiRequest, res: NextApiResponse) => { + setChallengeCookie(res, '', 0); + return readChallengeCookie(req.cookies[challengeCookieName()], getAuthSecret()); +}; + +/** + * Signs this browser out of the web before signing in for the app. Otherwise the handoff would + * hand over whoever is already signed in, and next-auth would link the Google account picked next + * to that user. The app's browser is ephemeral on iOS; on Android it shares Chrome's cookies, so + * this also signs Chrome out of splitpro.app. + */ +export const signOutBrowserSession = async (req: NextApiRequest, res: NextApiResponse) => { + const sessionTokens = SESSION_COOKIES.flatMap(({ name }) => req.cookies[name] ?? []); + + if (0 < sessionTokens.length) { + await db.session.deleteMany({ where: { sessionToken: { in: sessionTokens } } }); + } + + SESSION_COOKIES.forEach(({ name, secure }) => + appendSetCookie( + res, + `${name}=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`, + ), + ); +}; + +/** + * The user of the browser's web session, only if that session was created after `issuedAt`, i.e. + * by the sign-in /start began. Session rows have no creation time, but next-auth creates them with + * `expires = now + maxAge`, and only moves that forward when the session is used, which can't + * happen to a session created before /start since /start deletes it. + */ +export const getHandoffSessionUserId = async (req: NextApiRequest, issuedAt: number) => { + const sessionTokens = SESSION_COOKIES.flatMap(({ name }) => req.cookies[name] ?? []); + + if (0 === sessionTokens.length) { + return null; + } + + const sessions = await db.session.findMany({ + where: { sessionToken: { in: sessionTokens }, expires: { gt: new Date() } }, + select: { userId: true, expires: true }, + }); + const session = sessions.find( + ({ expires }) => expires.getTime() - WEB_SESSION_MAX_AGE_SECONDS * 1000 >= issuedAt, + ); + + return session?.userId ?? null; +}; + +/** + * Wraps a mobile auth API route: method and content type checks, no caching, and `{ error }` JSON + * for failures. + */ +export const mobileAuthRoute = + (method: 'GET' | 'POST', handler: (req: NextApiRequest, res: NextApiResponse) => Promise) => + async (req: NextApiRequest, res: NextApiResponse) => { + res.setHeader('Cache-Control', 'no-store'); + + if (method !== req.method) { + res.setHeader('Allow', method); + res.status(405).json({ error: 'Method not allowed' }); + return; + } + + // Keeps cross-site form posts, e.g. one triggering sign-in emails, out + if ('POST' === method && !isJsonContentType(req.headers['content-type'])) { + res.status(415).json({ error: 'Content-Type must be application/json' }); + return; + } + + try { + await handler(req, res); + } catch (error) { + if (!(error instanceof MobileAuthError)) { + console.error('Mobile auth error', error); + res.status(500).json({ error: 'Something went wrong' }); + return; + } + + if (error.retryAfterSeconds) { + res.setHeader('Retry-After', String(error.retryAfterSeconds)); + } + res.status(error.status).json({ error: error.message }); + } + }; + +export const parseInput = (schema: z.ZodType, input: unknown) => { + const result = schema.safeParse(input); + + if (result.success) { + return result.data; + } + + const issue = result.error.issues[0]; + throw new MobileAuthError( + 400, + issue ? `${issue.path.join('.') || 'body'}: ${issue.message}` : 'Invalid request', + ); +}; diff --git a/src/server/mobileAuthTokens.ts b/src/server/mobileAuthTokens.ts new file mode 100644 index 0000000..a97d281 --- /dev/null +++ b/src/server/mobileAuthTokens.ts @@ -0,0 +1,162 @@ +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto'; + +/** + * Pure helpers for native app sign-in. Kept free of db and env imports so they can be unit tested. + */ + +// The only redirect the app registers. Expo Go projects (exp://) are only allowed in +// development, since anyone can publish one and receive the handoff code. +export const MOBILE_REDIRECT_URI = 'splitpro://oauth-callback'; +const EXPO_GO_PROTOCOL = 'exp:'; + +// Every Expo Go project signs in with Apple under Expo Go's bundle id, so it's development only +const EXPO_GO_BUNDLE_ID = 'host.exp.Exponent'; + +export const HANDOFF_IDENTIFIER_PREFIX = 'mobile-handoff'; + +// generateOpaqueToken output +const HANDOFF_CODE_PATTERN = /^[0-9a-f]{64}$/; + +// base64url of a sha256 digest, no padding +const CHALLENGE_PATTERN = /^[A-Za-z0-9_-]{43}$/; + +// RFC 7636 code verifier +export const VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/; + +export const generateOpaqueToken = () => randomBytes(32).toString('hex'); + +/** + * Same hash next-auth v4 stores on VerificationToken, so either side can consume the other's codes. + */ +export const hashVerificationToken = (token: string, secret: string) => + createHash('sha256').update(`${token}${secret}`).digest('hex'); + +export const pkceChallenge = (verifier: string) => + createHash('sha256').update(verifier).digest('base64url'); + +export const isValidChallenge = (challenge: string) => CHALLENGE_PATTERN.test(challenge); + +export const verifierMatchesChallenge = (verifier: string, challenge: string) => + VERIFIER_PATTERN.test(verifier) && pkceChallenge(verifier) === challenge; + +export const isValidHandoffCode = (code: string) => HANDOFF_CODE_PATTERN.test(code); + +/** + * Returns the app redirect URI if it is allowed, otherwise null. + */ +export const parseMobileRedirect = (redirect: unknown, isDevelopment: boolean) => { + if ('string' !== typeof redirect || 2048 < redirect.length || /\s/.test(redirect)) { + return null; + } + + try { + const url = new URL(redirect); + + if (MOBILE_REDIRECT_URI === url.href) { + return url; + } + + return isDevelopment && EXPO_GO_PROTOCOL === url.protocol ? url : null; + } catch { + return null; + } +}; + +/** + * The bundle ids accepted as the audience of Apple identity tokens. + */ +export const parseAppleAudiences = (bundleIds: string, isDevelopment: boolean) => + bundleIds + .split(',') + .map((bundleId) => bundleId.trim()) + .filter((bundleId) => '' !== bundleId && (isDevelopment || EXPO_GO_BUNDLE_ID !== bundleId)); + +const challengeSignature = (payload: string, secret: string) => + createHmac('sha256', secret).update(`mobile-challenge:${payload}`).digest('base64url'); + +/** + * Cookie value tying a handoff to the /start that issued it, and when. + */ +export const signChallengeCookie = (challenge: string, issuedAt: number, secret: string) => { + const payload = `${challenge}.${issuedAt}`; + return `${payload}.${challengeSignature(payload, secret)}`; +}; + +export const readChallengeCookie = (value: string | undefined, secret: string) => { + const [challenge, issuedAt, signature, ...rest] = value?.split('.') ?? []; + + if (!challenge || !issuedAt || !signature || 0 < rest.length || !/^\d+$/.test(issuedAt)) { + return null; + } + + const expected = Buffer.from(challengeSignature(`${challenge}.${issuedAt}`, secret)); + const actual = Buffer.from(signature); + + if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) { + return null; + } + + return { challenge, issuedAt: Number(issuedAt) }; +}; + +export const appendQueryParams = (url: URL, params: Record) => { + const target = new URL(url); + Object.entries(params).forEach(([key, value]) => target.searchParams.set(key, value)); + return target.toString(); +}; + +export const handoffIdentifier = (challenge: string, userId: number) => + `${HANDOFF_IDENTIFIER_PREFIX}:${challenge}:${userId}`; + +/** + * The user id a handoff code was issued for, if it was issued for this challenge. + */ +export const handoffUserId = (identifier: string, challenge: string) => { + const prefix = `${HANDOFF_IDENTIFIER_PREFIX}:${challenge}:`; + + if (!identifier.startsWith(prefix)) { + return null; + } + + const userId = identifier.slice(prefix.length); + return /^\d+$/.test(userId) ? Number(userId) : null; +}; + +export const getBearerToken = (authorization: string | undefined) => { + const match = authorization?.match(/^Bearer\s+(\S+)\s*$/i); + return match?.[1] ?? null; +}; + +/** + * Whether the request authenticates with a bearer token, valid or not. + */ +export const hasBearerAuthorization = (authorization: string | undefined) => + /^Bearer(\s|$)/i.test(authorization ?? ''); + +/** + * Whether a POST body was sent as JSON. Cross-site HTML forms can't send this content type. + */ +export const isJsonContentType = (contentType: string | undefined) => + 'application/json' === contentType?.split(';')[0]?.trim().toLowerCase(); + +export type AppleFullName = + | string + | { + givenName?: string | null; + middleName?: string | null; + familyName?: string | null; + } + | null + | undefined; + +/** + * Apple only shares the name on the first sign-in, and only with the app, so the app forwards it. + */ +export const formatAppleFullName = (fullName: AppleFullName) => { + const parts = + 'string' === typeof fullName + ? [fullName] + : [fullName?.givenName, fullName?.middleName, fullName?.familyName]; + + return parts.join(' ').trim().replace(/\s+/g, ' ') || null; +}; diff --git a/src/tests/expoPush.integration.test.ts b/src/tests/expoPush.integration.test.ts new file mode 100644 index 0000000..f7b619c --- /dev/null +++ b/src/tests/expoPush.integration.test.ts @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { type ExpoPushMessage, type ExpoPushTicket } from 'expo-server-sdk'; + +const databaseUrl = process.env.TEST_DATABASE_URL; + +/** Waits for notifications sent after a save to go out. */ +const until = async (check: () => boolean | Promise) => { + const deadline = Date.now() + 5_000; + while (!(await check())) { + assert.ok(Date.now() < deadline, 'Timed out waiting for the notification'); + await new Promise((resolve) => setTimeout(resolve, 25)); + } +}; + +const setup = async () => { + assert.match(new URL(databaseUrl!).pathname, /test/i, 'Refusing to use a non-test database'); + process.env.DATABASE_URL = databaseUrl; + process.env.SKIP_ENV_VALIDATION = '1'; + + const [{ SplitType }, { TRPCError }, { db }, { appRouter }, { expo }, { toFixedNumber }] = + await Promise.all([ + import('@prisma/client'), + import('@trpc/server'), + import('~/server/db'), + import('~/server/api/root'), + import('~/server/expoPush'), + import('~/utils/numbers'), + ]); + + return { SplitType, TRPCError, db, appRouter, expo, toFixedNumber }; +}; + +void test( + 'native push: tokens are registered per user and get the web notifications', + { skip: !databaseUrl }, + async () => { + const { SplitType, TRPCError, db, appRouter, expo, toFixedNumber } = await setup(); + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const tokenA = `ExponentPushToken[alice-${suffix}]`; + const tokenB = `ExponentPushToken[bob-${suffix}]`; + const tokenC = `ExponentPushToken[spare-${suffix}]`; + + // Records what would go to Expo instead of sending it. + const sent: ExpoPushMessage[] = []; + let reply = (messages: ExpoPushMessage[]): ExpoPushTicket[] => + messages.map((_, index) => ({ status: 'ok', id: `receipt-${index}` })); + expo.sendPushNotificationsAsync = async (messages) => { + sent.push(...messages); + return reply(messages); + }; + + const [alice, bob] = await Promise.all( + ['Alice', 'Bob'].map((name) => + db.user.create({ data: { email: `${name.toLowerCase()}-${suffix}@example.com`, name } }), + ), + ); + assert.ok(alice && bob); + const group = await db.group.create({ + data: { name: 'Trip', publicId: `trip-${suffix}`, userId: alice.id }, + }); + await db.groupUser.createMany({ + data: [alice, bob].map((user) => ({ groupId: group.id, userId: user.id })), + }); + + const callerFor = (user: typeof alice) => + appRouter.createCaller({ + db, + ip: 'test', + session: { + expires: new Date(Date.now() + 60_000).toISOString(), + user: { id: user.id, name: user.name, email: user.email, currency: 'USD' }, + }, + }); + const ownerOf = async (token: string) => + (await db.devicePushToken.findUnique({ where: { token } }))?.userId; + const isBadRequest = (error: unknown) => { + assert.ok(error instanceof TRPCError); + assert.equal(error.code, 'BAD_REQUEST'); + return true; + }; + + try { + await assert.rejects( + callerFor(alice).user.registerPushToken({ token: 'not-a-token', platform: 'ios' }), + isBadRequest, + ); + + // A token moves to whoever registers it last, and only its owner can remove it. + await callerFor(alice).user.registerPushToken({ + token: tokenA, + platform: 'ios', + deviceName: "Alice's iPhone", + }); + await callerFor(bob).user.registerPushToken({ token: tokenA, platform: 'android' }); + assert.equal(await ownerOf(tokenA), bob.id); + await callerFor(alice).user.registerPushToken({ + token: tokenA, + platform: 'ios', + deviceName: "Alice's iPhone", + }); + await callerFor(bob).user.unregisterPushToken({ token: tokenA }); + assert.deepEqual( + await db.devicePushToken.findUnique({ + where: { token: tokenA }, + select: { userId: true, platform: true, deviceName: true }, + }), + { userId: alice.id, platform: 'ios', deviceName: "Alice's iPhone" }, + ); + await callerFor(bob).user.registerPushToken({ token: tokenB, platform: 'android' }); + + // Bob's expense goes to Alice only, with the web notification's text and a link. + const expense = await callerFor(bob).group.addOrEditExpense({ + groupId: group.id, + name: 'Lunch', + category: 'general', + amount: 10, + splitType: SplitType.EXACT, + currency: 'USD', + paidBy: bob.id, + participants: [ + { userId: bob.id, amount: 10 }, + { userId: alice.id, amount: -10 }, + ], + }); + assert.ok(expense); + await until(() => 0 < sent.length); + const amount = toFixedNumber( + (await db.expense.findUniqueOrThrow({ where: { id: expense.id } })).amount, + ); + assert.deepEqual(sent.splice(0), [ + { + to: tokenA, + title: 'Bob', + body: `Bob paid USD ${amount} for Lunch`, + data: { url: `/expenses/${expense.id}` }, + sound: 'default', + }, + ]); + + // Expo failing doesn't fail the save. + reply = () => { + throw new Error('Expo is down'); + }; + await callerFor(bob).group.addOrEditExpense({ + groupId: group.id, + expenseId: expense.id, + name: 'Lunch', + category: 'general', + amount: 10, + splitType: SplitType.EXACT, + currency: 'USD', + paidBy: bob.id, + participants: [ + { userId: bob.id, amount: 10 }, + { userId: alice.id, amount: -10 }, + ], + }); + await until(() => 0 < sent.length); + assert.deepEqual( + sent.splice(0).map((message) => [message.to, message.body]), + [[tokenA, `Updated Lunch USD ${amount}`]], + ); + assert.equal(await ownerOf(tokenA), alice.id); + + // A device that's gone is forgotten. + reply = (messages) => + messages.map(() => ({ + status: 'error', + message: 'not a registered push notification recipient', + details: { error: 'DeviceNotRegistered' }, + })); + await callerFor(bob).user.deleteExpense({ expenseId: expense.id }); + await until(async () => undefined === (await ownerOf(tokenA))); + assert.deepEqual(sent.splice(0), [ + { + to: tokenA, + title: 'Bob', + body: 'Deleted Lunch', + data: { url: `/groups/${group.id}` }, + sound: 'default', + }, + ]); + assert.equal(await ownerOf(tokenA), undefined); + assert.equal(await ownerOf(tokenB), bob.id); + + // Signing out of the app removes its token, deleting the account removes the rest. + await callerFor(alice).user.registerPushToken({ token: tokenC, platform: 'android' }); + await callerFor(alice).user.unregisterPushToken({ token: tokenC }); + assert.equal(await ownerOf(tokenC), undefined); + const carol = await db.user.create({ data: { email: `carol-${suffix}@example.com` } }); + await callerFor(carol).user.registerPushToken({ token: tokenC, platform: 'ios' }); + await db.user.delete({ where: { id: carol.id } }); + assert.equal(await ownerOf(tokenC), undefined); + } finally { + await db.group.deleteMany({ where: { id: group.id } }); + await db.user.deleteMany({ where: { id: { in: [alice.id, bob.id] } } }); + await db.$disconnect(); + } + }, +); + +void test( + 'native push: a late DeviceNotRegistered receipt keeps a token registered again since', + { skip: !databaseUrl }, + async () => { + const { db, expo } = await setup(); + const { checkReceipts } = await import('~/server/expoPush'); + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const [goneToken, backToken] = [ + `ExponentPushToken[gone-${suffix}]`, + `ExponentPushToken[back-${suffix}]`, + ]; + const user = await db.user.create({ data: { email: `receipts-${suffix}@example.com` } }); + + expo.getPushNotificationReceiptsAsync = async (ids) => + Object.fromEntries( + ids.map((id) => [ + id, + { + status: 'error', + message: 'not a registered push notification recipient', + details: { error: 'DeviceNotRegistered' }, + }, + ]), + ); + + try { + await db.devicePushToken.createMany({ + data: [goneToken, backToken].map((token) => ({ userId: user.id, token, platform: 'ios' })), + }); + const sentAt = new Date(); + await new Promise((resolve) => setTimeout(resolve, 10)); + // The app registered this token again after the notification went out + await db.devicePushToken.update({ + where: { token: backToken }, + data: { deviceName: 'Reinstalled' }, + }); + + await checkReceipts( + [ + { id: 'receipt-gone', token: goneToken }, + { id: 'receipt-back', token: backToken }, + ], + sentAt, + ); + + assert.equal(await db.devicePushToken.count({ where: { token: goneToken } }), 0); + assert.equal(await db.devicePushToken.count({ where: { token: backToken } }), 1); + } finally { + await db.user.deleteMany({ where: { id: user.id } }); + await db.$disconnect(); + } + }, +); + +void test( + 'native push: a token goes with the app session that registered it', + { skip: !databaseUrl }, + async () => { + const { db, appRouter } = await setup(); + const [{ getDevicePushTokens }, { createTRPCContext }] = await Promise.all([ + import('~/server/expoPush'), + import('~/server/api/trpc'), + ]); + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const revokedToken = `ExponentPushToken[revoked-${suffix}]`; + const expiredToken = `ExponentPushToken[expired-${suffix}]`; + const webToken = `ExponentPushToken[web-${suffix}]`; + const user = await db.user.create({ data: { email: `sessions-${suffix}@example.com` } }); + const createSession = () => + db.session.create({ + data: { + sessionToken: `session-${suffix}-${Math.random()}`, + userId: user.id, + expires: new Date(Date.now() + 60_000), + }, + }); + const contextFor = (sessionToken: string) => + createTRPCContext({ + req: { + headers: { authorization: `Bearer ${sessionToken}`, 'cf-connecting-ip': 'test' }, + cookies: {}, + }, + res: { getHeader: () => undefined, setHeader: () => undefined }, + } as never); + const register = async (sessionToken: string, token: string) => + appRouter + .createCaller(await contextFor(sessionToken)) + .user.registerPushToken({ token, platform: 'ios' }); + const sessionOf = async (token: string) => + (await db.devicePushToken.findUnique({ where: { token }, select: { sessionId: true } })) + ?.sessionId; + + try { + // Revoking the session, e.g. from another device, removes the token it registered + const revoked = await createSession(); + await register(revoked.sessionToken, revokedToken); + assert.equal(await sessionOf(revokedToken), revoked.id); + await db.session.delete({ where: { id: revoked.id } }); + assert.equal(await sessionOf(revokedToken), undefined); + + // An expired session's token is skipped and forgotten, a token without a session is kept + const expired = await createSession(); + await register(expired.sessionToken, expiredToken); + await db.devicePushToken.create({ + data: { userId: user.id, token: webToken, platform: 'android' }, + }); + await db.session.update({ + where: { id: expired.id }, + data: { expires: new Date(Date.now() - 1000) }, + }); + assert.deepEqual(await getDevicePushTokens([user.id]), [webToken]); + assert.equal(await sessionOf(expiredToken), undefined); + assert.equal(await sessionOf(webToken), null); + + // The app using an expired session deletes it, and the token with it + const lapsed = await createSession(); + await register(lapsed.sessionToken, expiredToken); + await db.session.update({ + where: { id: lapsed.id }, + data: { expires: new Date(Date.now() - 1000) }, + }); + assert.equal((await contextFor(lapsed.sessionToken)).session, null); + assert.equal(await sessionOf(expiredToken), undefined); + } finally { + await db.user.deleteMany({ where: { id: user.id } }); + await db.$disconnect(); + } + }, +); diff --git a/src/tests/expoPush.test.ts b/src/tests/expoPush.test.ts new file mode 100644 index 0000000..13bd62b --- /dev/null +++ b/src/tests/expoPush.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; + +import { + buildExpoPushMessages, + getExpensePushUrl, + isValidExpoPushToken, + readPushReceipts, + readPushTickets, +} from '~/utils/expoPush'; + +const tokenA = 'ExponentPushToken[aaaaaaaaaaaaaaaaaaaaaa]'; +const tokenB = 'ExponentPushToken[bbbbbbbbbbbbbbbbbbbbbb]'; +const tokenC = 'ExpoPushToken[cccccccccccccccccccccc]'; + +const unregistered = (expoPushToken?: string) => + ({ + status: 'error', + message: 'not a registered push notification recipient', + details: { error: 'DeviceNotRegistered', expoPushToken }, + }) as const; + +void describe('isValidExpoPushToken', () => { + void it('should accept Expo push tokens', () => { + assert.ok(isValidExpoPushToken(tokenA)); + assert.ok(isValidExpoPushToken(tokenC)); + }); + + void it('should reject anything else', () => { + ['', 'ExponentPushToken[', 'abc', 'fcm:token', JSON.stringify({ endpoint: 'x' })].forEach( + (token) => assert.equal(isValidExpoPushToken(token), false, token), + ); + }); +}); + +void describe('getExpensePushUrl', () => { + void it('should open the expense', () => { + assert.equal(getExpensePushUrl({ id: 'e1', groupId: 5, deletedBy: null }), '/expenses/e1'); + assert.equal(getExpensePushUrl({ id: 'e1', groupId: null, deletedBy: null }), '/expenses/e1'); + }); + + void it('should open where a deleted expense was', () => { + assert.equal(getExpensePushUrl({ id: 'e1', groupId: 5, deletedBy: 2 }), '/groups/5'); + assert.equal(getExpensePushUrl({ id: 'e1', groupId: null, deletedBy: 2 }), '/activity'); + }); +}); + +void describe('buildExpoPushMessages', () => { + void it('should send the web notification text to each token with its link', () => { + const messages = buildExpoPushMessages([tokenA, tokenB], { + title: 'Alice', + message: 'Alice paid USD 10 for Lunch', + url: '/expenses/e1', + }); + + assert.deepEqual(messages, [ + { + to: tokenA, + title: 'Alice', + body: 'Alice paid USD 10 for Lunch', + data: { url: '/expenses/e1' }, + sound: 'default', + }, + { + to: tokenB, + title: 'Alice', + body: 'Alice paid USD 10 for Lunch', + data: { url: '/expenses/e1' }, + sound: 'default', + }, + ]); + }); +}); + +void describe('readPushTickets', () => { + void it('should pick unregistered tokens and keep receipts for the rest', () => { + const result = readPushTickets( + [tokenA, tokenB, tokenC], + [ + { status: 'ok', id: 'r1' }, + unregistered(), + { status: 'error', message: 'Too fast', details: { error: 'MessageRateExceeded' } }, + ], + ); + + assert.deepEqual(result, { unregistered: [tokenB], receipts: [{ id: 'r1', token: tokenA }] }); + }); + + void it('should prefer the token Expo names in the error', () => { + assert.deepEqual(readPushTickets([tokenA], [unregistered(tokenC)]).unregistered, [tokenC]); + }); +}); + +void describe('readPushReceipts', () => { + void it('should pick tokens whose receipt says the device is unregistered', () => { + const pending = [ + { id: 'r1', token: tokenA }, + { id: 'r2', token: tokenB }, + { id: 'r3', token: tokenC }, + ]; + + assert.deepEqual( + readPushReceipts(pending, { + r1: { status: 'ok' }, + r2: unregistered(), + // r3 isn't ready yet + }), + [tokenB], + ); + }); +}); diff --git a/src/tests/mobileAuth.integration.test.ts b/src/tests/mobileAuth.integration.test.ts new file mode 100644 index 0000000..06aae9a --- /dev/null +++ b/src/tests/mobileAuth.integration.test.ts @@ -0,0 +1,518 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { type NextApiRequest, type NextApiResponse } from 'next'; + +const databaseUrl = process.env.TEST_DATABASE_URL; + +const DAY_MS = 24 * 60 * 60_000; +const WEB_SESSION_MAX_AGE_MS = 30 * DAY_MS; + +const setup = async () => { + assert.match(new URL(databaseUrl!).pathname, /test/i, 'Refusing to clean a non-test database'); + process.env.DATABASE_URL = databaseUrl; + process.env.SKIP_ENV_VALIDATION = '1'; + process.env.NEXTAUTH_SECRET ??= 'mobile-auth-test-secret'; + process.env.NEXTAUTH_URL ??= 'http://localhost:3000'; + + const [{ db }, { env }, { authAdapter }, mobileAuth, tokens] = await Promise.all([ + import('~/server/db'), + import('~/env'), + import('~/server/auth'), + import('~/server/mobileAuth'), + import('~/server/mobileAuthTokens'), + ]); + + // Read when used, so tests can switch settings + const mutableEnv = env as { -readonly [Key in keyof typeof env]: (typeof env)[Key] }; + + return { + db, + env: mutableEnv, + authAdapter, + ...mobileAuth, + ...tokens, + secret: process.env.NEXTAUTH_SECRET, + }; +}; + +type Handler = (req: NextApiRequest, res: NextApiResponse) => Promise; + +/** Just enough of Next's req/res to call an API route directly. */ +const callRoute = async ( + handler: Handler, + { + method = 'GET', + headers = {}, + cookies = {}, + query = {}, + body, + }: { + method?: string; + headers?: Record; + cookies?: Record; + query?: Record; + body?: unknown; + }, +) => { + const responseHeaders: Record = {}; + const response = { + status: 200, + body: undefined as unknown, + location: '', + setCookies: [] as string[], + }; + const res = { + getHeader: (name: string) => responseHeaders[name.toLowerCase()], + setHeader: (name: string, value: unknown) => { + responseHeaders[name.toLowerCase()] = value; + return res; + }, + status: (status: number) => { + response.status = status; + return res; + }, + json: (data: unknown) => { + response.body = data; + return res; + }, + send: (data: unknown) => { + response.body = data; + return res; + }, + redirect: (status: number, location: string) => { + response.status = status; + response.location = location; + return res; + }, + }; + const req = { + method, + headers: { 'cf-connecting-ip': 'test-route', ...headers }, + cookies, + query, + body, + }; + + await handler(req as unknown as NextApiRequest, res as unknown as NextApiResponse); + const setCookie = responseHeaders['set-cookie']; + response.setCookies = Array.isArray(setCookie) + ? setCookie.map(String) + : setCookie + ? [String(setCookie)] + : []; + return response; +}; + +/** The cookies a response sets, by name, as a browser would keep them. */ +const cookieJar = (setCookies: string[]) => + Object.fromEntries( + setCookies.map((cookie) => { + const [pair = ''] = cookie.split(';'); + const index = pair.indexOf('='); + return [pair.slice(0, index), pair.slice(index + 1)]; + }), + ); + +const withStatus = (status: number) => (error: unknown) => { + assert.equal((error as { status?: number }).status, status, String(error)); + return true; +}; + +void test( + 'mobile auth: email code attempts, bearer sessions and the browser handoff', + { skip: !databaseUrl }, + async () => { + const { + db, + secret, + verifyEmailCode, + getMobileSession, + deleteMobileSession, + createHandoffCode, + exchangeHandoffCode, + hashVerificationToken, + pkceChallenge, + generateOpaqueToken, + } = await setup(); + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const email = `mobile-${suffix}@example.com`; + const ip = `test-${suffix}`; + + const issueCode = (code: string) => + db.verificationToken.create({ + data: { + identifier: email, + token: hashVerificationToken(code, secret), + expires: new Date(Date.now() + 10 * 60_000), + }, + }); + const resetLimits = () => + db.rateLimit.deleteMany({ where: { key: { in: [`email-verify:email:${email}`] } } }); + + try { + // Five wrong guesses are rejected, the next attempt burns the code even if it is right + await issueCode('ABC234'); + for (let attempt = 0; attempt < 5; attempt++) { + await assert.rejects(verifyEmailCode(email, 'ZZZZZZ', ip), withStatus(400)); + } + await assert.rejects(verifyEmailCode(email, 'ABC234', ip), withStatus(429)); + assert.equal(await db.verificationToken.count({ where: { identifier: email } }), 0); + + // A fresh code signs up a new user, tolerating case and spaces like the web form + await resetLimits(); + await issueCode('ABC234'); + const signIn = await verifyEmailCode(email.toUpperCase(), ' abc234 ', ip); + assert.equal(signIn.user.email, email); + assert.equal(signIn.user.name, `mobile-${suffix}`); + assert.equal(signIn.user.currency, 'USD'); + await assert.rejects(verifyEmailCode(email, 'ABC234', ip), withStatus(400)); + + const session = await getMobileSession(signIn.token); + assert.equal(session?.user.id, signIn.user.id); + assert.ok(new Date(session.expires).getTime() > Date.now() + 179 * 24 * 60 * 60_000); + + await deleteMobileSession(signIn.token); + assert.equal(await getMobileSession(signIn.token), null); + + // Handoff codes need the matching verifier and work once + const verifier = generateOpaqueToken(); + const challenge = pkceChallenge(verifier); + + const wrongVerifierCode = await createHandoffCode(challenge, signIn.user.id); + await assert.rejects( + exchangeHandoffCode(wrongVerifierCode, generateOpaqueToken(), ip), + withStatus(400), + ); + await assert.rejects(exchangeHandoffCode(wrongVerifierCode, verifier, ip), withStatus(400)); + + const code = await createHandoffCode(challenge, signIn.user.id); + const exchanged = await exchangeHandoffCode(code, verifier, ip); + assert.equal(exchanged.user.id, signIn.user.id); + assert.ok((await getMobileSession(exchanged.token))?.user); + await assert.rejects(exchangeHandoffCode(code, verifier, ip), withStatus(400)); + } finally { + await db.verificationToken.deleteMany({ + where: { OR: [{ identifier: email }, { identifier: { startsWith: 'mobile-handoff:' } }] }, + }); + await db.rateLimit.deleteMany({ where: { key: { contains: suffix } } }); + await db.user.deleteMany({ where: { email } }); + await db.$disconnect(); + } + }, +); + +void test( + 'mobile auth: the Google handoff only hands over a session the sign-in from /start created', + { skip: !databaseUrl }, + async () => { + const { db, env, generateOpaqueToken, pkceChallenge } = await setup(); + const [{ default: start }, { default: handoff }, { default: exchange }] = await Promise.all([ + import('~/pages/api/mobile/auth/start'), + import('~/pages/api/mobile/auth/handoff'), + import('~/pages/api/mobile/auth/exchange'), + ]); + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const originalEnv = { ...env }; + Object.assign(env, { + NODE_ENV: 'production', + GOOGLE_CLIENT_ID: 'test', + GOOGLE_CLIENT_SECRET: 'test', + }); + + const victim = await db.user.create({ data: { email: `victim-${suffix}@example.com` } }); + const createWebSession = (createdAt: number) => + db.session.create({ + data: { + sessionToken: generateOpaqueToken(), + userId: victim.id, + expires: new Date(createdAt + WEB_SESSION_MAX_AGE_MS), + }, + }); + + try { + const verifier = generateOpaqueToken(); + const challenge = pkceChallenge(verifier); + const redirect = 'splitpro://oauth-callback'; + + // Expo Go redirects, i.e. any Expo Go project, are refused outside development + const expoGo = await callRoute(start, { + query: { provider: 'google', challenge, redirect: 'exp://evil.example.com/--/x' }, + }); + assert.equal(expoGo.status, 400); + + // /start signs the browser out of the web and asks Google which account to use + const existing = await createWebSession(Date.now() - DAY_MS); + const started = await callRoute(start, { + query: { provider: 'google', challenge, redirect }, + cookies: { 'next-auth.session-token': existing.sessionToken }, + }); + assert.equal(started.status, 200); + assert.match( + String(started.body), + /action="\/api\/auth\/signin\/google\?prompt=select_account"/, + ); + assert.equal(await db.session.count({ where: { id: existing.id } }), 0); + const jar = cookieJar(started.setCookies); + assert.equal(jar['next-auth.session-token'], ''); + assert.equal(jar['__Secure-next-auth.session-token'], ''); + assert.ok( + started.setCookies.some((cookie) => + /^__Secure-next-auth\.session-token=;.*Max-Age=0.*Secure/.test(cookie), + ), + ); + const challengeCookie = jar['splitpro.mobile-challenge'] ?? ''; + assert.ok(challengeCookie.startsWith(`${challenge}.`)); + + // A session from before /start never gets a code, even if it somehow survived + const stale = await createWebSession(Date.now() - 60_000); + const staleHandoff = await callRoute(handoff, { + query: { challenge, redirect }, + cookies: { + 'splitpro.mobile-challenge': challengeCookie, + 'next-auth.session-token': stale.sessionToken, + }, + }); + assert.equal(staleHandoff.location, `${redirect}?error=unauthenticated`); + + // Nor does a session without the signed cookie from /start + const fresh = await createWebSession(Date.now() + 1); + const forged = await callRoute(handoff, { + query: { challenge, redirect }, + cookies: { + 'splitpro.mobile-challenge': `${challenge}.0.forged`, + 'next-auth.session-token': fresh.sessionToken, + }, + }); + assert.equal(forged.location, `${redirect}?error=invalid_state`); + + // The session the Google sign-in creates after /start is handed over, once + const handedOff = await callRoute(handoff, { + query: { challenge, redirect }, + cookies: { + 'splitpro.mobile-challenge': challengeCookie, + 'next-auth.session-token': fresh.sessionToken, + }, + }); + const code = new URL(handedOff.location).searchParams.get('code'); + assert.ok(handedOff.location.startsWith(`${redirect}?code=`) && code); + assert.equal(cookieJar(handedOff.setCookies)['splitpro.mobile-challenge'], ''); + + const exchanged = await callRoute(exchange, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: { code, verifier }, + }); + assert.equal(exchanged.status, 200); + assert.equal((exchanged.body as { user: { id: number } }).user.id, victim.id); + + // Over https the challenge cookie is __Secure- prefixed + env.NEXTAUTH_URL = 'https://splitpro.example'; + const secure = await callRoute(start, { query: { provider: 'google', challenge, redirect } }); + const secureCookie = secure.setCookies.find((cookie) => + cookie.startsWith('__Secure-splitpro.mobile-challenge='), + ); + assert.ok(secureCookie?.includes('; Secure')); + assert.ok( + cookieJar(secure.setCookies)['__Secure-splitpro.mobile-challenge']?.startsWith(challenge), + ); + } finally { + Object.assign(env, originalEnv); + await db.verificationToken.deleteMany({ + where: { identifier: { startsWith: 'mobile-handoff:' } }, + }); + await db.rateLimit.deleteMany({ where: { key: { contains: 'test-route' } } }); + await db.user.deleteMany({ where: { id: victim.id } }); + await db.$disconnect(); + } + }, +); + +void test( + 'mobile auth: scoped handoff codes, sign up races, Apple audience, sign out and content types', + { skip: !databaseUrl }, + async () => { + const { + db, + env, + authAdapter, + secret, + verifyEmailCode, + exchangeHandoffCode, + signInWithApple, + getMobileSession, + hashVerificationToken, + generateOpaqueToken, + mobileAuthRoute, + } = await setup(); + const [{ default: signout }, { default: sendEmail }, { createTRPCContext }] = await Promise.all( + [ + import('~/pages/api/mobile/auth/signout'), + import('~/pages/api/mobile/auth/email'), + import('~/server/api/trpc'), + ], + ); + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const ip = `test-${suffix}`; + const email = `race-${suffix}@example.com`; + const originalEnv = { ...env }; + const originalGetUserByEmail = authAdapter.getUserByEmail; + + const [alice, bob] = await Promise.all( + ['alice', 'bob'].map((name) => + db.user.create({ data: { email: `${name}-${suffix}@example.com`, name } }), + ), + ); + assert.ok(alice && bob); + const createSession = (userId: number, expiresInMs = 30 * DAY_MS) => + db.session.create({ + data: { + sessionToken: generateOpaqueToken(), + userId, + expires: new Date(Date.now() + expiresInMs), + }, + }); + const issueEmailCode = (identifier: string, code: string) => + db.verificationToken.create({ + data: { + identifier, + token: hashVerificationToken(code, secret), + expires: new Date(Date.now() + 10 * 60_000), + }, + }); + + try { + // A handoff exchange only looks at handoff codes, so it can't burn an email code + const emailCode = generateOpaqueToken(); + await issueEmailCode(alice.email!, emailCode); + await assert.rejects( + exchangeHandoffCode(emailCode, generateOpaqueToken(), ip), + withStatus(400), + ); + assert.equal(await db.verificationToken.count({ where: { identifier: alice.email! } }), 1); + await assert.rejects( + exchangeHandoffCode('ABC234', generateOpaqueToken(), ip), + withStatus(400), + ); + + // A user created concurrently by another sign-in is reused instead of failing + const existing = await db.user.create({ data: { email, name: 'Existing' } }); + authAdapter.getUserByEmail = async () => null; + await issueEmailCode(email, 'ABC234'); + const raced = await verifyEmailCode(email, 'ABC234', ip); + assert.equal(raced.user.id, existing.id); + assert.equal(raced.user.name, 'Existing'); + authAdapter.getUserByEmail = originalGetUserByEmail; + + // Invite only instances refuse sign ups with a clean 403 + env.INVITE_ONLY = true; + await issueEmailCode(`new-${email}`, 'ABC234'); + await assert.rejects(verifyEmailCode(`new-${email}`, 'ABC234', ip), (error: unknown) => { + assert.equal((error as { status?: number }).status, 403); + assert.equal((error as Error).message, 'This instance is Invite Only'); + return true; + }); + env.INVITE_ONLY = false; + + // Any Expo Go project gets tokens for Expo Go's bundle id, so it only counts in development + env.APPLE_BUNDLE_ID = 'host.exp.Exponent'; + env.NODE_ENV = 'production'; + await assert.rejects(signInWithApple('a.b.c', null, ip), withStatus(503)); + env.NODE_ENV = 'development'; + await assert.rejects(signInWithApple('a.b.c', null, ip), withStatus(401)); + env.NODE_ENV = 'production'; + + // Signing out deletes the device's push token, if it's the signed in user's + const aliceSession = await createSession(alice.id); + const [aliceToken, bobToken] = [ + `ExponentPushToken[a-${suffix}]`, + `ExponentPushToken[b-${suffix}]`, + ]; + await db.devicePushToken.createMany({ + data: [ + { userId: alice.id, token: aliceToken, platform: 'ios' }, + { userId: bob.id, token: bobToken, platform: 'android' }, + ], + }); + const signOut = (sessionToken: string, body: unknown) => + callRoute(signout, { + method: 'POST', + headers: { authorization: `Bearer ${sessionToken}`, 'content-type': 'application/json' }, + body, + }); + + assert.equal((await signOut(aliceSession.sessionToken, { pushToken: bobToken })).status, 200); + assert.equal(await db.devicePushToken.count({ where: { token: bobToken } }), 1); + assert.equal(await getMobileSession(aliceSession.sessionToken), null); + + const aliceSession2 = await createSession(alice.id); + assert.equal( + (await signOut(aliceSession2.sessionToken, { pushToken: aliceToken })).status, + 200, + ); + assert.equal(await db.devicePushToken.count({ where: { token: aliceToken } }), 0); + assert.equal(await db.session.count({ where: { id: aliceSession2.id } }), 0); + + const aliceSession3 = await createSession(alice.id); + assert.equal((await signOut(aliceSession3.sessionToken, {})).status, 200); + assert.equal(await db.session.count({ where: { id: aliceSession3.id } }), 0); + + // Posts must be JSON, so a cross-site form can't send sign-in emails + const formPost = await callRoute(sendEmail, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: { email: alice.email }, + }); + assert.equal(formPost.status, 415); + assert.equal( + await db.rateLimit.count({ where: { key: { contains: alice.email! } } }), + 0, + 'Rejected before anything is sent', + ); + + // Unexpected errors don't leak their message + const failing = await callRoute( + mobileAuthRoute('GET', async () => { + throw new Error('Unique constraint failed on the fields: (`email`)'); + }), + {}, + ); + assert.deepEqual([failing.status, failing.body], [500, { error: 'Something went wrong' }]); + + // A bearer token is used exclusively, an invalid one doesn't fall back to the cookie + const webSession = await createSession(alice.id); + const appSession = await createSession(bob.id); + const contextFor = async (headers: Record) => { + const res = { getHeader: () => undefined, setHeader: () => undefined }; + const context = await createTRPCContext({ + req: { + headers: { 'cf-connecting-ip': ip, ...headers }, + cookies: { 'next-auth.session-token': webSession.sessionToken }, + }, + res, + } as never); + return context.session?.user.id ?? null; + }; + assert.equal(await contextFor({}), alice.id); + assert.equal( + await contextFor({ authorization: `Bearer ${appSession.sessionToken}` }), + bob.id, + ); + assert.equal(await contextFor({ authorization: 'Bearer not-a-session' }), null); + assert.equal(await contextFor({ authorization: 'Bearer' }), null); + assert.equal(await contextFor({ authorization: 'Basic dXNlcjpwYXNz' }), alice.id); + } finally { + Object.assign(env, originalEnv); + authAdapter.getUserByEmail = originalGetUserByEmail; + await db.verificationToken.deleteMany({ + where: { + identifier: { in: [alice.email!, email, `new-${email}`] }, + }, + }); + await db.rateLimit.deleteMany({ where: { key: { contains: suffix } } }); + await db.user.deleteMany({ + where: { OR: [{ id: { in: [alice.id, bob.id] } }, { email }] }, + }); + await db.$disconnect(); + } + }, +); diff --git a/src/tests/mobileAuth.test.ts b/src/tests/mobileAuth.test.ts new file mode 100644 index 0000000..575394c --- /dev/null +++ b/src/tests/mobileAuth.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { describe, it } from 'node:test'; + +import { + appendQueryParams, + formatAppleFullName, + generateOpaqueToken, + getBearerToken, + handoffIdentifier, + handoffUserId, + hasBearerAuthorization, + hashVerificationToken, + isJsonContentType, + isValidChallenge, + isValidHandoffCode, + parseAppleAudiences, + parseMobileRedirect, + pkceChallenge, + readChallengeCookie, + signChallengeCookie, + verifierMatchesChallenge, +} from '~/server/mobileAuthTokens'; + +const require = createRequire(import.meta.url); +// next-auth doesn't export its internals, so load the module that hashes email tokens by path +const { hashToken } = require( + path.join(path.dirname(require.resolve('next-auth')), 'core/lib/utils.js'), +) as { hashToken: (token: string, options: { provider: object; secret: string }) => string }; + +// RFC 7636, appendix B +const RFC_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'; +const RFC_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'; + +const EXPO_GO_REDIRECTS = [ + 'exp://192.168.1.20:8081/--/oauth-callback', + 'exp://u.expo.dev/abc/--/oauth-callback', + 'exp://evil.example.com/--/oauth-callback', +]; + +const REJECTED_REDIRECTS = [ + 'https://evil.example', + 'https://evil.example/splitpro://oauth-callback', + 'http://localhost:3000', + 'javascript:alert(1)', + 'data:text/html,hi', + '//evil.example', + 'splitpro.evil.example', + 'splitprox://oauth-callback', + 'exps://u.expo.dev', + ' splitpro://oauth-callback', + 'splitpro://oauth-callback\n', + '', + undefined, + ['splitpro://oauth-callback'], +]; + +void describe('parseMobileRedirect', () => { + void describe('production', () => { + void it('should accept only the app redirect', () => { + assert.equal( + parseMobileRedirect('splitpro://oauth-callback', false)?.href, + 'splitpro://oauth-callback', + ); + }); + + void it('should reject other app URIs, extra params and Expo Go', () => { + [ + 'splitpro://', + 'splitpro://auth', + 'splitpro://oauth-callback/', + 'splitpro:///oauth-callback', + 'splitpro://oauth-callback?code=planted', + 'splitpro://oauth-callback#x', + 'splitpro://oauth-callback.evil', + 'splitpro://user@oauth-callback', + ...EXPO_GO_REDIRECTS, + ...REJECTED_REDIRECTS, + ].forEach((redirect) => { + assert.equal(parseMobileRedirect(redirect, false), null, String(redirect)); + }); + }); + }); + + void describe('development', () => { + void it('should also accept Expo Go on any host', () => { + ['splitpro://oauth-callback', ...EXPO_GO_REDIRECTS].forEach((redirect) => { + assert.ok(parseMobileRedirect(redirect, true), redirect); + }); + }); + + void it('should reject everything else', () => { + ['splitpro://auth', ...REJECTED_REDIRECTS].forEach((redirect) => { + assert.equal(parseMobileRedirect(redirect, true), null, String(redirect)); + }); + }); + }); + + void it('should append the code to the redirect', () => { + const redirect = parseMobileRedirect('splitpro://oauth-callback', false)!; + assert.equal( + appendQueryParams(redirect, { code: 'abc' }), + 'splitpro://oauth-callback?code=abc', + ); + }); +}); + +void describe('parseAppleAudiences', () => { + void it("should ignore Expo Go's bundle id outside development", () => { + assert.deepEqual(parseAppleAudiences('app.splitpro, host.exp.Exponent', false), [ + 'app.splitpro', + ]); + assert.deepEqual(parseAppleAudiences('host.exp.Exponent', false), []); + }); + + void it('should accept it in development', () => { + assert.deepEqual(parseAppleAudiences('app.splitpro, host.exp.Exponent', true), [ + 'app.splitpro', + 'host.exp.Exponent', + ]); + }); + + void it('should skip empty entries', () => { + assert.deepEqual(parseAppleAudiences('', false), []); + assert.deepEqual(parseAppleAudiences(' app.splitpro ,,', false), ['app.splitpro']); + }); +}); + +void describe('challenge cookie', () => { + const secret = 'test-secret'; + + void it('should round trip the challenge and when it was issued', () => { + const value = signChallengeCookie(RFC_CHALLENGE, 1_700_000_000_000, secret); + assert.deepEqual(readChallengeCookie(value, secret), { + challenge: RFC_CHALLENGE, + issuedAt: 1_700_000_000_000, + }); + }); + + void it('should reject forged or malformed values', () => { + const value = signChallengeCookie(RFC_CHALLENGE, 1_700_000_000_000, secret); + const [challenge, issuedAt, signature] = value.split('.'); + [ + undefined, + '', + RFC_CHALLENGE, + `${challenge}.${issuedAt}`, + `${challenge}.0.${signature}`, + `${pkceChallenge('a'.repeat(43))}.${issuedAt}.${signature}`, + `${challenge}.${issuedAt}.${signature}x`, + `${value}.extra`, + signChallengeCookie(RFC_CHALLENGE, 1_700_000_000_000, 'other-secret'), + ].forEach((cookie) => { + assert.equal(readChallengeCookie(cookie, secret), null, String(cookie)); + }); + }); +}); + +void describe('isValidHandoffCode', () => { + void it('should only accept generated codes', () => { + assert.ok(isValidHandoffCode(generateOpaqueToken())); + assert.ok(!isValidHandoffCode('ABC234')); + assert.ok(!isValidHandoffCode(generateOpaqueToken().toUpperCase())); + assert.ok(!isValidHandoffCode(`${generateOpaqueToken()}0`)); + }); +}); + +void describe('isJsonContentType', () => { + void it('should accept JSON with or without parameters', () => { + assert.ok(isJsonContentType('application/json')); + assert.ok(isJsonContentType('Application/JSON; charset=utf-8')); + }); + + void it('should reject what HTML forms can send', () => { + [ + undefined, + '', + 'application/x-www-form-urlencoded', + 'multipart/form-data; boundary=x', + 'text/plain', + 'text/plain; application/json', + ].forEach((contentType) => { + assert.ok(!isJsonContentType(contentType), String(contentType)); + }); + }); +}); + +void describe('PKCE', () => { + void it('should match the RFC 7636 example', () => { + assert.equal(pkceChallenge(RFC_VERIFIER), RFC_CHALLENGE); + assert.ok(isValidChallenge(RFC_CHALLENGE)); + assert.ok(verifierMatchesChallenge(RFC_VERIFIER, RFC_CHALLENGE)); + }); + + void it('should reject a different or malformed verifier', () => { + assert.ok(!verifierMatchesChallenge(`${RFC_VERIFIER.slice(0, -1)}A`, RFC_CHALLENGE)); + assert.ok(!verifierMatchesChallenge('short', pkceChallenge('short'))); + assert.ok(!verifierMatchesChallenge(`${'a'.repeat(43)} `, pkceChallenge(`${'a'.repeat(43)} `))); + }); + + void it('should only accept base64url sha256 challenges', () => { + assert.ok(!isValidChallenge(`${RFC_CHALLENGE}=`)); + assert.ok(!isValidChallenge(RFC_CHALLENGE.replace('-', '+'))); + assert.ok(!isValidChallenge(RFC_CHALLENGE.slice(1))); + }); +}); + +void describe('hashVerificationToken', () => { + void it("should hash like next-auth, so both can consume each other's codes", () => { + const secret = 'test-secret'; + assert.equal( + hashVerificationToken('ABC234', secret), + hashToken('ABC234', { provider: {}, secret }), + ); + }); +}); + +void describe('handoff identifiers', () => { + void it('should round trip the user id for the same challenge only', () => { + const identifier = handoffIdentifier(RFC_CHALLENGE, 42); + assert.equal(handoffUserId(identifier, RFC_CHALLENGE), 42); + assert.equal(handoffUserId(identifier, pkceChallenge('a'.repeat(43))), null); + assert.equal(handoffUserId('someone@example.com', RFC_CHALLENGE), null); + assert.equal(handoffUserId(`mobile-handoff:${RFC_CHALLENGE}:42x`, RFC_CHALLENGE), null); + }); +}); + +void describe('getBearerToken', () => { + void it('should read the token from an Authorization header', () => { + assert.equal(getBearerToken('Bearer abc123'), 'abc123'); + assert.equal(getBearerToken('bearer abc123 '), 'abc123'); + }); + + void it('should ignore anything else', () => { + assert.equal(getBearerToken(undefined), null); + assert.equal(getBearerToken(''), null); + assert.equal(getBearerToken('Basic abc123'), null); + assert.equal(getBearerToken('Bearer'), null); + assert.equal(getBearerToken('Bearer abc 123'), null); + }); +}); + +void describe('hasBearerAuthorization', () => { + void it('should detect the bearer scheme, even with a malformed token', () => { + assert.ok(hasBearerAuthorization('Bearer abc123')); + assert.ok(hasBearerAuthorization('bearer abc 123')); + assert.ok(hasBearerAuthorization('Bearer')); + }); + + void it('should leave other requests to the cookie session', () => { + assert.ok(!hasBearerAuthorization(undefined)); + assert.ok(!hasBearerAuthorization('')); + assert.ok(!hasBearerAuthorization('Basic abc123')); + assert.ok(!hasBearerAuthorization('Bearerabc')); + }); +}); + +void describe('formatAppleFullName', () => { + void it('should join the parts Apple shares', () => { + assert.equal(formatAppleFullName({ givenName: 'Ada', familyName: 'Lovelace' }), 'Ada Lovelace'); + assert.equal( + formatAppleFullName({ givenName: ' Ada ', middleName: null, familyName: 'Lovelace' }), + 'Ada Lovelace', + ); + assert.equal(formatAppleFullName(' Ada Lovelace '), 'Ada Lovelace'); + }); + + void it('should return null without a name', () => { + assert.equal(formatAppleFullName(undefined), null); + assert.equal(formatAppleFullName({ givenName: null, familyName: null }), null); + assert.equal(formatAppleFullName(' '), null); + }); +}); diff --git a/src/utils/expoPush.ts b/src/utils/expoPush.ts new file mode 100644 index 0000000..17ab48f --- /dev/null +++ b/src/utils/expoPush.ts @@ -0,0 +1,67 @@ +import { + Expo, + type ExpoPushMessage, + type ExpoPushReceipt, + type ExpoPushTicket, +} from 'expo-server-sdk'; + +/** A notification as the app shows it, `url` being the in-app path it opens. */ +export type NativePushData = { title: string; message: string; url: string }; + +export type PendingPushReceipt = { id: string; token: string }; + +export const DEVICE_PLATFORMS = ['ios', 'android'] as const; + +export const isValidExpoPushToken = (token: string) => Expo.isExpoPushToken(token); + +/** Where an expense notification opens: the expense, or once it's deleted, where it was. */ +export const getExpensePushUrl = (expense: { + id: string; + groupId: number | null; + deletedBy: number | null; +}) => { + if (null === expense.deletedBy) { + return `/expenses/${expense.id}`; + } + + return null === expense.groupId ? '/activity' : `/groups/${expense.groupId}`; +}; + +/** One message per token, so every ticket Expo returns belongs to the token at its index. */ +export const buildExpoPushMessages = (tokens: string[], data: NativePushData): ExpoPushMessage[] => + tokens.map((to) => ({ + to, + title: data.title, + body: data.message, + data: { url: data.url }, + sound: 'default', + })); + +const isUnregistered = (result: ExpoPushTicket | ExpoPushReceipt) => + 'error' === result.status && 'DeviceNotRegistered' === result.details?.error; + +/** Tokens Expo no longer knows, and the receipts to check later for the ones it accepted. */ +export const readPushTickets = (tokens: string[], tickets: ExpoPushTicket[]) => ({ + unregistered: tickets.flatMap((ticket, index) => { + if ('error' !== ticket.status || !isUnregistered(ticket)) { + return []; + } + + const token = ticket.details?.expoPushToken ?? tokens[index]; + return token ? [token] : []; + }), + receipts: tickets.flatMap((ticket, index) => { + const token = tokens[index]; + return 'ok' === ticket.status && token ? [{ id: ticket.id, token }] : []; + }), +}); + +/** Tokens whose delivery receipt says the app is gone from the device. */ +export const readPushReceipts = ( + pending: PendingPushReceipt[], + receipts: Record, +) => + pending.flatMap(({ id, token }) => { + const receipt = receipts[id]; + return receipt && isUnregistered(receipt) ? [token] : []; + }); diff --git a/tsconfig.json b/tsconfig.json index 27248ea..e67c5a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,5 +39,5 @@ "**/*.js", ".next/types/**/*.ts", ], - "exclude": ["node_modules", "public"], + "exclude": ["node_modules", "public", "mobile"], } From e01bd881e0c2bafa20fc005329d87dffbc6718d1 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Wed, 21 Oct 2026 20:19:37 +1100 Subject: [PATCH 2/9] feat: delete account with an email code and Apple token revocation Anonymises the user and keeps shared expenses, balances and groups intact for everyone else. Deletion needs a one-time code emailed to the account; Apple sign-in tokens are revoked via Apple's REST API when configured. --- .env.example | 8 + .../migration.sql | 2 + prisma/schema.prisma | 3 + src/env.js | 11 + src/pages/add.tsx | 1 + src/server/api/routers/group.ts | 6 + src/server/api/routers/user.ts | 30 ++ src/server/api/services/accountDeletion.ts | 425 +++++++++++++++ src/server/api/services/recurringService.ts | 27 +- src/server/api/trpc.ts | 3 + src/server/appleRevoke.ts | 147 ++++++ src/server/mailer.ts | 30 ++ src/server/rateLimit.ts | 11 + src/store/addStore.ts | 1 + src/tests/accountDeletion.integration.test.ts | 491 ++++++++++++++++++ src/tests/addFlow.test.ts | 1 + src/tests/appleRevoke.test.ts | 249 +++++++++ src/tests/defaultSplit.test.ts | 1 + src/tests/editSnapshot.test.ts | 1 + src/tests/invites.test.ts | 1 + src/tests/quickWins.test.ts | 1 + src/tests/recurrence.test.ts | 1 + src/tests/splitRoundTrip.test.ts | 1 + src/tests/splits.test.ts | 1 + 24 files changed, 1445 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20261021120000_add_user_deleted_at/migration.sql create mode 100644 src/server/api/services/accountDeletion.ts create mode 100644 src/server/appleRevoke.ts create mode 100644 src/tests/accountDeletion.integration.test.ts create mode 100644 src/tests/appleRevoke.test.ts diff --git a/.env.example b/.env.example index f79c2ce..827826b 100644 --- a/.env.example +++ b/.env.example @@ -50,6 +50,14 @@ AUTHENTIK_ID= AUTHENTIK_SECRET= AUTHENTIK_ISSUER= +# Sign in with Apple in the native app. APPLE_BUNDLE_ID is the app's bundle id. +# The key (Certificates, Identifiers & Profiles > Keys, with Sign in with Apple enabled) revokes a +# user's Apple sign-in when they delete their account. Paste the .p8 contents, \n for newlines. +APPLE_BUNDLE_ID= +APPLE_TEAM_ID= +APPLE_KEY_ID= +APPLE_PRIVATE_KEY= + # Storage: any S3 compatible storage will work, for self hosting can use minio # If you're using minio for dev, you can generate access keys from the console http://localhost:9001/access-keys/new-account # R2_ACCESS_KEY="access-key" diff --git a/prisma/migrations/20261021120000_add_user_deleted_at/migration.sql b/prisma/migrations/20261021120000_add_user_deleted_at/migration.sql new file mode 100644 index 0000000..52261bb --- /dev/null +++ b/prisma/migrations/20261021120000_add_user_deleted_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "deletedAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 2e112e9..696907b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -50,6 +50,9 @@ model User { emailVerified DateTime? image String? currency String @default("USD") + /// Set when the person deleted their account. The row stays, without their name or email, so + /// expenses and balances they share with others stay intact. + deletedAt DateTime? accounts Account[] sessions Session[] groups Group[] @relation("GroupCreatedBy") diff --git a/src/env.js b/src/env.js index 5f23ab5..f721719 100644 --- a/src/env.js +++ b/src/env.js @@ -46,6 +46,14 @@ export const env = createEnv({ * production. Apple sign-in is off when unset. */ APPLE_BUNDLE_ID: z.string().optional(), + /** + * A Sign in with Apple key, to revoke a user's Apple sign-in when they delete their account. + * The private key is the .p8 file's contents; literal `\n` are read as newlines. Revoking is + * skipped when any of these is unset. + */ + APPLE_TEAM_ID: z.string().optional(), + APPLE_KEY_ID: z.string().optional(), + APPLE_PRIVATE_KEY: z.string().optional(), R2_ACCESS_KEY: z.string().optional(), R2_SECRET_KEY: z.string().optional(), R2_BUCKET: z.string().optional(), @@ -107,6 +115,9 @@ export const env = createEnv({ AUTHENTIK_SECRET: process.env.AUTHENTIK_SECRET, AUTHENTIK_ISSUER: process.env.AUTHENTIK_ISSUER, APPLE_BUNDLE_ID: process.env.APPLE_BUNDLE_ID, + APPLE_TEAM_ID: process.env.APPLE_TEAM_ID, + APPLE_KEY_ID: process.env.APPLE_KEY_ID, + APPLE_PRIVATE_KEY: process.env.APPLE_PRIVATE_KEY, R2_ACCESS_KEY: process.env.R2_ACCESS_KEY, R2_SECRET_KEY: process.env.R2_SECRET_KEY, R2_BUCKET: process.env.R2_BUCKET, diff --git a/src/pages/add.tsx b/src/pages/add.tsx index 78be5c9..61ec7a3 100644 --- a/src/pages/add.tsx +++ b/src/pages/add.tsx @@ -52,6 +52,7 @@ const AddPage: NextPageWithUser<{ setCurrentUser({ ...user, emailVerified: null, + deletedAt: null, name: user.name ?? null, email: user.email ?? null, image: user.image ?? null, diff --git a/src/server/api/routers/group.ts b/src/server/api/routers/group.ts index bc62469..221f42f 100644 --- a/src/server/api/routers/group.ts +++ b/src/server/api/routers/group.ts @@ -353,6 +353,12 @@ export const groupRouter = createTRPCRouter({ const userIds = [...new Set(input.userIds)]; await assertKnownUsers(ctx.session.user.id, userIds); + if ( + 0 < (await ctx.db.user.count({ where: { id: { in: userIds }, deletedAt: { not: null } } })) + ) { + throw new TRPCError({ code: 'BAD_REQUEST', message: "That account doesn't exist anymore" }); + } + const groupUsers = await ctx.db.groupUser.createMany({ data: userIds.map((userId) => ({ groupId: input.groupId, diff --git a/src/server/api/routers/user.ts b/src/server/api/routers/user.ts index fa5966c..513a93d 100644 --- a/src/server/api/routers/user.ts +++ b/src/server/api/routers/user.ts @@ -31,6 +31,11 @@ import { runBalanceTransaction } from '../services/balanceTransaction'; import { getBalanceBreakdownWithFriend } from '../services/balanceBreakdown'; import { sendRecurringStatusPushNotification } from '../services/notificationService'; import { pauseRulesWith, recurrenceInputSchema } from '../services/recurringService'; +import { + deleteAccount, + getAccountDeletionInfo, + requestAccountDeletionCode, +} from '../services/accountDeletion'; export const userRouter = createTRPCRouter({ me: protectedProcedure.query(async ({ ctx }) => { @@ -120,11 +125,13 @@ export const userRouter = createTRPCRouter({ const friendsIds = [...new Set([...balanceWithFriends, ...friendships].map((f) => f.friendId))]; + // Someone who deleted their account can't be added to anything new const friends = await db.user.findMany({ where: { id: { in: friendsIds, }, + deletedAt: null, }, }); @@ -603,6 +610,29 @@ export const userRouter = createTRPCRouter({ await importGroupFromSplitwise(ctx.session.user.id, input.groups); }), + /** What deleting the account changes: balances that stay with friends, groups handed over. */ + getDeleteAccountInfo: protectedProcedure.query(({ ctx }) => + getAccountDeletionInfo(ctx.session.user.id), + ), + + /** Emails the one-time code `deleteAccount` needs. */ + requestDeleteAccountCode: protectedProcedure.mutation(({ ctx }) => + requestAccountDeletionCode(ctx.session.user.id), + ), + + /** + * Deletes the account: name, email and sign-ins go, shared expenses stay for the others. Accounts + * linked to Apple send a fresh authorization code, so their Apple sign-in can be revoked. + */ + deleteAccount: protectedProcedure + .input( + z.object({ + code: z.string().trim().min(1).max(32), + appleAuthorizationCode: z.string().min(1).max(1024).optional(), + }), + ) + .mutation(({ ctx, input }) => deleteAccount(ctx.session.user.id, input)), + getWebPushPublicKey: protectedProcedure.query(async ({ ctx }) => { return env.WEB_PUSH_PUBLIC_KEY; }), diff --git a/src/server/api/services/accountDeletion.ts b/src/server/api/services/accountDeletion.ts new file mode 100644 index 0000000..0c90e57 --- /dev/null +++ b/src/server/api/services/accountDeletion.ts @@ -0,0 +1,425 @@ +import { type Prisma, RecurringStatus } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; +import { timingSafeEqual } from 'crypto'; + +import { env } from '~/env'; +import { authOptions, generateEmailToken } from '~/server/auth'; +import { db } from '~/server/db'; +import { sendAccountDeletedEmail, sendAccountDeletionCodeEmail } from '~/server/mailer'; +import { + HANDOFF_IDENTIFIER_PREFIX, + hashVerificationToken, + parseAppleAudiences, +} from '~/server/mobileAuthTokens'; +import { + RateLimitedError, + type RateLimitRule, + consumeRateLimits, + resetRateLimit, +} from '~/server/rateLimit'; +import { getAppleRevokeConfig, revokeAppleAuthorization } from '~/server/appleRevoke'; + +import { + sendRecurringGroupDeletedPushNotification, + sendRecurringStatusPushNotification, +} from './notificationService'; +import { lockAndPrepareAccountRemoval } from './recurringService'; + +/** What other people see in place of someone who deleted their account. */ +export const DELETED_USER_NAME = 'Deleted user'; + +const MINUTE = 60; +const DAY = 24 * 60 * MINUTE; + +export const DELETE_ACCOUNT_CODE_MAX_AGE_SECONDS = 15 * MINUTE; +export const DELETE_ACCOUNT_RESEND_SECONDS = MINUTE; +export const INVALID_DELETE_CODE_MESSAGE = 'Invalid or expired code'; + +export const ACCOUNT_DELETION_LIMITS = { + sendCooldown: { + name: 'account-delete-send:user:cooldown', + limit: 1, + windowSeconds: DELETE_ACCOUNT_RESEND_SECONDS, + }, + sendPerDay: { name: 'account-delete-send:user:1d', limit: 10, windowSeconds: DAY }, + // Like the sign-in code's: a few guesses per code, then a new one has to be requested + verifyPerCode: { + name: 'account-delete-verify:user', + limit: 5, + windowSeconds: DELETE_ACCOUNT_CODE_MAX_AGE_SECONDS, + }, +} satisfies Record; + +const DELETE_CODE_IDENTIFIER_PREFIX = 'account-delete'; +const ACCOUNT_DELETION_TRANSACTION_OPTIONS = { maxWait: 10_000, timeout: 20_000 }; + +/** + * Deletion codes live next to sign-in codes on VerificationToken, under their own identifier, so + * neither kind can be used as the other: sign-in looks codes up by email, this by user id. + */ +export const deleteCodeIdentifier = (userId: number) => + `${DELETE_CODE_IDENTIFIER_PREFIX}:${userId}`; + +const getSecret = () => { + const secret = authOptions.secret ?? env.NEXTAUTH_SECRET; + if (!secret) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Auth is not configured' }); + } + return secret; +}; + +/** + * The identifier is part of the hash, so a code can only match the account it was sent for, and + * two accounts getting the same code don't clash on the unique token column. + */ +export const hashDeleteCode = (userId: number, code: string, secret: string) => + hashVerificationToken(`${deleteCodeIdentifier(userId)}:${code.trim().toUpperCase()}`, secret); + +const isSameHash = (a: string, b: string) => { + const left = Buffer.from(a, 'hex'); + const right = Buffer.from(b, 'hex'); + return left.length === right.length && timingSafeEqual(left, right); +}; + +const rateLimited = (retryAfterSeconds: number) => { + const cause = new RateLimitedError(retryAfterSeconds); + return new TRPCError({ code: 'TOO_MANY_REQUESTS', message: cause.message, cause }); +}; + +/** + * What deleting the account changes, for the screen that explains it: their outstanding balances + * (which stay with their friends) and the groups they created (which pass to another member). + */ +export const getAccountDeletionInfo = async (userId: number) => { + const [user, balances, groups, appleAccounts] = await Promise.all([ + db.user.findUniqueOrThrow({ where: { id: userId }, select: { email: true } }), + db.balance.groupBy({ + by: ['currency'], + _sum: { amount: true }, + where: { userId, amount: { not: 0 } }, + }), + db.group.findMany({ + where: { userId }, + orderBy: { createdAt: 'asc' }, + select: { + id: true, + name: true, + // Who handOverGroups gives it to + groupUsers: { + where: { userId: { not: userId }, user: { deletedAt: null } }, + orderBy: { userId: 'asc' }, + take: 1, + select: { user: { select: { name: true, email: true } } }, + }, + groupBalances: { where: { amount: { not: 0 } }, take: 1, select: { amount: true } }, + }, + }), + db.account.count({ where: { userId, provider: 'apple' } }), + ]); + + return { + email: user.email, + balances: balances + .map((balance) => ({ currency: balance.currency, amount: balance._sum.amount ?? 0 })) + .filter((balance) => 0 !== balance.amount), + groups: groups.map((group) => { + const nextOwner = group.groupUsers[0]?.user; + return { + id: group.id, + name: group.name, + nextOwnerName: nextOwner ? nextOwner.name ?? nextOwner.email : null, + willBeDeleted: !nextOwner && 0 === group.groupBalances.length, + }; + }), + hasAppleAccount: 0 < appleAccounts, + }; +}; + +/** + * Emails a one-time code that `deleteAccount` needs. Only the newest code works, and it gets a + * fresh set of attempts. + */ +export const requestAccountDeletionCode = async (userId: number) => { + const user = await db.user.findUniqueOrThrow({ where: { id: userId }, select: { email: true } }); + + if (!user.email) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: 'Your account has no email address to send a code to', + }); + } + + const key = String(userId); + const result = await consumeRateLimits([ + [ACCOUNT_DELETION_LIMITS.sendCooldown, key], + [ACCOUNT_DELETION_LIMITS.sendPerDay, key], + ]); + + if (!result.allowed) { + throw rateLimited(result.retryAfterSeconds); + } + + const code = generateEmailToken(); + const identifier = deleteCodeIdentifier(userId); + + await db.$transaction([ + db.verificationToken.deleteMany({ where: { identifier } }), + db.verificationToken.create({ + data: { + identifier, + token: hashDeleteCode(userId, code, getSecret()), + expires: new Date(Date.now() + DELETE_ACCOUNT_CODE_MAX_AGE_SECONDS * 1000), + }, + }), + ]); + await resetRateLimit(ACCOUNT_DELETION_LIMITS.verifyPerCode, key); + + const sent = await sendAccountDeletionCodeEmail( + user.email, + code, + DELETE_ACCOUNT_CODE_MAX_AGE_SECONDS / MINUTE, + ); + + if (!sent) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to send email' }); + } + + return { + email: user.email, + expiresInSeconds: DELETE_ACCOUNT_CODE_MAX_AGE_SECONDS, + resendAfterSeconds: DELETE_ACCOUNT_RESEND_SECONDS, + }; +}; + +/** + * Throws unless `code` is the account's current deletion code. Counts the attempt; too many burn + * the code. Leaves it in place: `deleteAccount` consumes it in the transaction that deletes. + */ +const assertDeleteCode = async (userId: number, hash: string) => { + const key = String(userId); + const identifier = deleteCodeIdentifier(userId); + const result = await consumeRateLimits([[ACCOUNT_DELETION_LIMITS.verifyPerCode, key]]); + + if (!result.allowed) { + await db.verificationToken.deleteMany({ where: { identifier } }); + throw rateLimited(result.retryAfterSeconds); + } + + const stored = await db.verificationToken.findFirst({ where: { identifier } }); + + if (!stored || !isSameHash(stored.token, hash) || stored.expires.getTime() <= Date.now()) { + throw new TRPCError({ code: 'BAD_REQUEST', message: INVALID_DELETE_CODE_MESSAGE }); + } +}; + +/** + * Best effort, as the App Store requires for Sign in with Apple: revokes the user's Apple tokens + * with a fresh authorization code from the app. Needs the Apple `sub` of their linked accounts, + * so it runs before those are removed. + */ +const revokeAppleSignIn = async (userId: number, authorizationCode: string | undefined) => { + const appleAccounts = await db.account.findMany({ + where: { userId, provider: 'apple' }, + select: { providerAccountId: true }, + }); + + if (0 === appleAccounts.length) { + return; + } + + if (!authorizationCode) { + console.warn('Deleting an account linked to Apple without an authorization code to revoke'); + return; + } + + const config = getAppleRevokeConfig({ + teamId: env.APPLE_TEAM_ID, + keyId: env.APPLE_KEY_ID, + privateKey: env.APPLE_PRIVATE_KEY, + clientId: parseAppleAudiences(env.APPLE_BUNDLE_ID ?? '', false)[0], + }); + + if (!config) { + console.warn( + 'Not revoking Sign in with Apple: set APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_PRIVATE_KEY and APPLE_BUNDLE_ID', + ); + return; + } + + await revokeAppleAuthorization({ + config, + authorizationCode, + appleUserIds: appleAccounts.map((account) => account.providerAccountId), + }); +}; + +/** + * Groups the account created pass to the member who has been around longest (lowest id). A group + * nobody else is in any more goes, like deleting it would, if it's settled. Returns the deleted + * groups with their recurring expenses, whose creators should be told. + */ +const handOverGroups = async (tx: Prisma.TransactionClient, userId: number) => { + const groups = await tx.group.findMany({ + where: { userId }, + select: { + id: true, + name: true, + groupUsers: { + where: { userId: { not: userId }, user: { deletedAt: null } }, + orderBy: { userId: 'asc' }, + take: 1, + select: { userId: true }, + }, + groupBalances: { where: { amount: { not: 0 } }, take: 1, select: { amount: true } }, + recurringExpenses: { + where: { status: { not: RecurringStatus.STOPPED }, createdById: { not: userId } }, + select: { + createdById: true, + occurrences: { orderBy: { occurrenceDate: 'desc' }, take: 1, select: { name: true } }, + }, + }, + }, + }); + + const deleted: typeof groups = []; + + for (const group of groups) { + const nextOwner = group.groupUsers[0]; + + if (nextOwner) { + await tx.group.update({ where: { id: group.id }, data: { userId: nextOwner.userId } }); + } else if (0 === group.groupBalances.length) { + await tx.group.delete({ where: { id: group.id } }); + deleted.push(group); + } + } + + return deleted; +}; + +/** + * Leaves every group the account is settled in. Where it still has a balance it stays a member, + * shown as a deleted user, so the others can see and settle what's left. + */ +const leaveSettledGroups = async (tx: Prisma.TransactionClient, userId: number) => { + const unsettled = await tx.groupBalance.findMany({ + where: { userId, amount: { not: 0 } }, + distinct: ['groupId'], + select: { groupId: true }, + }); + + await tx.groupUser.deleteMany({ + where: { userId, groupId: { notIn: unsettled.map((balance) => balance.groupId) } }, + }); +}; + +/** + * Removes the person, not what they share: their name, email, sign-in methods, sessions, devices + * and friendships go. Expenses, balances and notes stay for the people they share them with, + * against an anonymous user row. + */ +const anonymiseAccount = async ( + tx: Prisma.TransactionClient, + userId: number, + email: string | null, +) => { + const pausedIds = await lockAndPrepareAccountRemoval(tx, userId); + const deletedGroups = await handOverGroups(tx, userId); + await leaveSettledGroups(tx, userId); + + await tx.friendship.deleteMany({ where: { OR: [{ userId }, { friendId: userId }] } }); + await tx.account.deleteMany({ where: { userId } }); + await tx.devicePushToken.deleteMany({ where: { userId } }); + await tx.session.deleteMany({ where: { userId } }); + await tx.pushNotification.deleteMany({ where: { userId } }); + await tx.verificationToken.deleteMany({ + where: { + OR: [ + { identifier: deleteCodeIdentifier(userId) }, + ...(email ? [{ identifier: email }] : []), + { + AND: [ + { identifier: { startsWith: `${HANDOFF_IDENTIFIER_PREFIX}:` } }, + { identifier: { endsWith: `:${userId}` } }, + ], + }, + ], + }, + }); + + await tx.user.update({ + where: { id: userId }, + data: { + name: DELETED_USER_NAME, + email: null, + emailVerified: null, + image: null, + deletedAt: new Date(), + }, + }); + + return { pausedIds, deletedGroups }; +}; + +/** + * Deletes the account once the emailed code checks out. The code is used up in the same + * transaction, so it works once. + */ +export const deleteAccount = async ( + userId: number, + { code, appleAuthorizationCode }: { code: string; appleAuthorizationCode?: string }, +) => { + const hash = hashDeleteCode(userId, code, getSecret()); + await assertDeleteCode(userId, hash); + + // Captured before it's removed, for the goodbye email + const { email } = await db.user.findUniqueOrThrow({ + where: { id: userId }, + select: { email: true }, + }); + + await revokeAppleSignIn(userId, appleAuthorizationCode); + + // Balances don't change, so not serializable like balance changes: that would conflict with + // them for nothing. The rules and the account are locked row by row instead. + const { pausedIds, deletedGroups } = await db.$transaction(async (tx) => { + const { count } = await tx.verificationToken.deleteMany({ + where: { identifier: deleteCodeIdentifier(userId), token: hash, expires: { gt: new Date() } }, + }); + + if (1 !== count) { + throw new TRPCError({ code: 'BAD_REQUEST', message: INVALID_DELETE_CODE_MESSAGE }); + } + + return anonymiseAccount(tx, userId, email); + }, ACCOUNT_DELETION_TRANSACTION_OPTIONS); + + const key = String(userId); + await Promise.all([ + resetRateLimit(ACCOUNT_DELETION_LIMITS.verifyPerCode, key), + ...(email ? [db.rateLimit.deleteMany({ where: { key: { endsWith: `:${email}` } } })] : []), + ]).catch(console.error); + + pausedIds.forEach((id) => { + sendRecurringStatusPushNotification(id).catch(console.error); + }); + deletedGroups.forEach((group) => { + sendRecurringGroupDeletedPushNotification( + group.recurringExpenses.flatMap((rule) => + null === rule.createdById + ? [] + : [ + { + createdById: rule.createdById, + name: rule.occurrences[0]?.name ?? 'A recurring expense', + }, + ], + ), + group.name, + ).catch(console.error); + }); + + if (email) { + sendAccountDeletedEmail(email).catch(console.error); + } +}; diff --git a/src/server/api/services/recurringService.ts b/src/server/api/services/recurringService.ts index 0eb4328..0c2dd85 100644 --- a/src/server/api/services/recurringService.ts +++ b/src/server/api/services/recurringService.ts @@ -135,6 +135,7 @@ export async function setRuleTemplate( /** * Why the rule can't go on because someone it recorded has no account anymore, if so. Their rows * are gone from the expense with their account, so only the recorded ids still show they were in. + * A deleted account that was anonymised still has its rows, but counts as gone too. */ const getAccountGoneProblem = async ( tx: Prisma.TransactionClient, @@ -144,7 +145,9 @@ const getAccountGoneProblem = async ( ...new Set([...rule.templateUserIds, ...(rule.createdById ? [rule.createdById] : [])]), ]; const existing = new Set( - (await tx.user.findMany({ where: { id: { in: ids } }, select: { id: true } })).map((u) => u.id), + ( + await tx.user.findMany({ where: { id: { in: ids }, deletedAt: null }, select: { id: true } }) + ).map((u) => u.id), ); if (null === rule.createdById || !existing.has(rule.createdById)) { return { status: RecurringStatus.STOPPED, reason: CREATOR_GONE_REASON }; @@ -254,7 +257,7 @@ export async function checkRule( // Everyone recorded must still have an account and their row in the expense, also with a zero // share. Each saved expense adds up to zero and has the payer's own row. const userIds = rule.templateUserIds; - const existing = await tx.user.count({ where: { id: { in: userIds } } }); + const existing = await tx.user.count({ where: { id: { in: userIds }, deletedAt: null } }); const total = template.expenseParticipants.reduce((sum, p) => sum + p.amount, 0); if ( existing !== userIds.length || @@ -552,6 +555,19 @@ export async function prepareAccountDeletion(tx: Prisma.TransactionClient, userI return rules.map((rule) => rule.id); } +/** + * Locks what removing or anonymising the account touches and stops or pauses its rules, in the + * transaction that does it. Returns the paused rules, whose creators should be told. + */ +export async function lockAndPrepareAccountRemoval(tx: Prisma.TransactionClient, userId: number) { + // Every rule it can reach, then the account, then their rules again. An edit adding them to a + // rule either committed before the account was locked, and the second look finds it, or can't + // add them. + await lockRulesOfAccount(tx, userId); + await tx.$queryRaw`SELECT id FROM "User" WHERE id = ${userId} FOR UPDATE`; + return prepareAccountDeletion(tx, userId); +} + /** * Removes an account, pausing or stopping its recurring expenses first. Returns the paused rules * and the ones deleted with the groups they owned, whose creators should be told once committed. @@ -559,12 +575,7 @@ export async function prepareAccountDeletion(tx: Prisma.TransactionClient, userI export async function deleteUserAccount(userId: number) { // Retried on a conflict like any balance change, as it deletes expenses and balances too. return runBalanceTransaction(async (tx) => { - // Every rule it can reach, then the account, then their rules again. An edit adding them to a - // rule either committed before the account was locked, and the second look finds it, or can't - // add them. - await lockRulesOfAccount(tx, userId); - await tx.$queryRaw`SELECT id FROM "User" WHERE id = ${userId} FOR UPDATE`; - const pausedIds = await prepareAccountDeletion(tx, userId); + const pausedIds = await lockAndPrepareAccountRemoval(tx, userId); const groups = await tx.group.findMany({ where: { userId }, select: { diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts index e6c3abe..c468af4 100644 --- a/src/server/api/trpc.ts +++ b/src/server/api/trpc.ts @@ -18,6 +18,7 @@ import { getClientIp } from '~/server/clientIp'; import { db } from '~/server/db'; import { findMobileSession } from '~/server/mobileAuth'; import { getBearerToken, hasBearerAuthorization } from '~/server/mobileAuthTokens'; +import { RateLimitedError } from '~/server/rateLimit'; /** * 1. CONTEXT @@ -99,6 +100,8 @@ const t = initTRPC.context().create({ data: { ...shape.data, zodError: error.cause instanceof ZodError ? error.cause.flatten() : null, + retryAfterSeconds: + error.cause instanceof RateLimitedError ? error.cause.retryAfterSeconds : null, }, }; }, diff --git a/src/server/appleRevoke.ts b/src/server/appleRevoke.ts new file mode 100644 index 0000000..d10167b --- /dev/null +++ b/src/server/appleRevoke.ts @@ -0,0 +1,147 @@ +import { SignJWT, decodeJwt, importPKCS8 } from 'jose'; + +/** + * Revokes a user's Sign in with Apple authorization when they delete their account, as the App + * Store requires. Free of db and env imports so it can be unit tested. + * + * @see https://developer.apple.com/documentation/signinwithapplerestapi/revoke-tokens + */ + +export const APPLE_AUDIENCE = 'https://appleid.apple.com'; +const APPLE_TOKEN_URL = 'https://appleid.apple.com/auth/token'; +const APPLE_REVOKE_URL = 'https://appleid.apple.com/auth/revoke'; +// The secret is made for one exchange and one revoke. Apple accepts up to six months. +const CLIENT_SECRET_MAX_AGE_SECONDS = 5 * 60; +const APPLE_REQUEST_TIMEOUT_MS = 10_000; + +export type AppleRevokeConfig = { + teamId: string; + keyId: string; + /** PKCS #8 PEM, the contents of the .p8 file. */ + privateKey: string; + /** The app's bundle id, which Apple issued the authorization code to. */ + clientId: string; +}; + +export type AppleRevokeResult = 'revoked' | 'unconfigured' | 'mismatched' | 'failed'; + +/** Env vars often hold a PEM on one line, with literal `\n`. */ +export const normalizeApplePrivateKey = (privateKey: string) => + privateKey.replace(/\\n/g, '\n').trim(); + +export const getAppleRevokeConfig = ({ + teamId, + keyId, + privateKey, + clientId, +}: Partial>): AppleRevokeConfig | null => + teamId && keyId && privateKey && clientId + ? { teamId, keyId, privateKey: normalizeApplePrivateKey(privateKey), clientId } + : null; + +/** + * The client secret JWT for Apple's token endpoints, signed with the Sign in with Apple key. + */ +export const createAppleClientSecret = async (config: AppleRevokeConfig, now = Date.now()) => { + const issuedAt = Math.floor(now / 1000); + + return new SignJWT({}) + .setProtectedHeader({ alg: 'ES256', kid: config.keyId }) + .setIssuer(config.teamId) + .setSubject(config.clientId) + .setAudience(APPLE_AUDIENCE) + .setIssuedAt(issuedAt) + .setExpirationTime(issuedAt + CLIENT_SECRET_MAX_AGE_SECONDS) + .sign(await importPKCS8(config.privateKey, 'ES256')); +}; + +const postForm = (fetchFn: typeof fetch, url: string, form: Record) => + fetchFn(url, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams(form).toString(), + signal: AbortSignal.timeout(APPLE_REQUEST_TIMEOUT_MS), + }); + +const readError = async (response: Response) => { + const body = await response.text().catch(() => ''); + return `${response.status} ${body}`.trim(); +}; + +/** + * Exchanges a fresh authorization code from the app for the user's tokens, then revokes them. Only + * when the code belongs to one of `appleUserIds` (the `sub` of their linked Apple accounts), so a + * code for some other Apple ID can't revoke that person's sign-in. Never throws: deleting the + * account goes on whatever Apple says. + */ +export const revokeAppleAuthorization = async ({ + config, + authorizationCode, + appleUserIds, + fetchFn = fetch, + now = Date.now(), +}: { + config: AppleRevokeConfig | null; + authorizationCode: string; + appleUserIds: string[]; + fetchFn?: typeof fetch; + now?: number; +}): Promise => { + if (!config) { + return 'unconfigured'; + } + + try { + const clientSecret = await createAppleClientSecret(config, now); + const tokenResponse = await postForm(fetchFn, APPLE_TOKEN_URL, { + client_id: config.clientId, + client_secret: clientSecret, + code: authorizationCode, + grant_type: 'authorization_code', + }); + + if (!tokenResponse.ok) { + console.error('Apple token exchange failed', await readError(tokenResponse)); + return 'failed'; + } + + const tokens = (await tokenResponse.json()) as { + id_token?: string; + refresh_token?: string; + access_token?: string; + }; + // Straight from Apple over TLS, so the claims can be read without checking the signature + const sub = tokens.id_token ? decodeJwt(tokens.id_token).sub : undefined; + + if (!sub || !appleUserIds.includes(sub)) { + console.error('Apple authorization code is for another Apple ID, not revoking'); + return 'mismatched'; + } + + const [token, hint] = tokens.refresh_token + ? [tokens.refresh_token, 'refresh_token'] + : [tokens.access_token, 'access_token']; + + if (!token) { + console.error('Apple token exchange returned no token to revoke'); + return 'failed'; + } + + const revokeResponse = await postForm(fetchFn, APPLE_REVOKE_URL, { + client_id: config.clientId, + client_secret: clientSecret, + token, + token_type_hint: hint, + }); + + if (!revokeResponse.ok) { + console.error('Apple token revoke failed', await readError(revokeResponse)); + return 'failed'; + } + + return 'revoked'; + } catch (error) { + console.error('Apple token revoke failed', error); + return 'failed'; + } +}; diff --git a/src/server/mailer.ts b/src/server/mailer.ts index 1dc292e..9d0d6c1 100644 --- a/src/server/mailer.ts +++ b/src/server/mailer.ts @@ -51,6 +51,36 @@ export async function sendSignUpEmail(email: string, token: string, url: string) return await sendMail(email, subject, text, html); } +export async function sendAccountDeletionCodeEmail( + email: string, + token: string, + expiresInMinutes: number, +) { + if (env.NODE_ENV === 'development') { + console.log('Account deletion code : ', email, token); + return true; + } + + const subject = 'Confirm account deletion'; + const text = `Hey,\n\nUse this code to confirm deleting your SplitPro account: ${token}\n\nThe code expires in ${expiresInMinutes} minutes.\n\nIf you didn't ask to delete your account, you can ignore this email and your account stays as it is.\n\nThanks,\nSplitPro Team`; + const html = `

Hey,

Use this code to confirm deleting your SplitPro account: ${token}

The code expires in ${expiresInMinutes} minutes.

If you didn't ask to delete your account, you can ignore this email and your account stays as it is.


Thanks,
SplitPro Team

`; + + return await sendMail(email, subject, text, html); +} + +export async function sendAccountDeletedEmail(email: string) { + if (env.NODE_ENV === 'development') { + console.log('Account deleted email : ', email); + return true; + } + + const subject = 'Your SplitPro account was deleted'; + const text = `Hey,\n\nYour SplitPro account was deleted. Your name and email address were removed. Expenses you shared stay with the people you shared them with.\n\nYou're welcome to sign up again any time.\n\nThanks,\nSplitPro Team`; + const html = `

Hey,

Your SplitPro account was deleted. Your name and email address were removed. Expenses you shared stay with the people you shared them with.

You're welcome to sign up again any time.


Thanks,
SplitPro Team

`; + + return await sendMail(email, subject, text, html); +} + export async function sendInviteEmail(email: string, name: string) { if (!env.ENABLE_SENDING_INVITES) { throw new Error('Sending invites is not enabled'); diff --git a/src/server/rateLimit.ts b/src/server/rateLimit.ts index 1e67046..3da358d 100644 --- a/src/server/rateLimit.ts +++ b/src/server/rateLimit.ts @@ -12,6 +12,17 @@ export type RateLimitResult = { retryAfterSeconds: number; }; +/** + * The cause of a tRPC TOO_MANY_REQUESTS error, so the client gets `retryAfterSeconds` in the error + * data. + */ +export class RateLimitedError extends Error { + constructor(public retryAfterSeconds: number) { + const minutes = Math.max(1, Math.ceil(retryAfterSeconds / 60)); + super(`Too many attempts. Try again in ${minutes} minute${1 === minutes ? '' : 's'}.`); + } +} + /** * Counts one hit for `key` under `rule` and reports whether it is still within the limit. * Fixed windows, stored in Postgres so they survive restarts and deploys. diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 9798dea..1185064 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -976,6 +976,7 @@ export const inviteParticipant = async ( emailVerified: new Date(), image: null, currency: 'USD', + deletedAt: null, }); try { diff --git a/src/tests/accountDeletion.integration.test.ts b/src/tests/accountDeletion.integration.test.ts new file mode 100644 index 0000000..eba9d11 --- /dev/null +++ b/src/tests/accountDeletion.integration.test.ts @@ -0,0 +1,491 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { test } from 'node:test'; +import { UnsecuredJWT } from 'jose'; + +const databaseUrl = process.env.TEST_DATABASE_URL; + +const setup = async () => { + assert.match(new URL(databaseUrl!).pathname, /test/i, 'Refusing to clean a non-test database'); + process.env.DATABASE_URL = databaseUrl; + process.env.SKIP_ENV_VALIDATION = '1'; + process.env.NEXTAUTH_SECRET ??= 'account-deletion-test-secret'; + process.env.NEXTAUTH_URL ??= 'http://localhost:3000'; + + const [ + { SplitType }, + { db }, + { env }, + { authAdapter, generateEmailToken }, + mobileAuth, + { hashVerificationToken }, + { appRouter }, + accountDeletion, + { resetRateLimit }, + ] = await Promise.all([ + import('@prisma/client'), + import('~/server/db'), + import('~/env'), + import('~/server/auth'), + import('~/server/mobileAuth'), + import('~/server/mobileAuthTokens'), + import('~/server/api/root'), + import('~/server/api/services/accountDeletion'), + import('~/server/rateLimit'), + ]); + const secret = process.env.NEXTAUTH_SECRET; + // Read when used, so the test can switch settings. Codes are logged instead of emailed. + const mutableEnv = env as { -readonly [Key in keyof typeof env]: (typeof env)[Key] }; + mutableEnv.NODE_ENV = 'development'; + + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`; + const emailOf = (name: string) => `${name.toLowerCase()}-${suffix}@example.com`; + const createUser = (name: string) => + db.user.create({ data: { email: emailOf(name), name, currency: 'USD' } }); + + const callerFor = (user: { id: number; name: string | null; email: string | null }) => + appRouter.createCaller({ + db, + ip: 'test', + session: { + expires: new Date(Date.now() + 60_000).toISOString(), + user: { id: user.id, name: user.name, email: user.email, currency: 'USD' }, + }, + }); + + /** Signs in like the app does with an email code, returning the bearer session. */ + const signInWithEmail = async (email: string) => { + const code = generateEmailToken(); + await authAdapter.createVerificationToken!({ + identifier: email, + token: hashVerificationToken(code, secret), + expires: new Date(Date.now() + 60_000), + }); + return mobileAuth.verifyEmailCode(email, code, `test-${suffix}`); + }; + + /** Requests a deletion code and reads it from the development log. */ + const requestCode = async (user: { id: number; name: string | null; email: string | null }) => { + await resetRateLimit(accountDeletion.ACCOUNT_DELETION_LIMITS.sendCooldown, String(user.id)); + const logged: unknown[][] = []; + const original = console.log; + console.log = (...args: unknown[]) => logged.push(args); + try { + await callerFor(user).user.requestDeleteAccountCode(); + } finally { + console.log = original; + } + const line = logged.find((args) => 'Account deletion code : ' === args[0]); + assert.ok(line, 'The code is logged in development'); + assert.equal(line[1], user.email); + return String(line[2]); + }; + + return { + SplitType, + db, + env: mutableEnv, + secret, + ...mobileAuth, + ...accountDeletion, + hashVerificationToken, + generateEmailToken, + resetRateLimit, + emailOf, + createUser, + callerFor, + signInWithEmail, + requestCode, + }; +}; + +const withCode = (code: string) => (error: unknown) => { + assert.equal((error as { code?: string }).code, code, String(error)); + return true; +}; + +/** Stands in for Apple's token endpoints, returning an id token for `sub`. */ +const appleFetch = (sub: string, onExchange?: () => Promise) => { + const calls: string[] = []; + const fetchFn = (async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input); + calls.push(url); + if (url.endsWith('/auth/token')) { + await onExchange?.(); + return new Response( + JSON.stringify({ refresh_token: 'refresh', id_token: new UnsecuredJWT({ sub }).encode() }), + ); + } + return new Response(null, { status: 200 }); + }) as typeof fetch; + return { calls, fetchFn }; +}; + +const configureApple = (env: Record) => { + const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); + env.APPLE_BUNDLE_ID = 'app.splitpro.test'; + env.APPLE_TEAM_ID = 'TEAM123456'; + env.APPLE_KEY_ID = 'KEY1234567'; + env.APPLE_PRIVATE_KEY = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); +}; + +void test( + 'account deletion: the emailed code is single use, expires and is limited', + { skip: !databaseUrl }, + async () => { + const t = await setup(); + const eve = await t.createUser('Eve'); + const userIds = [eve.id]; + const originalFetch = globalThis.fetch; + + try { + const caller = t.callerFor(eve); + const deleteWith = (code: string) => caller.user.deleteAccount({ code }); + const identifier = t.deleteCodeIdentifier(eve.id); + + // No code asked for yet + await assert.rejects(deleteWith('ABC234'), withCode('BAD_REQUEST')); + + // A wrong code fails, and the right one is still there afterwards + let code = await t.requestCode(eve); + assert.match(code, /^[A-HJ-NP-Z2-9]{6}$/); + await assert.rejects(deleteWith(code === 'ABC234' ? 'ABC235' : 'ABC234'), (error) => { + assert.equal((error as Error).message, t.INVALID_DELETE_CODE_MESSAGE); + return true; + }); + assert.equal(await t.db.verificationToken.count({ where: { identifier } }), 1); + + // A second request inside the cooldown is refused with how long to wait + await assert.rejects(caller.user.requestDeleteAccountCode(), (error) => { + withCode('TOO_MANY_REQUESTS')(error); + assert.ok( + 0 < + Number( + (error as Error & { cause: { retryAfterSeconds: number } }).cause.retryAfterSeconds, + ), + ); + return true; + }); + + // An expired code fails + await t.db.verificationToken.updateMany({ + where: { identifier }, + data: { expires: new Date(Date.now() - 1000) }, + }); + await assert.rejects(deleteWith(code), withCode('BAD_REQUEST')); + + // Five wrong guesses burn the code: even the right one is refused after + code = await t.requestCode(eve); + assert.equal(await t.db.verificationToken.count({ where: { identifier } }), 1); + for (let attempt = 0; attempt < 5; attempt += 1) { + await assert.rejects(deleteWith('WRONG2'), withCode('BAD_REQUEST')); + } + await assert.rejects(deleteWith(code), withCode('TOO_MANY_REQUESTS')); + assert.equal(await t.db.verificationToken.count({ where: { identifier } }), 0); + + // A new code gets a fresh set of attempts + code = await t.requestCode(eve); + + // A sign-in code for the same email can't delete the account + const signInCode = t.generateEmailToken(); + await t.db.verificationToken.create({ + data: { + identifier: eve.email!, + token: t.hashVerificationToken(signInCode, t.secret), + expires: new Date(Date.now() + 60_000), + }, + }); + await assert.rejects(deleteWith(signInCode), withCode('BAD_REQUEST')); + + // And the deletion code can't sign in + await assert.rejects(t.verifyEmailCode(eve.email!, code, `test-${eve.id}`), (error) => { + assert.equal((error as { status?: number }).status, 400); + return true; + }); + assert.equal(await t.db.verificationToken.count({ where: { identifier } }), 1); + + // An Apple link with Apple failing doesn't stop the deletion + await t.db.account.create({ + data: { + userId: eve.id, + type: 'oauth', + provider: 'apple', + providerAccountId: `eve-${eve.id}`, + }, + }); + configureApple(t.env); + globalThis.fetch = (() => Promise.reject(new TypeError('fetch failed'))) as typeof fetch; + const originalError = console.error; + console.error = () => undefined; + try { + // Lowercase and spaces are fine, as typed + await caller.user.deleteAccount({ + code: ` ${code.toLowerCase()} `, + appleAuthorizationCode: 'apple-code', + }); + } finally { + console.error = originalError; + } + + const deleted = await t.db.user.findUniqueOrThrow({ where: { id: eve.id } }); + assert.ok(deleted.deletedAt); + assert.equal(deleted.email, null); + + // Single use: the same code doesn't work again + await assert.rejects(deleteWith(code), withCode('BAD_REQUEST')); + assert.equal(await t.db.verificationToken.count({ where: { identifier } }), 0); + } finally { + globalThis.fetch = originalFetch; + await t.db.user.deleteMany({ where: { id: { in: userIds } } }); + } + }, +); + +void test( + 'account deletion: anonymises the account and leaves what others share', + { skip: !databaseUrl }, + async () => { + const t = await setup(); + const [alice, bob, carol] = [ + await t.createUser('Alice'), + await t.createUser('Bob'), + await t.createUser('Carol'), + ]; + const userIds = [alice.id, bob.id, carol.id]; + const groupIds: number[] = []; + const originalFetch = globalThis.fetch; + + const createGroup = async (name: string, ownerId: number, memberIds: number[]) => { + const group = await t.db.group.create({ + data: { name, publicId: `${name.toLowerCase()}-${alice.id}`, userId: ownerId }, + }); + await t.db.groupUser.createMany({ + data: memberIds.map((userId) => ({ groupId: group.id, userId })), + }); + groupIds.push(group.id); + return group; + }; + + /** An expense with its balances, as the split service would record it. */ + const addExpense = async ( + groupId: number | null, + currency: string, + paidBy: number, + amount: number, + shares: [number, number][], + ) => { + await t.db.expense.create({ + data: { + groupId, + currency, + paidBy, + addedBy: paidBy, + name: 'Dinner', + category: 'food', + amount, + splitType: t.SplitType.EXACT, + expenseParticipants: { + create: shares.map(([userId, share]) => ({ userId, amount: share })), + }, + }, + }); + const owed = shares.filter(([userId]) => userId !== paidBy); + await t.db.balance.createMany({ + data: owed.flatMap(([userId, share]) => [ + { userId, friendId: paidBy, currency, amount: share }, + { userId: paidBy, friendId: userId, currency, amount: -share }, + ]), + }); + if (null !== groupId) { + await t.db.groupBalance.createMany({ + data: owed.flatMap(([userId, share]) => [ + { groupId, userId, firendId: paidBy, currency, amount: share }, + { groupId, userId: paidBy, firendId: userId, currency, amount: -share }, + ]), + }); + } + }; + + const balancesOf = async (userId: number) => { + const [balances, groupBalances] = await Promise.all([ + t.db.balance.findMany({ + where: { OR: [{ userId }, { friendId: userId }] }, + orderBy: [{ userId: 'asc' }, { friendId: 'asc' }, { currency: 'asc' }], + select: { userId: true, friendId: true, currency: true, amount: true }, + }), + t.db.groupBalance.findMany({ + where: { OR: [{ userId }, { firendId: userId }] }, + orderBy: [{ groupId: 'asc' }, { userId: 'asc' }, { firendId: 'asc' }], + select: { groupId: true, userId: true, firendId: true, currency: true, amount: true }, + }), + ]); + return { balances, groupBalances }; + }; + + try { + // Alice owes Bob in a trip she set up with Bob and Carol, and Bob owes her directly + const trip = await createGroup('Trip', alice.id, [alice.id, bob.id, carol.id]); + const solo = await createGroup('Solo', alice.id, [alice.id]); + const book = await createGroup('Book', carol.id, [alice.id, carol.id]); + // Written directly rather than through the balance transactions, which would only add + // serialization conflicts with the other tests sharing the database + await addExpense(trip.id, 'USD', bob.id, 3000, [ + [bob.id, 2000], + [alice.id, -1000], + [carol.id, -1000], + ]); + await addExpense(null, 'EUR', alice.id, 5000, [ + [alice.id, 2500], + [bob.id, -2500], + ]); + await t.db.friendship.createMany({ + data: [ + { userId: alice.id, friendId: bob.id }, + { userId: bob.id, friendId: alice.id }, + ], + }); + + // She uses the app: a bearer session, a push token, Google and Apple sign-ins + const appSession = await t.signInWithEmail(alice.email!); + const session = await t.db.session.findUniqueOrThrow({ + where: { sessionToken: appSession.token }, + }); + await t.db.devicePushToken.create({ + data: { + userId: alice.id, + token: `ExponentPushToken[alice-${alice.id}]`, + platform: 'ios', + sessionId: session.id, + }, + }); + await t.db.pushNotification.create({ data: { userId: alice.id, subscription: '{}' } }); + const appleSub = `alice-apple-${alice.id}`; + await t.db.account.createMany({ + data: [ + { + userId: alice.id, + type: 'oauth', + provider: 'google', + providerAccountId: `g-${alice.id}`, + }, + { userId: alice.id, type: 'oauth', provider: 'apple', providerAccountId: appleSub }, + ], + }); + + const info = await t.callerFor(alice).user.getDeleteAccountInfo(); + assert.equal(info.email, alice.email); + assert.equal(info.hasAppleAccount, true); + assert.deepEqual( + [...info.balances].sort((a, b) => a.currency.localeCompare(b.currency)), + [ + { currency: 'EUR', amount: 2500 }, + { currency: 'USD', amount: -1000 }, + ], + ); + assert.deepEqual(info.groups, [ + { id: trip.id, name: 'Trip', nextOwnerName: 'Bob', willBeDeleted: false }, + { id: solo.id, name: 'Solo', nextOwnerName: null, willBeDeleted: true }, + ]); + + const bobBefore = await balancesOf(bob.id); + const carolBefore = await balancesOf(carol.id); + + // Apple is asked to revoke while her Apple account is still linked + configureApple(t.env); + let linkedAtExchange = -1; + const apple = appleFetch(appleSub, async () => { + linkedAtExchange = await t.db.account.count({ + where: { userId: alice.id, provider: 'apple' }, + }); + }); + globalThis.fetch = apple.fetchFn; + + const code = await t.requestCode(alice); + await t.callerFor(alice).user.deleteAccount({ code, appleAuthorizationCode: 'apple-code' }); + globalThis.fetch = originalFetch; + + assert.deepEqual(apple.calls, [ + 'https://appleid.apple.com/auth/token', + 'https://appleid.apple.com/auth/revoke', + ]); + assert.equal(linkedAtExchange, 1); + + // Anonymised, signed out everywhere, nothing to sign in with + const deleted = await t.db.user.findUniqueOrThrow({ where: { id: alice.id } }); + assert.equal(deleted.name, t.DELETED_USER_NAME); + assert.equal(deleted.email, null); + assert.equal(deleted.emailVerified, null); + assert.equal(deleted.image, null); + assert.ok(deleted.deletedAt); + assert.equal(await t.getMobileSession(appSession.token), null); + const [sessions, accounts, pushTokens, webPush, friendships, tokens] = await Promise.all([ + t.db.session.count({ where: { userId: alice.id } }), + t.db.account.count({ where: { userId: alice.id } }), + t.db.devicePushToken.count({ where: { userId: alice.id } }), + t.db.pushNotification.count({ where: { userId: alice.id } }), + t.db.friendship.count({ where: { OR: [{ userId: alice.id }, { friendId: alice.id }] } }), + t.db.verificationToken.count({ where: { identifier: alice.email! } }), + ]); + assert.deepEqual( + [sessions, accounts, pushTokens, webPush, friendships, tokens], + [0, 0, 0, 0, 0, 0], + ); + + // Her friends' balances, and the expenses behind them, are untouched + assert.deepEqual(await balancesOf(bob.id), bobBefore); + assert.deepEqual(await balancesOf(carol.id), carolBefore); + assert.equal(await t.db.expense.count({ where: { paidBy: { in: [alice.id, bob.id] } } }), 2); + + // The trip passes to Bob and she stays in it until settled; the empty group goes; she + // leaves Carol's group, where she owes nothing + const groups = await t.db.group.findMany({ + where: { id: { in: groupIds } }, + include: { groupUsers: { select: { userId: true } } }, + }); + const tripAfter = groups.find((g) => g.id === trip.id); + assert.equal(tripAfter?.userId, bob.id); + assert.ok(tripAfter?.groupUsers.some((member) => member.userId === alice.id)); + assert.equal( + groups.some((g) => g.id === solo.id), + false, + ); + assert.deepEqual( + groups.find((g) => g.id === book.id)?.groupUsers.map((member) => member.userId), + [carol.id], + ); + + // Bob still sees her balance, under the anonymous name + const bobFriends = await t.callerFor(bob).user.getBalances(); + const shown = bobFriends.balances.find((balance) => balance.friendId === alice.id); + assert.equal(shown?.friend.name, t.DELETED_USER_NAME); + assert.equal(shown?.friend.email, null); + + // But can't pick her for anything new + const bobPicks = await t.callerFor(bob).user.getFriends(); + assert.equal( + bobPicks.some((friend) => friend.id === alice.id), + false, + ); + await assert.rejects( + t.callerFor(carol).group.addMembers({ groupId: book.id, userIds: [alice.id] }), + withCode('BAD_REQUEST'), + ); + + // The same email signs up again as a new, empty account + const again = await t.signInWithEmail(alice.email!); + userIds.push(again.user.id); + assert.notEqual(again.user.id, alice.id); + const fresh = await t + .callerFor({ id: again.user.id, name: null, email: again.user.email ?? null }) + .user.getDeleteAccountInfo(); + assert.deepEqual(fresh.balances, []); + assert.deepEqual(fresh.groups, []); + assert.equal(fresh.hasAppleAccount, false); + } finally { + globalThis.fetch = originalFetch; + await t.db.group.deleteMany({ where: { id: { in: groupIds } } }); + await t.db.user.deleteMany({ where: { id: { in: userIds } } }); + await t.db.$disconnect(); + } + }, +); diff --git a/src/tests/addFlow.test.ts b/src/tests/addFlow.test.ts index 6b739ee..c365ce1 100644 --- a/src/tests/addFlow.test.ts +++ b/src/tests/addFlow.test.ts @@ -28,6 +28,7 @@ const createUser = (id: number): User => ({ name: `User ${id}`, email: `user-${id}@example.com`, emailVerified: null, + deletedAt: null, image: null, currency: 'USD', }); diff --git a/src/tests/appleRevoke.test.ts b/src/tests/appleRevoke.test.ts new file mode 100644 index 0000000..0cf2998 --- /dev/null +++ b/src/tests/appleRevoke.test.ts @@ -0,0 +1,249 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync } from 'node:crypto'; +import { describe, it } from 'node:test'; +import { UnsecuredJWT, decodeProtectedHeader, importSPKI, jwtVerify } from 'jose'; + +import { + type AppleRevokeConfig, + createAppleClientSecret, + getAppleRevokeConfig, + normalizeApplePrivateKey, + revokeAppleAuthorization, +} from '~/server/appleRevoke'; + +const { privateKey, publicKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' }); +const PRIVATE_KEY = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(); +const PUBLIC_KEY = publicKey.export({ type: 'spki', format: 'pem' }).toString(); + +const config: AppleRevokeConfig = { + teamId: 'TEAM123456', + keyId: 'KEY1234567', + privateKey: PRIVATE_KEY, + clientId: 'app.splitpro.ios', +}; + +const APPLE_SUB = '001234.abcdef.1234'; +const idToken = (sub: string) => new UnsecuredJWT({ sub }).encode(); + +type Call = { url: string; form: URLSearchParams }; + +/** A fetch that answers each Apple endpoint with the given response, and records the calls. */ +const mockFetch = (responses: Record Response | Promise>) => { + const calls: Call[] = []; + const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : String(input); + calls.push({ url, form: new URLSearchParams(String(init?.body)) }); + const respond = responses[url]; + assert.ok(respond, `Unexpected request to ${url}`); + return respond(); + }) as typeof fetch; + return { calls, fetchFn }; +}; + +const tokenResponse = (body: Record) => () => + new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } }); + +/** Runs without the expected error logs. */ +const quietly = async (run: () => Promise) => { + const original = console.error; + console.error = () => undefined; + try { + return await run(); + } finally { + console.error = original; + } +}; + +const TOKEN_URL = 'https://appleid.apple.com/auth/token'; +const REVOKE_URL = 'https://appleid.apple.com/auth/revoke'; + +void describe('createAppleClientSecret', () => { + void it('should sign an ES256 JWT with the key id, team and bundle id for Apple', async () => { + const now = Date.UTC(2026, 9, 21, 12); + const secret = await createAppleClientSecret(config, now); + + assert.deepEqual(decodeProtectedHeader(secret), { alg: 'ES256', kid: 'KEY1234567' }); + const { payload } = await jwtVerify(secret, await importSPKI(PUBLIC_KEY, 'ES256'), { + currentDate: new Date(now), + }); + assert.deepEqual(payload, { + iss: 'TEAM123456', + sub: 'app.splitpro.ios', + aud: 'https://appleid.apple.com', + iat: now / 1000, + exp: now / 1000 + 300, + }); + }); +}); + +void describe('getAppleRevokeConfig', () => { + void it('should read a private key with literal \\n as a PEM', () => { + const oneLine = PRIVATE_KEY.trim().replace(/\n/g, '\\n'); + + assert.equal(normalizeApplePrivateKey(oneLine), PRIVATE_KEY.trim()); + assert.equal( + getAppleRevokeConfig({ ...config, privateKey: oneLine })?.privateKey, + PRIVATE_KEY.trim(), + ); + }); + + void it('should be unconfigured when anything is missing', () => { + assert.equal(getAppleRevokeConfig({ ...config, teamId: undefined }), null); + assert.equal(getAppleRevokeConfig({ ...config, keyId: '' }), null); + assert.equal(getAppleRevokeConfig({ ...config, privateKey: undefined }), null); + assert.equal(getAppleRevokeConfig({ ...config, clientId: undefined }), null); + }); +}); + +void describe('revokeAppleAuthorization', () => { + void it('should exchange the code, then revoke the refresh token', async () => { + const { calls, fetchFn } = mockFetch({ + [TOKEN_URL]: tokenResponse({ + access_token: 'access', + refresh_token: 'refresh', + id_token: idToken(APPLE_SUB), + }), + [REVOKE_URL]: () => new Response(null, { status: 200 }), + }); + + const result = await revokeAppleAuthorization({ + config, + authorizationCode: 'auth-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }); + + assert.equal(result, 'revoked'); + assert.deepEqual( + calls.map((call) => call.url), + [TOKEN_URL, REVOKE_URL], + ); + const [exchange, revoke] = calls; + assert.equal(exchange?.form.get('client_id'), 'app.splitpro.ios'); + assert.equal(exchange?.form.get('code'), 'auth-code'); + assert.equal(exchange?.form.get('grant_type'), 'authorization_code'); + assert.equal(exchange?.form.has('redirect_uri'), false); + assert.ok(exchange?.form.get('client_secret')); + assert.equal(revoke?.form.get('client_id'), 'app.splitpro.ios'); + assert.equal(revoke?.form.get('client_secret'), exchange?.form.get('client_secret')); + assert.equal(revoke?.form.get('token'), 'refresh'); + assert.equal(revoke?.form.get('token_type_hint'), 'refresh_token'); + }); + + void it('should revoke the access token when there is no refresh token', async () => { + const { calls, fetchFn } = mockFetch({ + [TOKEN_URL]: tokenResponse({ access_token: 'access', id_token: idToken(APPLE_SUB) }), + [REVOKE_URL]: () => new Response(null, { status: 200 }), + }); + + const result = await revokeAppleAuthorization({ + config, + authorizationCode: 'auth-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }); + + assert.equal(result, 'revoked'); + assert.equal(calls[1]?.form.get('token'), 'access'); + assert.equal(calls[1]?.form.get('token_type_hint'), 'access_token'); + }); + + void it('should skip when Apple is not configured', async () => { + const { calls, fetchFn } = mockFetch({}); + + const result = await revokeAppleAuthorization({ + config: null, + authorizationCode: 'auth-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }); + + assert.equal(result, 'unconfigured'); + assert.equal(calls.length, 0); + }); + + void it('should not revoke a code for another Apple ID', async () => { + const { calls, fetchFn } = mockFetch({ + [TOKEN_URL]: tokenResponse({ refresh_token: 'refresh', id_token: idToken('someone-else') }), + }); + + const result = await quietly(() => + revokeAppleAuthorization({ + config, + authorizationCode: 'auth-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }), + ); + + assert.equal(result, 'mismatched'); + assert.deepEqual( + calls.map((call) => call.url), + [TOKEN_URL], + ); + }); + + void describe('Failures', () => { + void it('should report a rejected code without throwing', async () => { + const { calls, fetchFn } = mockFetch({ + [TOKEN_URL]: () => new Response('{"error":"invalid_grant"}', { status: 400 }), + }); + + const result = await quietly(() => + revokeAppleAuthorization({ + config, + authorizationCode: 'used-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }), + ); + + assert.equal(result, 'failed'); + assert.equal(calls.length, 1); + }); + + void it('should report a failed revoke without throwing', async () => { + const { fetchFn } = mockFetch({ + [TOKEN_URL]: tokenResponse({ refresh_token: 'refresh', id_token: idToken(APPLE_SUB) }), + [REVOKE_URL]: () => new Response('{"error":"invalid_client"}', { status: 400 }), + }); + + const result = await quietly(() => + revokeAppleAuthorization({ + config, + authorizationCode: 'auth-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }), + ); + + assert.equal(result, 'failed'); + }); + + void it('should report a network error or a bad key without throwing', async () => { + const { fetchFn } = mockFetch({ + [TOKEN_URL]: () => Promise.reject(new TypeError('fetch failed')), + }); + + const [offline, badKey] = await quietly(() => + Promise.all([ + revokeAppleAuthorization({ + config, + authorizationCode: 'auth-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }), + revokeAppleAuthorization({ + config: { ...config, privateKey: 'not a key' }, + authorizationCode: 'auth-code', + appleUserIds: [APPLE_SUB], + fetchFn, + }), + ]), + ); + + assert.equal(offline, 'failed'); + assert.equal(badKey, 'failed'); + }); + }); +}); diff --git a/src/tests/defaultSplit.test.ts b/src/tests/defaultSplit.test.ts index 1f6c80c..58a74b9 100644 --- a/src/tests/defaultSplit.test.ts +++ b/src/tests/defaultSplit.test.ts @@ -32,6 +32,7 @@ const user = (id: number, name: string): User => ({ name, email: `${name.toLowerCase()}@example.com`, emailVerified: null, + deletedAt: null, image: null, currency: 'INR', }); diff --git a/src/tests/editSnapshot.test.ts b/src/tests/editSnapshot.test.ts index 84f2b24..f47a3c3 100644 --- a/src/tests/editSnapshot.test.ts +++ b/src/tests/editSnapshot.test.ts @@ -19,6 +19,7 @@ const createUser = (id: number): User => ({ name: `User ${id}`, email: `user-${id}@example.com`, emailVerified: null, + deletedAt: null, image: null, currency: 'USD', }); diff --git a/src/tests/invites.test.ts b/src/tests/invites.test.ts index 2550409..a936794 100644 --- a/src/tests/invites.test.ts +++ b/src/tests/invites.test.ts @@ -10,6 +10,7 @@ const createUser = (id: number, name: string): User => ({ name, email: `${name.toLowerCase()}@example.com`, emailVerified: null, + deletedAt: null, image: null, currency: 'USD', }); diff --git a/src/tests/quickWins.test.ts b/src/tests/quickWins.test.ts index 17f22d9..a7f51e6 100644 --- a/src/tests/quickWins.test.ts +++ b/src/tests/quickWins.test.ts @@ -20,6 +20,7 @@ const me: User = { name: 'Me', email: 'me@example.com', emailVerified: null, + deletedAt: null, image: null, currency: 'INR', }; diff --git a/src/tests/recurrence.test.ts b/src/tests/recurrence.test.ts index 9344aa2..29de9cb 100644 --- a/src/tests/recurrence.test.ts +++ b/src/tests/recurrence.test.ts @@ -238,6 +238,7 @@ void describe('repeat in the add form', () => { name: 'Me', email: 'me@example.com', emailVerified: null, + deletedAt: null, image: null, currency: 'USD', }; diff --git a/src/tests/splitRoundTrip.test.ts b/src/tests/splitRoundTrip.test.ts index fa3e7b2..7e056d1 100644 --- a/src/tests/splitRoundTrip.test.ts +++ b/src/tests/splitRoundTrip.test.ts @@ -20,6 +20,7 @@ const createUser = (id: number): User => ({ name: `User ${id}`, email: `user-${id}@example.com`, emailVerified: null, + deletedAt: null, image: null, currency: 'USD', }); diff --git a/src/tests/splits.test.ts b/src/tests/splits.test.ts index 1fe1d7e..e9ca093 100644 --- a/src/tests/splits.test.ts +++ b/src/tests/splits.test.ts @@ -11,6 +11,7 @@ const createUser = (id: number): User => ({ name: `User ${id}`, email: `user-${id}@example.com`, emailVerified: null, + deletedAt: null, image: null, currency: 'USD', }); From d4d9f99ed143bffac4b6b0c0730d3b2e3d43ce06 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Wed, 21 Oct 2026 20:20:55 +1100 Subject: [PATCH 3/9] fix: require exp and sub on Apple identity tokens --- src/server/mobileAuth.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server/mobileAuth.ts b/src/server/mobileAuth.ts index a74236b..80b9f99 100644 --- a/src/server/mobileAuth.ts +++ b/src/server/mobileAuth.ts @@ -397,6 +397,8 @@ const verifyAppleIdentityToken = async (identityToken: string, audience: string[ const { payload } = await jwtVerify(identityToken, appleJwks, { issuer: APPLE_ISSUER, audience, + algorithms: ['RS256'], + requiredClaims: ['exp', 'sub'], }); return payload; } catch (error) { From 6f9e0d05ab66d5d662c3e4b8bf9e70603766c2f5 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Wed, 21 Oct 2026 20:55:14 +1100 Subject: [PATCH 4/9] feat: delete account on the website, leave deleted users out of new expenses --- src/components/Account/DeleteAccount.tsx | 347 +++++++++++++++++++++++ src/pages/account.tsx | 4 +- src/pages/auth/signin.tsx | 6 + src/store/addStore.ts | 20 +- src/tests/defaultSplit.test.ts | 151 ++++++++++ src/utils/defaultSplit.ts | 13 +- 6 files changed, 532 insertions(+), 9 deletions(-) create mode 100644 src/components/Account/DeleteAccount.tsx diff --git a/src/components/Account/DeleteAccount.tsx b/src/components/Account/DeleteAccount.tsx new file mode 100644 index 0000000..d12614a --- /dev/null +++ b/src/components/Account/DeleteAccount.tsx @@ -0,0 +1,347 @@ +import { TRPCClientError } from '@trpc/client'; +import { REGEXP_ONLY_DIGITS_AND_CHARS } from 'input-otp'; +import { AlertTriangle, Mail, Receipt, User, UserX } from 'lucide-react'; +import { signOut } from 'next-auth/react'; +import React, { useEffect, useState } from 'react'; +import { toast } from 'sonner'; + +import { cn } from '~/lib/utils'; +import { type AppRouter } from '~/server/api/root'; +import { api } from '~/utils/api'; +import { toUIString } from '~/utils/numbers'; + +import { Button } from '../ui/button'; +import { AppDrawer } from '../ui/drawer'; +import { InputOTP, InputOTPGroup, InputOTPSlot } from '../ui/input-otp'; +import { Skeleton } from '../ui/skeleton'; + +const CODE_LENGTH = 6; + +const isApiError = (error: unknown): error is TRPCClientError => + error instanceof TRPCClientError; + +/** When the server asked to wait (too many codes or guesses), from the error's data. */ +const lockedUntil = (error: unknown) => { + const seconds = + isApiError(error) && 'TOO_MANY_REQUESTS' === error.data?.code + ? error.data.retryAfterSeconds + : null; + return seconds ? Date.now() + seconds * 1000 : null; +}; + +const errorMessage = (error: unknown) => + isApiError(error) ? error.message : 'Something went wrong. Please try again.'; + +/** Whole seconds left until `until` (a timestamp), ticking every second; 0 once it has passed. */ +const useSecondsLeft = (until: number | null) => { + const [now, setNow] = useState(Date.now); + + useEffect(() => { + if (null === until) { + return; + } + + setNow(Date.now()); + const interval = setInterval(() => { + const current = Date.now(); + setNow(current); + if (current >= until) { + clearInterval(interval); + } + }, 1000); + + return () => clearInterval(interval); + }, [until]); + + return null === until ? 0 : Math.max(0, Math.ceil((until - now) / 1000)); +}; + +const formatDuration = (seconds: number) => + `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; + +const Section: React.FC<{ title: string; footer: string; children: React.ReactNode }> = ({ + title, + footer, + children, +}) => ( +
+

{title}

+
+ {children} +
+

{footer}

+
+); + +/** + * Deletes the account like the app does: what happens first, then the code we email. The web has + * no Sign in with Apple, so an account linked to Apple is deleted without revoking it. + */ +export const DeleteAccount: React.FC = () => { + const [open, setOpen] = useState(false); + const [step, setStep] = useState<'explain' | 'code'>('explain'); + const [email, setEmail] = useState(null); + const [code, setCode] = useState(''); + const [error, setError] = useState(null); + const [resendLockedUntil, setResendLockedUntil] = useState(null); + const [verifyLockedUntil, setVerifyLockedUntil] = useState(null); + const [signingOut, setSigningOut] = useState(false); + const resendSecondsLeft = useSecondsLeft(resendLockedUntil); + const verifySecondsLeft = useSecondsLeft(verifyLockedUntil); + + const infoQuery = api.user.getDeleteAccountInfo.useQuery(undefined, { enabled: open }); + const requestCode = api.user.requestDeleteAccountCode.useMutation(); + const deleteAccount = api.user.deleteAccount.useMutation(); + + const info = infoQuery.data; + const isLocked = 0 < verifySecondsLeft; + const deleting = deleteAccount.isLoading || signingOut; + + const onOpenChange = (next: boolean) => { + if (deleting) { + return; + } + + setOpen(next); + if (!next) { + setStep('explain'); + setCode(''); + setError(null); + } + }; + + const sendCode = async () => { + try { + const sent = await requestCode.mutateAsync(); + setEmail(sent.email); + setCode(''); + setError(null); + setVerifyLockedUntil(null); + setResendLockedUntil(Date.now() + sent.resendAfterSeconds * 1000); + if ('code' === step) { + toast.success('We sent you a new code'); + } + setStep('code'); + } catch (sendError) { + const until = lockedUntil(sendError); + if (until) { + setResendLockedUntil(until); + } + // A code sent a moment ago still works, so go on to enter it + if (until && 'explain' === step && info?.email) { + setEmail(info.email); + setStep('code'); + return; + } + toast.error(errorMessage(sendError)); + } + }; + + const confirmDelete = async () => { + if (deleting || isLocked) { + return; + } + + if (CODE_LENGTH !== code.length) { + setError(`Enter the ${CODE_LENGTH}-character code`); + return; + } + + setError(null); + + try { + await deleteAccount.mutateAsync({ code }); + } catch (deleteError) { + const until = lockedUntil(deleteError); + if (until) { + // Too many guesses burn the code + setCode(''); + setVerifyLockedUntil(until); + } else { + setError(errorMessage(deleteError)); + } + return; + } + + // The server already ended every session; this clears the cookie and leaves the app + setSigningOut(true); + await signOut({ callbackUrl: '/auth/signin?deleted=1' }); + }; + + const status = isLocked + ? `Too many attempts. Try again in ${formatDuration(verifySecondsLeft)}.` + : error; + + return ( + + Delete account + + } + open={open} + onOpenChange={onOpenChange} + onClose={() => onOpenChange(false)} + leftAction="Cancel" + title="Delete account" + className="h-[85vh]" + dismissible={!deleting} + > + {'code' === step ? ( +
+
+ +

Check your email

+

+ Enter the code we sent to {email ?? ''} to delete your account. +

+
+ +
+ { + setCode(value.toUpperCase()); + setError(null); + }} + onComplete={() => setError(null)} + > + + {Array.from({ length: CODE_LENGTH }, (_, index) => ( + + ))} + + + {status ? ( +

+ {status} +

+ ) : null} +
+ +
+ + + +
+
+ ) : ( +
+
+ +

Delete your SplitPro account?

+
+ +
+
+ +

Your name and email are removed, and you're signed out on every device.

+
+
+ +

Expenses you share stay for the others, under "Deleted user".

+
+
+ +

This can't be undone.

+
+
+ + {!info ? : null} + + {info && 0 < info.balances.length ? ( +
+ {info.balances.map((balance) => ( +
+

{0 < balance.amount ? 'You get' : 'You owe'}

+

+ {balance.currency.toUpperCase()} {toUIString(balance.amount)} +

+
+ ))} +
+ ) : null} + + {info && 0 < info.groups.length ? ( +
+ {info.groups.map((group) => ( +
+

{group.name}

+

+ {group.nextOwnerName + ? `Goes to ${group.nextOwnerName}` + : group.willBeDeleted + ? 'Will be deleted' + : null} +

+
+ ))} +
+ ) : null} + +
+ +

+ {info?.email + ? `We'll email a code to ${info.email} to confirm.` + : "We'll email you a code to confirm."} +

+
+
+ )} +
+ ); +}; diff --git a/src/pages/account.tsx b/src/pages/account.tsx index 40d1c7c..bed3f2b 100644 --- a/src/pages/account.tsx +++ b/src/pages/account.tsx @@ -15,6 +15,7 @@ import { } from 'lucide-react'; import { signOut } from 'next-auth/react'; import { AppDrawer } from '~/components/ui/drawer'; +import { DeleteAccount } from '~/components/Account/DeleteAccount'; import { SubmitFeedback } from '~/components/Account/SubmitFeedback'; import { UpdateDetails } from '~/components/Account/UpdateDetails'; import { api } from '~/utils/api'; @@ -205,7 +206,7 @@ const AccountPage: NextPageWithUser = ({ user }) => { -
+
+
diff --git a/src/pages/auth/signin.tsx b/src/pages/auth/signin.tsx index c9030c5..2a14e6b 100644 --- a/src/pages/auth/signin.tsx +++ b/src/pages/auth/signin.tsx @@ -105,6 +105,7 @@ const Home: NextPage<{ feedbackEmail: string; providers: ClientSafeProvider[] }> const [emailError, setEmailError] = useState(''); const router = useRouter(); const queryError = 'string' === typeof router.query.error ? router.query.error : ''; + const accountDeleted = '1' === router.query.deleted; const emailForm = useForm>({ resolver: zodResolver(emailSchema), @@ -154,6 +155,11 @@ const Home: NextPage<{ feedbackEmail: string; providers: ClientSafeProvider[] }>

SplitPro

+ {accountDeleted ? ( +

+ Your account was deleted. +

+ ) : null} {providers .filter((provider) => provider.id !== 'email') .map((provider) => ( diff --git a/src/store/addStore.ts b/src/store/addStore.ts index 1185064..bcec887 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -151,13 +151,20 @@ export const matchesEditSnapshot = ( ); }; -/** The participants to save: the stored ones while the expense is unchanged, else the form's. */ +/** + * The participants to save: the stored ones while the expense is unchanged, else the form's. + * Someone who deleted their account is only saved with a share or as the payer: a row with nothing + * would only record them in a new expense, and keep a repeating one from going on. + */ export const getParticipantsToSave = ( state: Pick< AddExpenseState, 'editSnapshot' | 'amount' | 'currency' | 'paidBy' | 'splitType' | 'participants' >, -) => (matchesEditSnapshot(state) ? state.editSnapshot.participants : state.participants); +) => + (matchesEditSnapshot(state) ? state.editSnapshot.participants : state.participants).filter( + (p) => !p.deletedAt || 0 !== toInteger(p.amount ?? 0) || p.id === state.paidBy?.id, + ); /** Shows the stored cents again when a change returns the form to the expense as it was opened. */ const withEditSnapshot = >(state: AddExpenseState, next: T) => { @@ -435,6 +442,12 @@ export const useAddExpenseStore = create()((set) => ({ s.pendingInviteSplit, ); const split = s.splitTouched ? manualSplit : getGroupDefaultSplit(group); + // Members who deleted their account start out of the split, unless it was ticked by hand + const leftOutIds = participants + .filter( + (p) => p.deletedAt && !(s.splitTouched && undefined !== manualSplit?.shares[p.id]), + ) + .map((p) => p.id); if (!split) { return { group, @@ -443,7 +456,7 @@ export const useAddExpenseStore = create()((set) => ({ pendingInviteSplit, ...splitWithParticipants( state, - participants.map((p) => ({ ...p, splitShare: 1 })), + participants.map((p) => ({ ...p, splitShare: leftOutIds.includes(p.id) ? 0 : 1 })), SplitType.EQUAL, ), }; @@ -452,6 +465,7 @@ export const useAddExpenseStore = create()((set) => ({ const resolved = resolveDefaultSplit( split, participants.map((p) => p.id), + leftOutIds, ); return { group, diff --git a/src/tests/defaultSplit.test.ts b/src/tests/defaultSplit.test.ts index 58a74b9..c8b5d2d 100644 --- a/src/tests/defaultSplit.test.ts +++ b/src/tests/defaultSplit.test.ts @@ -109,6 +109,17 @@ void describe('resolveDefaultSplit', () => { ); }); + void it('gives nothing to those left out, whatever the split has for them', () => { + assert.deepEqual( + resolveDefaultSplit({ splitType: 'SHARE', shares: { 1: 2, 2: 1 } }, [1, 2, 3], [2, 3]), + { splitType: 'SHARE', shares: [2, 0, 0], addedIds: [], outdated: false }, + ); + assert.deepEqual( + resolveDefaultSplit({ splitType: 'PERCENTAGE', shares: { 1: 50, 2: 50 } }, [1, 2, 3], [2]), + { splitType: 'EQUAL', shares: [1, 0, 1], addedIds: [], outdated: true }, + ); + }); + void it('splits equally when no one left in the split has a share', () => { const resolved = resolveDefaultSplit({ splitType: 'SHARE', shares: { 1: 0, 2: 3 } }, [1]); assert.equal(resolved.splitType, SplitType.EQUAL); @@ -339,6 +350,146 @@ void describe('group default split in the add flow', () => { }); }); +void describe('group members who deleted their account', () => { + const gone = { ...user(4, 'Deleted user'), email: null, deletedAt: new Date(2026, 9, 1) }; + const saved = () => getParticipantsToSave(state()).map((p) => [p.id, p.amount]); + const tick = (id: number) => { + const participant = state().participants.find((p) => p.id === id); + state().actions.addOrUpdateParticipant({ ...participant!, splitShare: 1 }); + }; + + void it('leaves them out of the split, but in the form, without a default', () => { + start(); + state().actions.selectGroup(group(), [me, alex, gone]); + state().actions.setAmount(100); + + assert.equal(state().splitType, SplitType.EQUAL); + assert.deepEqual(amounts(), [ + [1, 50], + [2, -50], + [4, 0], + ]); + assert.deepEqual(saved(), [ + [1, 50], + [2, -50], + ]); + }); + + void it('leaves them out of an equal or share default that still has them', () => { + for (const splitType of [SplitType.EQUAL, SplitType.SHARE]) { + start(); + state().actions.selectGroup(group({ splitType, shares: { 1: 1, 2: 1, 4: 2 } }), [ + me, + alex, + gone, + ]); + state().actions.setAmount(100); + + assert.equal(state().splitType, splitType); + assert.deepEqual(amounts(), [ + [1, 50], + [2, -50], + [4, 0], + ]); + assert.deepEqual(state().defaultSplitHint?.addedIds, []); + } + }); + + void it('splits a percentage default that gave them a part equally among the others', () => { + start(); + state().actions.selectGroup( + group({ splitType: SplitType.PERCENTAGE, shares: { 1: 50, 2: 25, 4: 25 } }), + [me, alex, gone], + ); + state().actions.setAmount(100); + + assert.equal(state().splitType, SplitType.EQUAL); + assert.deepEqual(amounts(), [ + [1, 50], + [2, -50], + [4, 0], + ]); + assert.equal(state().defaultSplitHint?.outdated, true); + }); + + void it('keeps a percentage default that gave them nothing', () => { + start(); + state().actions.selectGroup( + group({ splitType: SplitType.PERCENTAGE, shares: { 1: 60, 2: 40, 4: 0 } }), + [me, alex, gone], + ); + state().actions.setAmount(100); + + assert.equal(state().splitType, SplitType.PERCENTAGE); + assert.deepEqual(saved(), [ + [1, 40], + [2, -40], + ]); + }); + + void it('includes them once ticked by hand, also when the group is selected again', () => { + start(); + state().actions.selectGroup(group(), [me, alex, gone]); + state().actions.setAmount(90); + tick(4); + + const expected = [ + [1, 60], + [2, -30], + [4, -30], + ]; + assert.deepEqual(saved(), expected); + + removeGroup(); + state().actions.selectGroup(group(), [me, alex, gone]); + assert.deepEqual(saved(), expected); + }); + + void it('keeps them in an edit of an expense they have a share in', () => { + start(); + useAddExpenseStore.setState( + getEditFormState({ + amount: 1000, + splitType: SplitType.EQUAL, + category: 'general', + currency: 'EUR', + name: 'Dinner', + expenseDate: new Date(2026, 8, 30), + fileKey: null, + group: group(), + paidByUser: me, + expenseParticipants: [ + { amount: 500, user: me }, + { amount: -500, user: gone }, + ], + }), + ); + + assert.deepEqual(saved(), [ + [1, 5], + [4, -5], + ]); + state().actions.setAmount(20); + assert.deepEqual(saved(), [ + [1, 10], + [4, -10], + ]); + }); + + void it('keeps them when they paid', () => { + start(); + state().actions.selectGroup(group(), [me, alex, gone]); + state().actions.setAmount(100); + state().actions.setPaidBy(gone); + + assert.deepEqual(saved(), [ + [1, -50], + [2, -50], + [4, 100], + ]); + }); +}); + /** A small seeded generator, so the property tests are random but repeatable. */ const random = (seed: number) => () => { seed = (seed + 0x6d2b79f5) | 0; diff --git a/src/utils/defaultSplit.ts b/src/utils/defaultSplit.ts index 7ecb3b2..836824b 100644 --- a/src/utils/defaultSplit.ts +++ b/src/utils/defaultSplit.ts @@ -163,28 +163,31 @@ export type ResolvedSplit = { /** * Fits a stored split to who takes part now. Someone it doesn't know joins an equal split or gets * 1 share. Percentages can't take anyone new without changing the others, so if they don't add - * up to 100% among the participants, everyone is split equally. + * up to 100% among the participants, everyone is split equally. `leftOutIds` (members who deleted + * their account) get nothing, whatever the split has for them. */ export const resolveDefaultSplit = ( split: DefaultSplit, participantIds: number[], + leftOutIds: number[] = [], ): ResolvedSplit => { + const isLeftOut = (id: number) => leftOutIds.includes(id); const equally: ResolvedSplit = { splitType: SplitType.EQUAL, - shares: participantIds.map(() => 1), + shares: participantIds.map((id) => (isLeftOut(id) ? 0 : 1)), addedIds: [], outdated: true, }; - const known = (id: number) => undefined !== split.shares[id]; + const known = (id: number) => isLeftOut(id) || undefined !== split.shares[id]; if (SplitType.PERCENTAGE === split.splitType) { - const shares = participantIds.map((id) => split.shares[id] ?? 0); + const shares = participantIds.map((id) => (isLeftOut(id) ? 0 : split.shares[id] ?? 0)); return participantIds.every(known) && 10_000 === sumHundredths(shares) ? { splitType: split.splitType, shares, addedIds: [], outdated: false } : equally; } - const shares = participantIds.map((id) => split.shares[id] ?? 1); + const shares = participantIds.map((id) => (isLeftOut(id) ? 0 : split.shares[id] ?? 1)); if (0 === sumHundredths(shares)) { return equally; } From ad0d0f3ddbc6dfe1b914701148a534e57685c4f2 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 31 Oct 2026 15:00:26 +1100 Subject: [PATCH 5/9] fix: never give a deleted account a session A sign-in that found the user just before the account was deleted could create its session (or link Google/Apple) after the deletion removed them, and bearer sessions never looked at deletedAt. Sessions, sign-in links and the email sign-in's update now lock the user row FOR SHARE and require deletedAt IS NULL, so they either commit before the deletion (which then removes them) or wait for it and are refused. Adapter lookups return no deleted user, and a session of a deleted account is deleted when used, by the app (bearer) and the web (cookie). --- src/server/auth.ts | 58 ++++++++- src/server/mobileAuth.ts | 12 +- src/tests/accountDeletion.integration.test.ts | 118 +++++++++++++++++- 3 files changed, 182 insertions(+), 6 deletions(-) diff --git a/src/server/auth.ts b/src/server/auth.ts index 789baab..e1ae66e 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -1,4 +1,5 @@ import { PrismaAdapter } from '@next-auth/prisma-adapter'; +import { type Prisma } from '@prisma/client'; import { randomInt } from 'crypto'; import { type GetServerSidePropsContext } from 'next'; import { @@ -8,7 +9,7 @@ import { type Session, type User, } from 'next-auth'; -import { Adapter, AdapterUser } from 'next-auth/adapters'; +import { Adapter, AdapterAccount, AdapterSession, AdapterUser } from 'next-auth/adapters'; import DiscordProvider from 'next-auth/providers/discord'; import GoogleProvider from 'next-auth/providers/google'; import EmailProvider from 'next-auth/providers/email'; @@ -150,11 +151,66 @@ declare module 'next-auth' { } } +/** Thrown when signing in would give a session to, or link a sign-in to, a deleted account. */ +export class DeletedAccountError extends Error { + constructor() { + super('This account has been deleted'); + } +} + +/** + * Runs `write` while the account is locked against deletion, which locks it FOR UPDATE before it + * removes sessions and sign-in methods. So a sign-in that looked the user up just before the + * deletion either commits first, and the deletion removes what it wrote, or waits and is refused. + */ +const writeForLiveUser = (userId: number, write: (tx: Prisma.TransactionClient) => Promise) => + db.$transaction(async (tx) => { + const live = await tx.$queryRaw>` + SELECT id FROM "User" WHERE id = ${userId} AND "deletedAt" IS NULL FOR SHARE`; + + if (0 === live.length) { + throw new DeletedAccountError(); + } + + return write(tx); + }); + +// Adapter users are our User rows, typed without our columns +const isDeleted = (user: AdapterUser) => + !!(user as AdapterUser & { deletedAt: Date | null }).deletedAt; +const liveUser = (user: AdapterUser | null) => (user && isDeleted(user) ? null : user); + const SplitProPrismaAdapter = (...args: Parameters): Adapter => { const prismaAdapter = PrismaAdapter(...args); return { ...prismaAdapter, + // A deleted account keeps its row for what others share with it, but can never sign in again + getUser: async (id) => liveUser(await prismaAdapter.getUser!(id)), + getUserByEmail: async (email) => liveUser(await prismaAdapter.getUserByEmail!(email)), + getUserByAccount: async (account) => liveUser(await prismaAdapter.getUserByAccount!(account)), + getSessionAndUser: async (sessionToken) => { + const userAndSession = await prismaAdapter.getSessionAndUser!(sessionToken); + + if (!userAndSession || !isDeleted(userAndSession.user)) { + return userAndSession; + } + + await db.session.deleteMany({ where: { sessionToken } }); + return null; + }, + updateUser: ({ id, ...data }) => + writeForLiveUser(Number(id), (tx) => + tx.user.update({ where: { id: Number(id) }, data }), + ) as unknown as Promise, + linkAccount: (account: AdapterAccount) => + writeForLiveUser(Number(account.userId), (tx) => + tx.account.create({ data: { ...account, userId: Number(account.userId) } }), + ), + createSession: (session) => + writeForLiveUser(Number(session.userId), (tx) => + tx.session.create({ data: { ...session, userId: Number(session.userId) } }), + ) as unknown as Promise, createUser: async (user: Omit): Promise => { // next-auth types this via the optional @auth/core peer, which resolves to `any` without it const prismaCreateUser = prismaAdapter.createUser as diff --git a/src/server/mobileAuth.ts b/src/server/mobileAuth.ts index 80b9f99..8c5d5dc 100644 --- a/src/server/mobileAuth.ts +++ b/src/server/mobileAuth.ts @@ -8,6 +8,7 @@ import { z } from 'zod'; import { env } from '~/env'; import { + DeletedAccountError, EMAIL_AUTH_LIMITS, EMAIL_TOKEN_MAX_AGE_SECONDS, assertEmailSignInAllowed, @@ -183,7 +184,8 @@ const completeSignIn = async (user: DbUser, account: Account, isNewUser: boolean /** * The app session for a bearer token, with the id of its Session row (push tokens are linked to - * it). An expired session is deleted, which also removes the push tokens it registered. + * it). An expired session, or one of a deleted account, is deleted, which also removes the push + * tokens it registered. */ export const findMobileSession = async ( sessionToken: string | null, @@ -198,7 +200,7 @@ export const findMobileSession = async ( return null; } - if (session.expires.getTime() < Date.now()) { + if (session.expires.getTime() < Date.now() || session.user.deletedAt) { await db.session.deleteMany({ where: { sessionToken } }); return null; } @@ -597,7 +599,11 @@ export const mobileAuthRoute = try { await handler(req, res); - } catch (error) { + } catch (caught) { + // Signing in to an account deleted meanwhile, refused by the adapter + const error = + caught instanceof DeletedAccountError ? new MobileAuthError(401, caught.message) : caught; + if (!(error instanceof MobileAuthError)) { console.error('Mobile auth error', error); res.status(500).json({ error: 'Something went wrong' }); diff --git a/src/tests/accountDeletion.integration.test.ts b/src/tests/accountDeletion.integration.test.ts index eba9d11..6a04885 100644 --- a/src/tests/accountDeletion.integration.test.ts +++ b/src/tests/accountDeletion.integration.test.ts @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { generateKeyPairSync } from 'node:crypto'; import { test } from 'node:test'; import { UnsecuredJWT } from 'jose'; +import { type AdapterAccount } from 'next-auth/adapters'; const databaseUrl = process.env.TEST_DATABASE_URL; @@ -16,9 +17,9 @@ const setup = async () => { { SplitType }, { db }, { env }, - { authAdapter, generateEmailToken }, + { DeletedAccountError, authAdapter, authOptions, generateEmailToken }, mobileAuth, - { hashVerificationToken }, + { generateOpaqueToken, hashVerificationToken }, { appRouter }, accountDeletion, { resetRateLimit }, @@ -90,6 +91,10 @@ const setup = async () => { ...accountDeletion, hashVerificationToken, generateEmailToken, + generateOpaqueToken, + DeletedAccountError, + authAdapter, + authOptions, resetRateLimit, emailOf, createUser, @@ -489,3 +494,112 @@ void test( } }, ); + +void test( + 'account deletion: a deleted account gets no session, whenever the sign-in started', + { skip: !databaseUrl }, + async () => { + const t = await setup(); + const frank = await t.createUser('Frank'); + const userIds = [frank.id]; + const { events } = t.authOptions; + const isDeletedAccount = (error: unknown) => { + assert.ok(error instanceof t.DeletedAccountError, String(error)); + return true; + }; + + try { + // The deletion commits after the sign-in found the account, before it creates the session + const code = await t.requestCode(frank); + t.authOptions.events = { + ...events, + signIn: async () => { + await t.callerFor(frank).user.deleteAccount({ code }); + }, + }; + await assert.rejects(t.signInWithEmail(frank.email!), isDeletedAccount); + t.authOptions.events = events; + assert.ok((await t.db.user.findUniqueOrThrow({ where: { id: frank.id } })).deletedAt); + assert.equal(await t.db.session.count({ where: { userId: frank.id } }), 0); + + // Which the app's sign-in routes answer as a 401 + const response = { status: 0, body: undefined as unknown }; + const res = { + setHeader: () => res, + status: (status: number) => { + response.status = status; + return res; + }, + json: (body: unknown) => { + response.body = body; + return res; + }, + }; + await t.mobileAuthRoute('GET', () => Promise.reject(new t.DeletedAccountError()))( + { method: 'GET', headers: {} } as never, + res as never, + ); + assert.deepEqual(response, { status: 401, body: { error: 'This account has been deleted' } }); + + // Google and Apple sign-ins found it the same way: they can't link to it or sign in to it + const sessionToken = t.generateOpaqueToken(); + await assert.rejects( + Promise.resolve( + t.authAdapter.createSession!({ + sessionToken, + userId: String(frank.id), + expires: new Date(Date.now() + 60_000), + }), + ), + isDeletedAccount, + ); + const google = { + userId: String(frank.id), + type: 'oauth' as const, + provider: 'google', + providerAccountId: `g-${frank.id}`, + }; + // Typed via next-auth's optional @auth/core peer, which resolves to `any` without it + const linkAccount = t.authAdapter.linkAccount as ( + account: AdapterAccount, + ) => Promise; + await assert.rejects(linkAccount(google), isDeletedAccount); + await assert.rejects( + Promise.resolve( + t.authAdapter.updateUser!({ id: String(frank.id), emailVerified: new Date() }), + ), + isDeletedAccount, + ); + assert.equal(await t.db.account.count({ where: { userId: frank.id } }), 0); + + // Nor is it found by a sign-in method or session somehow left behind, which are removed + await t.db.account.create({ data: { ...google, userId: frank.id } }); + assert.equal( + await t.authAdapter.getUserByAccount!({ + provider: 'google', + providerAccountId: `g-${frank.id}`, + }), + null, + ); + assert.equal(await t.authAdapter.getUser!(frank.id as unknown as string), null); + const [appSession, webSession] = await Promise.all( + [0, 1].map(() => + t.db.session.create({ + data: { + sessionToken: t.generateOpaqueToken(), + userId: frank.id, + expires: new Date(Date.now() + 60_000), + }, + }), + ), + ); + assert.equal(await t.getMobileSession(appSession!.sessionToken), null); + assert.equal(await t.authAdapter.getSessionAndUser!(webSession!.sessionToken), null); + assert.equal(await t.db.session.count({ where: { userId: frank.id } }), 0); + } finally { + t.authOptions.events = events; + await t.db.user.deleteMany({ where: { id: { in: userIds } } }); + await t.db.$disconnect(); + } + }, +); From f68839c92151014ea0bb8846fa506275d92cddaa Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 31 Oct 2026 15:02:14 +1100 Subject: [PATCH 6/9] fix: never pass a group to a member deleted at the same time The next owner was picked from an unlocked read, so when two members deleted their accounts at once a group could pass to the other one, deleted, even with a live member left. The deletion now locks its account together with the expected next owners, in id order so two deletions needing each other's account wait instead of deadlocking, then locks its groups and picks each next owner with FOR SHARE on the member and their membership, re-checking deletedAt after any wait. --- src/server/api/services/accountDeletion.ts | 56 ++++++++--- src/server/api/services/recurringService.ts | 19 +++- src/tests/accountDeletion.integration.test.ts | 96 +++++++++++++++++++ 3 files changed, 157 insertions(+), 14 deletions(-) diff --git a/src/server/api/services/accountDeletion.ts b/src/server/api/services/accountDeletion.ts index 0c90e57..e71eba6 100644 --- a/src/server/api/services/accountDeletion.ts +++ b/src/server/api/services/accountDeletion.ts @@ -253,23 +253,53 @@ const revokeAppleSignIn = async (userId: number, authorizationCode: string | und }); }; +/** + * Who the groups the account owns would pass to, as things stand: locked with the account, before + * anything else, so two members deleting at once lock each other's accounts in the same order. + */ +const findNextOwners = async (tx: Prisma.TransactionClient, userId: number) => + ( + await tx.group.findMany({ + where: { userId }, + select: { + groupUsers: { + where: { userId: { not: userId }, user: { deletedAt: null } }, + orderBy: { userId: 'asc' }, + take: 1, + select: { userId: true }, + }, + }, + }) + ).flatMap((group) => group.groupUsers.map((member) => member.userId)); + +/** + * The member who has been around longest (lowest id) and still has an account, locked with their + * membership so neither goes before the handover commits. One being deleted meanwhile is waited + * for, then passed over. + */ +const lockNextOwner = async (tx: Prisma.TransactionClient, groupId: number, userId: number) => { + const [nextOwner] = await tx.$queryRaw>` + SELECT gu."userId" FROM "GroupUser" gu JOIN "User" u ON u.id = gu."userId" + WHERE gu."groupId" = ${groupId} AND gu."userId" <> ${userId} AND u."deletedAt" IS NULL + ORDER BY gu."userId" LIMIT 1 FOR SHARE`; + return nextOwner?.userId; +}; + /** * Groups the account created pass to the member who has been around longest (lowest id). A group * nobody else is in any more goes, like deleting it would, if it's settled. Returns the deleted * groups with their recurring expenses, whose creators should be told. */ const handOverGroups = async (tx: Prisma.TransactionClient, userId: number) => { + // Read once the account is locked, so this includes any a member deleted meanwhile passed to it, + // and no more can be. Locked, so none is deleted meanwhile either. + const groupIds = await tx.$queryRaw>` + SELECT id FROM "Group" WHERE "userId" = ${userId} ORDER BY id FOR NO KEY UPDATE`; const groups = await tx.group.findMany({ - where: { userId }, + where: { id: { in: groupIds.map((group) => group.id) } }, select: { id: true, name: true, - groupUsers: { - where: { userId: { not: userId }, user: { deletedAt: null } }, - orderBy: { userId: 'asc' }, - take: 1, - select: { userId: true }, - }, groupBalances: { where: { amount: { not: 0 } }, take: 1, select: { amount: true } }, recurringExpenses: { where: { status: { not: RecurringStatus.STOPPED }, createdById: { not: userId } }, @@ -284,10 +314,10 @@ const handOverGroups = async (tx: Prisma.TransactionClient, userId: number) => { const deleted: typeof groups = []; for (const group of groups) { - const nextOwner = group.groupUsers[0]; + const nextOwnerId = await lockNextOwner(tx, group.id, userId); - if (nextOwner) { - await tx.group.update({ where: { id: group.id }, data: { userId: nextOwner.userId } }); + if (undefined !== nextOwnerId) { + await tx.group.update({ where: { id: group.id }, data: { userId: nextOwnerId } }); } else if (0 === group.groupBalances.length) { await tx.group.delete({ where: { id: group.id } }); deleted.push(group); @@ -323,7 +353,11 @@ const anonymiseAccount = async ( userId: number, email: string | null, ) => { - const pausedIds = await lockAndPrepareAccountRemoval(tx, userId); + const pausedIds = await lockAndPrepareAccountRemoval( + tx, + userId, + await findNextOwners(tx, userId), + ); const deletedGroups = await handOverGroups(tx, userId); await leaveSettledGroups(tx, userId); diff --git a/src/server/api/services/recurringService.ts b/src/server/api/services/recurringService.ts index 0c2dd85..3070c02 100644 --- a/src/server/api/services/recurringService.ts +++ b/src/server/api/services/recurringService.ts @@ -557,14 +557,27 @@ export async function prepareAccountDeletion(tx: Prisma.TransactionClient, userI /** * Locks what removing or anonymising the account touches and stops or pauses its rules, in the - * transaction that does it. Returns the paused rules, whose creators should be told. + * transaction that does it. `otherUserIds` are accounts the removal needs to stay as they are + * until it commits (e.g. who its groups pass to); they are locked against their own removal. + * Returns the paused rules, whose creators should be told. */ -export async function lockAndPrepareAccountRemoval(tx: Prisma.TransactionClient, userId: number) { +export async function lockAndPrepareAccountRemoval( + tx: Prisma.TransactionClient, + userId: number, + otherUserIds: number[] = [], +) { // Every rule it can reach, then the account, then their rules again. An edit adding them to a // rule either committed before the account was locked, and the second look finds it, or can't // add them. await lockRulesOfAccount(tx, userId); - await tx.$queryRaw`SELECT id FROM "User" WHERE id = ${userId} FOR UPDATE`; + // The accounts in id order, so two removals that each need the other's account wait for each + // other instead of deadlocking + const userIds = [...new Set([userId, ...otherUserIds])].sort((a, b) => a - b); + for (const id of userIds) { + await (id === userId + ? tx.$queryRaw`SELECT id FROM "User" WHERE id = ${id} FOR UPDATE` + : tx.$queryRaw`SELECT id FROM "User" WHERE id = ${id} FOR SHARE`); + } return prepareAccountDeletion(tx, userId); } diff --git a/src/tests/accountDeletion.integration.test.ts b/src/tests/accountDeletion.integration.test.ts index 6a04885..3cd1b49 100644 --- a/src/tests/accountDeletion.integration.test.ts +++ b/src/tests/accountDeletion.integration.test.ts @@ -603,3 +603,99 @@ void test( } }, ); + +/** Waits until some transaction is waiting for a row lock. */ +const waitForLockWait = async (db: { + $queryRaw: (query: TemplateStringsArray) => Promise; +}) => { + const deadline = Date.now() + 10_000; + while (Date.now() < deadline) { + const [row] = (await db.$queryRaw` + SELECT count(*)::int AS waiting FROM pg_locks WHERE NOT granted`) as Array<{ + waiting: number; + }>; + if (0 < (row?.waiting ?? 0)) { + return; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail('Nothing waited for a lock'); +}; + +void test( + 'account deletion: a group never passes to a member deleted at the same time', + { skip: !databaseUrl }, + async () => { + const t = await setup(); + const [alice, bob, carol] = [ + await t.createUser('Alice'), + await t.createUser('Bob'), + await t.createUser('Carol'), + ]; + const userIds = [alice.id, bob.id, carol.id]; + const groupIds: number[] = []; + + const createGroup = async (name: string, ownerId: number, memberIds: number[]) => { + const group = await t.db.group.create({ + data: { name, publicId: `${name.toLowerCase()}-race-${alice.id}`, userId: ownerId }, + }); + await t.db.groupUser.createMany({ + data: memberIds.map((userId) => ({ groupId: group.id, userId })), + }); + groupIds.push(group.id); + return group; + }; + const ownerOf = async (groupId: number) => + (await t.db.group.findUnique({ where: { id: groupId } }))?.userId ?? null; + + try { + // Bob, next in line for Alice's trip, is being deleted while Alice deletes her account + const trip = await createGroup('Trip', alice.id, [alice.id, bob.id, carol.id]); + const code = await t.requestCode(alice); + let release = () => undefined as void; + const released = new Promise((resolve) => { + release = resolve; + }); + let bobLocked = () => undefined as void; + const isBobLocked = new Promise((resolve) => { + bobLocked = resolve; + }); + const deletingBob = t.db.$transaction( + async (tx) => { + await tx.$queryRaw`SELECT id FROM "User" WHERE id = ${bob.id} FOR UPDATE`; + bobLocked(); + await released; + await tx.user.update({ where: { id: bob.id }, data: { deletedAt: new Date() } }); + }, + { timeout: 20_000 }, + ); + await isBobLocked; + const deletingAlice = t.callerFor(alice).user.deleteAccount({ code }); + await waitForLockWait(t.db); + release(); + await deletingBob; + await deletingAlice; + + // It goes to Carol, who is still around + assert.equal(await ownerOf(trip.id), carol.id); + + // Two members whose groups would pass to each other, deleting at once, don't deadlock + const [dave, erin] = [await t.createUser('Dave'), await t.createUser('Erin')]; + userIds.push(dave.id, erin.id); + const daves = await createGroup('Daves', dave.id, [dave.id, erin.id, carol.id]); + const erins = await createGroup('Erins', erin.id, [erin.id, dave.id]); + const [daveCode, erinCode] = [await t.requestCode(dave), await t.requestCode(erin)]; + await Promise.all([ + t.callerFor(dave).user.deleteAccount({ code: daveCode }), + t.callerFor(erin).user.deleteAccount({ code: erinCode }), + ]); + assert.equal(await ownerOf(daves.id), carol.id); + // Erin's group had nobody left and nothing to settle + assert.equal(await ownerOf(erins.id), null); + } finally { + await t.db.group.deleteMany({ where: { id: { in: groupIds } } }); + await t.db.user.deleteMany({ where: { id: { in: userIds } } }); + await t.db.$disconnect(); + } + }, +); From e6d10dcef3d94c5a2678af16f5c43cb955466c51 Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 31 Oct 2026 15:04:28 +1100 Subject: [PATCH 7/9] fix: keep a deleted account's rows when editing an expense Leaving someone who deleted their account out unless they have a share also ran on edits, including the stored rows of an unchanged expense. A rename then dropped their zero-share row, so the server no longer saw a metadata-only edit and re-validated and rebalanced the expense, which old expenses can fail. Only new expenses leave them out now; an edit saves the rows it has. --- src/store/addStore.ts | 27 +++++-- src/tests/defaultSplit.test.ts | 35 ++++++++++ src/tests/editLegacy.integration.test.ts | 89 ++++++++++++++++++++++++ 3 files changed, 146 insertions(+), 5 deletions(-) diff --git a/src/store/addStore.ts b/src/store/addStore.ts index bcec887..fcc95be 100644 --- a/src/store/addStore.ts +++ b/src/store/addStore.ts @@ -153,18 +153,35 @@ export const matchesEditSnapshot = ( /** * The participants to save: the stored ones while the expense is unchanged, else the form's. - * Someone who deleted their account is only saved with a share or as the payer: a row with nothing - * would only record them in a new expense, and keep a repeating one from going on. + * A new expense saves someone who deleted their account only with a share or as the payer: a row + * with nothing would only record them in it, and keep a repeating one from going on. An edit keeps + * the rows it has, so saving it unchanged leaves the expense as it was. */ export const getParticipantsToSave = ( state: Pick< AddExpenseState, - 'editSnapshot' | 'amount' | 'currency' | 'paidBy' | 'splitType' | 'participants' + | 'editSnapshot' + | 'loadedRecurrence' + | 'amount' + | 'currency' + | 'paidBy' + | 'splitType' + | 'participants' >, -) => - (matchesEditSnapshot(state) ? state.editSnapshot.participants : state.participants).filter( +) => { + if (matchesEditSnapshot(state)) { + return state.editSnapshot.participants; + } + + // Only an expense opened for editing has it, as null when it doesn't repeat + if (undefined !== state.loadedRecurrence) { + return state.participants; + } + + return state.participants.filter( (p) => !p.deletedAt || 0 !== toInteger(p.amount ?? 0) || p.id === state.paidBy?.id, ); +}; /** Shows the stored cents again when a change returns the form to the expense as it was opened. */ const withEditSnapshot = >(state: AddExpenseState, next: T) => { diff --git a/src/tests/defaultSplit.test.ts b/src/tests/defaultSplit.test.ts index c8b5d2d..05e21d2 100644 --- a/src/tests/defaultSplit.test.ts +++ b/src/tests/defaultSplit.test.ts @@ -476,6 +476,41 @@ void describe('group members who deleted their account', () => { ]); }); + void it('keeps their row in an edit of an expense they have no share in', () => { + start(); + useAddExpenseStore.setState( + getEditFormState({ + amount: 1000, + splitType: SplitType.EQUAL, + category: 'general', + currency: 'EUR', + name: 'Dinner', + expenseDate: new Date(2026, 8, 30), + fileKey: null, + group: group(), + paidByUser: me, + expenseParticipants: [ + { amount: 500, user: me }, + { amount: -500, user: alex }, + { amount: 0, user: gone }, + ], + }), + ); + + // Saved as stored, so the server sees nothing but a rename + assert.deepEqual(saved(), [ + [1, 5], + [2, -5], + [4, 0], + ]); + state().actions.setAmount(20); + assert.deepEqual(saved(), [ + [1, 10], + [2, -10], + [4, 0], + ]); + }); + void it('keeps them when they paid', () => { start(); state().actions.selectGroup(group(), [me, alex, gone]); diff --git a/src/tests/editLegacy.integration.test.ts b/src/tests/editLegacy.integration.test.ts index fe43901..17899ca 100644 --- a/src/tests/editLegacy.integration.test.ts +++ b/src/tests/editLegacy.integration.test.ts @@ -349,3 +349,92 @@ void test( } }, ); + +void test( + 'renaming an expense with a deleted account at no share keeps their row and the balances', + { skip: !databaseUrl }, + async () => { + const { db, store, createUsers, callerFor, snapshotBalances, getRows, cleanUp, getSaveInput } = + await setup(); + const users = await createUsers(['Alice', 'Bob', 'Carol', 'Dave']); + const [alice, bob, carol, dave] = users; + assert.ok(alice && bob && carol && dave); + const userIds = users.map((user) => user.id); + const caller = callerFor(alice); + + const load = async (expenseId: string) => { + store.useAddExpenseStore.getState().actions.resetState(); + store.useAddExpenseStore.setState({ + ...store.getEditFormState(await caller.user.getExpenseDetails({ expenseId })), + currentUser: alice, + }); + }; + const createExpense = async (name: string, currency: string, shares: [number, number][]) => { + const expense = await db.expense.create({ + data: { + name, + category: 'general', + currency, + amount: 1000, + paidBy: alice.id, + addedBy: alice.id, + expenseDate: new Date(), + splitType: 'EXACT', + expenseParticipants: { + create: shares.map(([userId, amount]) => ({ userId, amount })), + }, + }, + }); + await db.balance.createMany({ + data: shares + .filter(([userId, amount]) => userId !== alice.id && 0 !== amount) + .flatMap(([userId, amount]) => [ + { userId, friendId: alice.id, currency, amount }, + { userId: alice.id, friendId: userId, currency, amount: -amount }, + ]), + }); + return expense; + }; + + try { + // Dave was in both with nothing to pay, then deleted his account + const current = await createExpense('Current', 'USD', [ + [alice.id, 500], + [bob.id, -500], + [dave.id, 0], + ]); + // An old client's rounding put 4 cents too many on the payer: fine across four rows, more + // than today's validation allows across the three left without Dave's + const legacy = await createExpense('Legacy', 'EUR', [ + [alice.id, 670], + [bob.id, -333], + [carol.id, -333], + [dave.id, 0], + ]); + await db.user.update({ + where: { id: dave.id }, + data: { name: 'Deleted user', email: null, deletedAt: new Date() }, + }); + const before = await snapshotBalances(userIds); + + for (const expense of [current, legacy]) { + const rows = await getRows(expense.id); + await load(expense.id); + await caller.user.addOrEditExpense({ + ...getSaveInput(), + expenseId: expense.id, + name: 'Renamed', + }); + + assert.deepEqual(await getRows(expense.id), rows, expense.name); + assert.equal( + (await db.expense.findUniqueOrThrow({ where: { id: expense.id } })).name, + 'Renamed', + ); + } + assert.deepEqual(await snapshotBalances(userIds), before); + } finally { + await cleanUp(userIds, []); + } + }, +); From 21902f127bed99ee62a5182a608bbf163eafa3df Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 31 Oct 2026 15:06:24 +1100 Subject: [PATCH 8/9] fix: only let the app's own browser sign out the web from /start GET /api/mobile/auth/start signs the browser out of the web before the Google sign-in, so a link on any site could sign a visitor out. It now only does so when the browser opened the page itself (Sec-Fetch-Site: none, as the app's ASWebAuthenticationSession or Custom Tab, a typed URL or a bookmark do) or came from one of our pages (same-origin). Links and forms from other sites (cross-site, same-site) get a page asking to sign in from the app, and the session is left alone. Browsers without Fetch Metadata (Safari before 16.4, used by the app on older iOS) are let through unless their Referer is another site, so sign-in keeps working there. --- src/pages/api/mobile/auth/start.ts | 44 +++++++++++++++++------- src/server/mobileAuthTokens.ts | 30 ++++++++++++++++ src/tests/mobileAuth.integration.test.ts | 25 +++++++++++++- src/tests/mobileAuth.test.ts | 26 ++++++++++++++ 4 files changed, 112 insertions(+), 13 deletions(-) diff --git a/src/pages/api/mobile/auth/start.ts b/src/pages/api/mobile/auth/start.ts index e39feaf..1799fb6 100644 --- a/src/pages/api/mobile/auth/start.ts +++ b/src/pages/api/mobile/auth/start.ts @@ -9,6 +9,7 @@ import { rememberChallenge, signOutBrowserSession, } from '~/server/mobileAuth'; +import { isOwnNavigation } from '~/server/mobileAuthTokens'; const escapeHtml = (value: string) => value.replace( @@ -16,21 +17,28 @@ const escapeHtml = (value: string) => (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]!, ); -/** - * next-auth only starts OAuth from a POST with a CSRF token, so this page fetches one and submits - * the same form the web sign-in button would. next-auth passes the action's query on to the - * provider, so Google always asks which account to use. - */ -const renderSignInPage = (provider: string, callbackUrl: string) => ` +const renderPage = (title: string, body: string) => ` - Signing in to SplitPro + ${title} -

Signing in to SplitPro…

+${body} + +`; + +/** + * next-auth only starts OAuth from a POST with a CSRF token, so this page fetches one and submits + * the same form the web sign-in button would. next-auth passes the action's query on to the + * provider, so Google always asks which account to use. + */ +const renderSignInPage = (provider: string, callbackUrl: string) => + renderPage( + 'Signing in to SplitPro', + `

Signing in to SplitPro…

@@ -47,9 +55,13 @@ const renderSignInPage = (provider: string, callbackUrl: string) => ` - -`; + `, + ); + +const START_FROM_APP_PAGE = renderPage( + 'Sign in from the SplitPro app', + '

To sign in to the SplitPro app, open the app and start signing in there.

', +); const querySchema = z.object({ provider: z.literal('google') }); @@ -61,10 +73,18 @@ export default mobileAuthRoute('GET', async (req, res) => { throw new MobileAuthError(503, 'Google sign-in is not configured'); } + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + + // The app opens this in a browser of its own. A link from another site must not be able to sign + // the visitor out of the web, which is the first thing this does. + if (!isOwnNavigation(req.headers['sec-fetch-site'], req.headers.referer, env.NEXTAUTH_URL)) { + res.status(403).send(START_FROM_APP_PAGE); + return; + } + const callbackUrl = `/api/mobile/auth/handoff?${new URLSearchParams({ challenge, redirect }).toString()}`; await signOutBrowserSession(req, res); rememberChallenge(res, challenge); - res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.status(200).send(renderSignInPage(provider, callbackUrl)); }); diff --git a/src/server/mobileAuthTokens.ts b/src/server/mobileAuthTokens.ts index a97d281..cd3874f 100644 --- a/src/server/mobileAuthTokens.ts +++ b/src/server/mobileAuthTokens.ts @@ -139,6 +139,36 @@ export const hasBearerAuthorization = (authorization: string | undefined) => export const isJsonContentType = (contentType: string | undefined) => 'application/json' === contentType?.split(';')[0]?.trim().toLowerCase(); +// Sec-Fetch-Site of a page the browser opened itself (`none`: the app's sign-in browser, a typed +// URL or a bookmark) or one of our own pages linked to +const OWN_NAVIGATION_SITES = ['none', 'same-origin']; + +/** + * Whether the browser opened the page itself or came from one of ours, rather than a link or form + * on another site. Browsers without Fetch Metadata (e.g. Safari before 16.4, which older iOS uses + * for the app's sign-in) are let through unless their Referer names another site: refusing them + * would break sign-in there, and a link can at worst sign such a browser out. + */ +export const isOwnNavigation = ( + secFetchSite: string | string[] | undefined, + referer: string | undefined, + origin: string, +) => { + if (undefined !== secFetchSite) { + return 'string' === typeof secFetchSite && OWN_NAVIGATION_SITES.includes(secFetchSite); + } + + if (!referer) { + return true; + } + + try { + return new URL(referer).origin === new URL(origin).origin; + } catch { + return false; + } +}; + export type AppleFullName = | string | { diff --git a/src/tests/mobileAuth.integration.test.ts b/src/tests/mobileAuth.integration.test.ts index 06aae9a..59f2a52 100644 --- a/src/tests/mobileAuth.integration.test.ts +++ b/src/tests/mobileAuth.integration.test.ts @@ -240,8 +240,31 @@ void test( }); assert.equal(expoGo.status, 400); - // /start signs the browser out of the web and asks Google which account to use + // A link or form on another site can't use /start to sign the browser out const existing = await createWebSession(Date.now() - DAY_MS); + for (const headers of [ + { 'sec-fetch-site': 'cross-site' } as Record, + { 'sec-fetch-site': 'same-site' }, + { referer: 'https://evil.example/' }, + ]) { + const linked = await callRoute(start, { + query: { provider: 'google', challenge, redirect }, + headers, + cookies: { 'next-auth.session-token': existing.sessionToken }, + }); + assert.equal(linked.status, 403, JSON.stringify(headers)); + assert.match(String(linked.body), /open the app and start signing in there/); + assert.deepEqual(linked.setCookies, []); + assert.equal(await db.session.count({ where: { id: existing.id } }), 1); + } + + // Opened by the app's browser, /start signs it out of the web and asks Google which account + // to use. Browsers too old to send Sec-Fetch-Site are treated the same. + const fromApp = await callRoute(start, { + query: { provider: 'google', challenge, redirect }, + headers: { 'sec-fetch-site': 'none' }, + }); + assert.equal(fromApp.status, 200); const started = await callRoute(start, { query: { provider: 'google', challenge, redirect }, cookies: { 'next-auth.session-token': existing.sessionToken }, diff --git a/src/tests/mobileAuth.test.ts b/src/tests/mobileAuth.test.ts index 575394c..2ecee56 100644 --- a/src/tests/mobileAuth.test.ts +++ b/src/tests/mobileAuth.test.ts @@ -13,6 +13,7 @@ import { hasBearerAuthorization, hashVerificationToken, isJsonContentType, + isOwnNavigation, isValidChallenge, isValidHandoffCode, parseAppleAudiences, @@ -272,3 +273,28 @@ void describe('formatAppleFullName', () => { assert.equal(formatAppleFullName(' '), null); }); }); + +void describe('isOwnNavigation', () => { + const origin = 'https://splitpro.app'; + + void it('allows what the browser opened itself or one of our pages linked to', () => { + assert.equal(isOwnNavigation('none', undefined, origin), true); + assert.equal(isOwnNavigation('same-origin', `${origin}/auth/signin`, origin), true); + }); + + void it('refuses links and forms from other sites, whatever the Referer says', () => { + assert.equal(isOwnNavigation('cross-site', undefined, origin), false); + assert.equal(isOwnNavigation('same-site', 'https://evil.splitpro.app/', origin), false); + assert.equal(isOwnNavigation('cross-site', `${origin}/`, origin), false); + assert.equal(isOwnNavigation('unknown', undefined, origin), false); + assert.equal(isOwnNavigation(['none', 'none'], undefined, origin), false); + }); + + void it('allows browsers without Fetch Metadata unless the Referer is another site', () => { + assert.equal(isOwnNavigation(undefined, undefined, origin), true); + assert.equal(isOwnNavigation(undefined, `${origin}/balances`, origin), true); + assert.equal(isOwnNavigation(undefined, 'https://evil.example/', origin), false); + assert.equal(isOwnNavigation(undefined, 'https://splitpro.app.evil.example/', origin), false); + assert.equal(isOwnNavigation(undefined, 'not a url', origin), false); + }); +}); From bc3152a5041f1c0957e561ffb8d297960898abfe Mon Sep 17 00:00:00 2001 From: KM Koushik Date: Sat, 31 Oct 2026 15:11:50 +1100 Subject: [PATCH 9/9] fix: lock the user FOR UPDATE before the adapter updates it, avoiding a deadlock --- src/server/auth.ts | 21 +++++++++++++------ src/tests/accountDeletion.integration.test.ts | 21 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/server/auth.ts b/src/server/auth.ts index e1ae66e..2aab14b 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -162,11 +162,20 @@ export class DeletedAccountError extends Error { * Runs `write` while the account is locked against deletion, which locks it FOR UPDATE before it * removes sessions and sign-in methods. So a sign-in that looked the user up just before the * deletion either commits first, and the deletion removes what it wrote, or waits and is refused. + * Writes to the User row itself lock it FOR UPDATE up front: two FOR SHARE holders upgrading to + * write the same row deadlock. */ -const writeForLiveUser = (userId: number, write: (tx: Prisma.TransactionClient) => Promise) => +const writeForLiveUser = ( + userId: number, + write: (tx: Prisma.TransactionClient) => Promise, + { updatesUser = false } = {}, +) => db.$transaction(async (tx) => { - const live = await tx.$queryRaw>` - SELECT id FROM "User" WHERE id = ${userId} AND "deletedAt" IS NULL FOR SHARE`; + const live = updatesUser + ? await tx.$queryRaw>` + SELECT id FROM "User" WHERE id = ${userId} AND "deletedAt" IS NULL FOR UPDATE` + : await tx.$queryRaw>` + SELECT id FROM "User" WHERE id = ${userId} AND "deletedAt" IS NULL FOR SHARE`; if (0 === live.length) { throw new DeletedAccountError(); @@ -200,9 +209,9 @@ const SplitProPrismaAdapter = (...args: Parameters): Adapt return null; }, updateUser: ({ id, ...data }) => - writeForLiveUser(Number(id), (tx) => - tx.user.update({ where: { id: Number(id) }, data }), - ) as unknown as Promise, + writeForLiveUser(Number(id), (tx) => tx.user.update({ where: { id: Number(id) }, data }), { + updatesUser: true, + }) as unknown as Promise, linkAccount: (account: AdapterAccount) => writeForLiveUser(Number(account.userId), (tx) => tx.account.create({ data: { ...account, userId: Number(account.userId) } }), diff --git a/src/tests/accountDeletion.integration.test.ts b/src/tests/accountDeletion.integration.test.ts index 3cd1b49..ede19e6 100644 --- a/src/tests/accountDeletion.integration.test.ts +++ b/src/tests/accountDeletion.integration.test.ts @@ -699,3 +699,24 @@ void test( } }, ); + +void test( + 'account deletion: concurrent sign-in updates to the same account never deadlock', + { skip: !databaseUrl }, + async () => { + const t = await setup(); + const grace = await t.createUser('Grace'); + try { + await Promise.all( + Array.from({ length: 8 }, () => + t.authAdapter.updateUser!({ id: String(grace.id), emailVerified: new Date() }), + ), + ); + const updated = await t.db.user.findUnique({ where: { id: grace.id } }); + assert.ok(updated?.emailVerified); + } finally { + await t.db.user.deleteMany({ where: { id: grace.id } }); + await t.db.$disconnect(); + } + }, +);