diff --git a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx index 16d76218e4..30acac9f5e 100644 --- a/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx +++ b/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx @@ -1,5 +1,5 @@ import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud"; -import { sendEmailFromDefaultTemplate } from "@/lib/emails"; +import { normalizeEmail, sendEmailFromDefaultTemplate } from "@/lib/emails"; import { getItemQuantityForCustomer } from "@/lib/payments/customer-data"; import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { getPrismaClientForTenancy } from "@/prisma-client"; @@ -68,7 +68,34 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({ return codeObj; }, - async handler(tenancy, {}, data, body, user) { + // Runs before the code is claimed (marked used). Must live here, not in `handler`, + // so a mismatched attempt doesn't burn the invitation for the real recipient. + async validate(tenancy, { email: invitedEmail }, data, body, user) { + if (!user) throw new KnownErrors.UserAuthenticationRequired; + if (user.restricted_reason) { + throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason); + } + + const prisma = await getPrismaClientForTenancy(tenancy); + // Contact channels are stored normalized; normalize the invited email to match. + // Legacy invitations created before send-code normalized may still hold a non- + // normalized `method.email`, so do it at compare time too. + const normalized = normalizeEmail(invitedEmail); + const invitedChannel = await prisma.contactChannel.findFirst({ + where: { + tenancyId: tenancy.id, + projectUserId: user.id, + type: "EMAIL", + value: normalized, + isVerified: true, + }, + select: { id: true }, + }); + if (!invitedChannel) { + throw new KnownErrors.TeamInvitationEmailMismatch(); + } + }, + async handler(tenancy, { email: invitedEmail }, data, body, user) { if (!user) throw new KnownErrors.UserAuthenticationRequired; if (user.restricted_reason) { throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason); diff --git a/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx b/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx index fddea5c7c1..b5ead2927f 100644 --- a/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx +++ b/apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx @@ -1,3 +1,4 @@ +import { normalizeEmail } from "@/lib/emails"; import { ensureUserTeamPermissionExists } from "@/lib/request-checks"; import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -48,13 +49,15 @@ export const POST = createSmartRouteHandler({ } }); + // Normalize the invited email so accept can compare against stored contact channels, + // which are themselves normalized on creation. const codeObj = await teamInvitationCodeHandler.sendCode({ tenancy: auth.tenancy, data: { team_id: body.team_id, }, method: { - email: body.email, + email: normalizeEmail(body.email), }, callbackUrl: body.callback_url, }, {}); diff --git a/apps/backend/src/route-handlers/verification-code-handler.tsx b/apps/backend/src/route-handlers/verification-code-handler.tsx index 5444e436a1..062e2e4687 100644 --- a/apps/backend/src/route-handlers/verification-code-handler.tsx +++ b/apps/backend/src/route-handlers/verification-code-handler.tsx @@ -190,19 +190,23 @@ export function createVerificationCodeHandler< switch (handlerType) { case 'post': { - await globalPrismaClient.verificationCode.update({ + // Atomic claim — conditional WHERE closes the TOCTOU against the checks above. + const claimResult = await globalPrismaClient.verificationCode.updateMany({ where: { - projectId_branchId_code: { - projectId: auth.project.id, - branchId: auth.tenancy.branchId, - code, - }, + projectId: auth.project.id, + branchId: auth.tenancy.branchId, + code, type: options.type, + usedAt: null, + expiresAt: { gt: new Date() }, }, data: { usedAt: new Date(), }, }); + if (claimResult.count === 0) { + throw new KnownErrors.VerificationCodeAlreadyUsed(); + } return await options.handler(auth.tenancy, validatedMethod, validatedData, requestBody as any, auth.user); } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts index e413df3e41..a1d55c2900 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts @@ -382,6 +382,33 @@ it("should use the send-sign-in-code request context when creating a new OTP use expect(userResponse.body.country_code).toBe("CA"); }); +it("should mint exactly one refresh token when the same code is redeemed in parallel", async ({ expect }) => { + // Guards the verification-code TOCTOU fix. Before the fix, the read-then-write pattern + // in verification-code-handler.tsx let N concurrent requests with the same OTP each pass + // the `if (usedAt) throw` check and each call createAuthTokens, minting N independent + // refresh tokens from one code. That enabled session-persistence: revoking one token + // didn't kill the others (no bulk-revoke exists for passwordless users short of a + // password change). The fix claims the code with a conditional updateMany and errors all + // losing racers with VERIFICATION_CODE_ALREADY_USED. + const sendSignInCodeRes = await Auth.Otp.sendSignInCode(); + const signInCode = await Auth.Otp.getSignInCodeFromMailbox(sendSignInCodeRes.sendSignInCodeResponse.body.nonce); + + const parallelCount = 5; + const responses = await Promise.all( + Array.from({ length: parallelCount }, () => niceBackendFetch("/api/v1/auth/otp/sign-in", { + method: "POST", + accessType: "client", + body: { code: signInCode }, + })), + ); + + const successes = responses.filter(r => r.status === 200); + const alreadyUsed = responses.filter(r => r.status === 409 && (r.body as any)?.code === "VERIFICATION_CODE_ALREADY_USED"); + + expect(successes).toHaveLength(1); + expect(successes.length + alreadyUsed.length).toBe(parallelCount); +}); + it.todo("should not sign in if e-mail's usedForAuth status has changed since sign-in code was sent"); it.todo("should not sign in if account's otpEnabled status has changed since sign-in code was sent"); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts index 7aa811e62c..f7bbddfbea 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts @@ -386,7 +386,10 @@ it("allows team admins to be added when item quantity is increased", async ({ ex for (let i = 0; i < mailboxes.length; i++) { const mailbox = mailboxes[i]; backendContext.set({ mailbox: mailbox }); - await Auth.fastSignUp(); + await Auth.fastSignUp({ + primary_email: mailbox.emailAddress, + primary_email_verified: true, + }); const invitationMessages = await mailbox.waitForMessagesWithSubject("join"); const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", { diff --git a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts index 1ce357a06f..3ec4103345 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts @@ -1140,3 +1140,174 @@ it("can accept invitation by ID", async ({ expect }) => { }); expect(listResponse.body.items).toHaveLength(0); }); + +it("rejects accept when the signed-in user's email does not match the invited email", async ({ expect }) => { + // Without this check, anyone holding the 45-char code (forwarded email, insider with + // outbox access, leaked share) could accept the invitation as themselves. The handler + // must require that the accepting user actually owns the invited email. + await Project.createAndSwitch(); + await Auth.fastSignUp(); + const { teamId } = await Team.create(); + + const receiveMailbox = createMailbox(); + backendContext.set({ userAuth: null }); + await niceBackendFetch("/api/v1/team-invitations/send-code", { + method: "POST", + accessType: "server", + body: { + email: receiveMailbox.emailAddress, + team_id: teamId, + callback_url: "http://localhost:12345/some-callback-url", + }, + }); + + const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join"); + const code = invitationMessages + .findLast((m) => m.subject.includes("join")) + ?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1]; + expect(code).toBeTruthy(); + + // A different user (different verified email) signs in and tries to redeem the code. + // This simulates an attacker who obtained the invitation link out of band. + await Auth.fastSignUp(); + + const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", { + method: "POST", + accessType: "client", + body: { code }, + }); + expect(acceptResponse.status).toBe(403); + expect(acceptResponse.body.code).toBe("TEAM_INVITATION_EMAIL_MISMATCH"); + + // The attacker should not have been added to the team. + const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, { + accessType: "client", + method: "GET", + }); + expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeUndefined(); +}); + +it("does not burn the invitation when a wrong-email user attempts to accept", async ({ expect }) => { + // Regression test for the griefing vector a reviewer flagged: if the email-match + // check runs after the atomic claim, any attacker with the link can burn the code, + // leaving the real recipient with VERIFICATION_CODE_ALREADY_USED. The email check + // must run in the pre-claim validate hook so a mismatched attempt leaves usedAt=null. + await Project.createAndSwitch(); + await Auth.fastSignUp(); + const { teamId } = await Team.create(); + + const receiveMailbox = createMailbox(); + backendContext.set({ userAuth: null }); + await niceBackendFetch("/api/v1/team-invitations/send-code", { + method: "POST", + accessType: "server", + body: { + email: receiveMailbox.emailAddress, + team_id: teamId, + callback_url: "http://localhost:12345/some-callback-url", + }, + }); + + const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join"); + const code = invitationMessages + .findLast((m) => m.subject.includes("join")) + ?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1]; + expect(code).toBeTruthy(); + + // Attacker (different verified email) tries first — must be rejected with mismatch. + await Auth.fastSignUp(); + const attackerResponse = await niceBackendFetch("/api/v1/team-invitations/accept", { + method: "POST", + accessType: "client", + body: { code }, + }); + expect(attackerResponse.status).toBe(403); + expect(attackerResponse.body.code).toBe("TEAM_INVITATION_EMAIL_MISMATCH"); + + // Legitimate recipient signs up and redeems the same code — must still succeed. + backendContext.set({ mailbox: receiveMailbox }); + await Auth.fastSignUp({ + primary_email: receiveMailbox.emailAddress, + primary_email_verified: true, + }); + const legitimateResponse = await niceBackendFetch("/api/v1/team-invitations/accept", { + method: "POST", + accessType: "client", + body: { code }, + }); + expect(legitimateResponse.status).toBe(200); + + const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, { + accessType: "client", + method: "GET", + }); + expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined(); +}); + +it("accepts an invitation sent to a differently-cased email against a normalized channel", async ({ expect }) => { + // Regression test for the reviewer's Finding 3: contact channels are stored + // lowercased via normalizeEmail, but send-code used to store body.email raw. + // Sending to Alice@Example.com must match a channel stored as alice@example.com. + await Project.createAndSwitch(); + await Auth.fastSignUp(); + const { teamId } = await Team.create(); + + const receiveMailbox = createMailbox(); + // Deliberately send to an uppercased variant of the recipient's address. + const uppercasedEmail = receiveMailbox.emailAddress.replace(/^(.)/, c => c.toUpperCase()); + backendContext.set({ userAuth: null }); + await niceBackendFetch("/api/v1/team-invitations/send-code", { + method: "POST", + accessType: "server", + body: { + email: uppercasedEmail, + team_id: teamId, + callback_url: "http://localhost:12345/some-callback-url", + }, + }); + + backendContext.set({ mailbox: receiveMailbox }); + await Auth.fastSignUp({ + primary_email: receiveMailbox.emailAddress, + primary_email_verified: true, + }); + await Team.acceptInvitation(); + + const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, { + accessType: "client", + method: "GET", + }); + expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined(); +}); + +it("still allows the legitimate invitee with the matching verified email to accept", async ({ expect }) => { + // Complements the mismatch test: the new email-match check must not break the happy path. + await Project.createAndSwitch(); + await Auth.fastSignUp(); + const { teamId } = await Team.create(); + + const receiveMailbox = createMailbox(); + backendContext.set({ userAuth: null }); + await niceBackendFetch("/api/v1/team-invitations/send-code", { + method: "POST", + accessType: "server", + body: { + email: receiveMailbox.emailAddress, + team_id: teamId, + callback_url: "http://localhost:12345/some-callback-url", + }, + }); + + backendContext.set({ mailbox: receiveMailbox }); + await Auth.fastSignUp({ + primary_email: receiveMailbox.emailAddress, + primary_email_verified: true, + }); + await Team.acceptInvitation(); + + const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, { + accessType: "client", + method: "GET", + }); + expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined(); +}); diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index 6942da8042..5b001d160c 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -1080,7 +1080,7 @@ export class StackClientInterface { code: string, session: InternalSession, type: T, - }): Promise> { + }): Promise> { const res = await this.sendClientRequestAndCatchKnownError( options.type === 'check' ? "/team-invitations/accept/check-code" : @@ -1097,7 +1097,7 @@ export class StackClientInterface { }), }, options.session, - [KnownErrors.VerificationCodeError] + [KnownErrors.VerificationCodeError, KnownErrors.TeamInvitationEmailMismatch] ); if (res.status === "error") { diff --git a/packages/stack-shared/src/known-errors.tsx b/packages/stack-shared/src/known-errors.tsx index e65a64bb4e..4ad21c070d 100644 --- a/packages/stack-shared/src/known-errors.tsx +++ b/packages/stack-shared/src/known-errors.tsx @@ -1132,6 +1132,16 @@ const TeamInvitationRestrictedUserNotAllowed = createKnownErrorConstructor( (json: any) => [json.restricted_reason ?? { type: "anonymous" }] as const, ); +const TeamInvitationEmailMismatch = createKnownErrorConstructor( + KnownError, + "TEAM_INVITATION_EMAIL_MISMATCH", + () => [ + 403, + "This team invitation was sent to a different email address. Sign in with the invited email, or add and verify that email on your account, then try again.", + ] as const, + () => [] as const, +); + const EmailTemplateAlreadyExists = createKnownErrorConstructor( KnownError, @@ -1942,6 +1952,7 @@ export const KnownErrors = { TeamNotFound, TeamMembershipNotFound, TeamInvitationRestrictedUserNotAllowed, + TeamInvitationEmailMismatch, EmailTemplateAlreadyExists, OAuthConnectionNotConnectedToUser, OAuthConnectionAlreadyConnectedToAnotherUser, diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 4422a824c2..599bef7adf 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -2537,7 +2537,7 @@ export class _StackClientAppImplIncomplete> { + async verifyTeamInvitationCode(code: string): Promise> { return await this._interface.acceptTeamInvitation({ type: 'check', code, @@ -2545,7 +2545,7 @@ export class _StackClientAppImplIncomplete> { + async acceptTeamInvitation(code: string): Promise> { const result = await this._interface.acceptTeamInvitation({ type: 'use', code, @@ -2559,7 +2559,7 @@ export class _StackClientAppImplIncomplete> { + async getTeamInvitationDetails(code: string): Promise> { const result = await this._interface.acceptTeamInvitation({ type: 'details', code, diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 6a6d20f2a6..efdd12a589 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -73,9 +73,9 @@ export type StackClientApp>, resetPassword(options: { code: string, password: string }): Promise>, verifyPasswordResetCode(code: string): Promise>, - verifyTeamInvitationCode(code: string): Promise>, - acceptTeamInvitation(code: string): Promise>, - getTeamInvitationDetails(code: string): Promise>, + verifyTeamInvitationCode(code: string): Promise>, + acceptTeamInvitation(code: string): Promise>, + getTeamInvitationDetails(code: string): Promise>, verifyEmail(code: string): Promise>, signInWithMagicLink(code: string, options?: { noRedirect?: boolean }): Promise>, signInWithMfa(otp: string, code: string, options?: { noRedirect?: boolean }): Promise>,