Skip to content

Commit c69a270

Browse files
authored
fix team invitation email check + verification code TOCTOU (#1365)
## Summary Two authorization fixes in the backend. Both are pre-existing in `dev` and were found during a security audit of `apps/backend/src`. ### 1. Team invitation accept — email not validated [`team-invitations/accept/verification-code-handler.tsx`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx) destructured the invited email as `{}` and only used `data.team_id` + the accepting `user`. Any signed-in user in the tenancy who possessed the 45-char code could join the team as themselves — the invitation was not actually bound to the email it was addressed to. **Attack scenarios that work without this fix** - Forwarded invitation email (shared inbox, assistant inbox, auto-forward rules). - Screenshot of the invitation link pasted into Slack / Notion. - Insider with server-access reading the email outbox (`GET /api/latest/emails/outbox` returns rendered `html` + `variables.teamInvitationLink`). - Stale invite still sitting in spam after the invitee forwarded it elsewhere. **Fix.** The accept handler now requires that the accepting user owns the invited email as a *verified* contact channel on their account. Matches the invariant already used by the "list invitations for me" endpoint ([`team-invitations/crud.tsx:41-66`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/app/api/latest/team-invitations/crud.tsx#L41-L66)). Rejections return a new `TEAM_INVITATION_EMAIL_MISMATCH` (403) error. ### 2. Verification-code handler TOCTOU [`route-handlers/verification-code-handler.tsx`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/route-handlers/verification-code-handler.tsx) had a classic read-then-write TOCTOU: ```ts const verificationCode = await prisma.verificationCode.findUnique(...); if (verificationCode.usedAt) throw new KnownErrors.VerificationCodeAlreadyUsed(); // ... validation ... await prisma.verificationCode.update({ data: { usedAt: new Date() } }); // unconditional return await options.handler(...); ``` Five concurrent requests with the same code all pass the `if (usedAt)` gate, all mark the code used, all run the post-handler. For OTP sign-in the handler calls `createAuthTokens` which writes a fresh `projectUserRefreshToken` row per call — so **one OTP → N refresh tokens**. `auth/sessions/current` only revokes by `id: refreshTokenId` and there is no bulk-revoke for passwordless users (only password change in [`users/crud.tsx:1210`](https://github.com/stack-auth/stack-auth/blob/dev/apps/backend/src/app/api/latest/users/crud.tsx#L1210) does `deleteMany`). A phished OTP therefore becomes a session-persistence primitive. **Fix.** Replace the unconditional `update` with a conditional `updateMany({ where: { …, usedAt: null } })` executed before `options.handler`; if `count === 0` the race was already lost and we throw `VERIFICATION_CODE_ALREADY_USED` (409). This also benefits MFA sign-in and passkey sign-in, which share the same handler. ## Changes | File | Change | |---|---| | `team-invitations/accept/verification-code-handler.tsx` | Require verified contact channel matching `method.email` | | `route-handlers/verification-code-handler.tsx` | Atomic `updateMany` claim gated on `usedAt: null` | | `stack-shared/src/known-errors.tsx` | New `TeamInvitationEmailMismatch` (403) | | `e2e/.../team-invitations.test.ts` | Two new tests (mismatch + happy path) | | `e2e/.../auth/otp/sign-in.test.ts` | One new test: 5 parallel redemptions of one OTP → 1× 200 + 4× 409 | ## Test plan - [x] `pnpm test run apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts` — 27/27 pass - [x] `pnpm test run apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts` — 12/12 (+ 4 pre-existing `it.todo`) - [x] `pnpm test run apps/e2e/tests/backend/endpoints/api/v1/auth/password` — 33/33 (+ 7 pre-existing todos) - [x] `pnpm test run apps/e2e/tests/backend/endpoints/api/v1/contact-channels` — 24/24 - [x] `pnpm test run apps/e2e/tests/backend/endpoints/api/v1/auth/passkey apps/e2e/tests/backend/endpoints/api/v1/auth/mfa` — 16/16 - [x] `pnpm --filter @stackframe/backend typecheck` — clean - [x] `pnpm --filter @stackframe/backend lint` + `pnpm --filter @stackframe/stack-shared lint` — clean ## Notes - The broader "plaintext credentials in DB + Sentry logs every header" finding from the same audit is **not** in this PR — a scrubber for `Sentry.setContext` request headers + unit tests is prepared on a local stash and will go out as a separate PR. - The team-invitation fix does not require any config change; fresh signups via the OTP / password flows that set `primary_email_verified: true` during creation already land the user with a verified channel matching the invited email, so the happy path is unaffected. ### Follow-up review (Codex) Addressed in follow-up commit `954cddb`: - **Finding 1 (High)**: mismatched invite acceptance was consuming the invitation before rejecting. Moved the email-ownership check into the pre-claim `options.validate` hook so a wrong-email attempt leaves `usedAt` untouched and the real recipient can still redeem. New test asserts this end-to-end. - **Finding 3 (Medium)**: invitation stored `body.email` raw but contact channels are stored via `normalizeEmail`, so case-varied invites (e.g. `Alice@Example.com`) wouldn't match a `alice@example.com` channel. `send-code` now normalizes on storage and `accept` normalizes on compare for back-compat with already-issued invites. New test covers the mixed-case path. - **Finding 2 (partial)**: added `expiresAt > now` to the atomic claim predicate for the boundary case where a code expires between the read and the claim. The reviewer's broader point about the `attemptCount` rate-limit check being non-atomic with its own increment **pre-dates this PR** (it reads the in-memory `verificationCode.attemptCount` from line 150, not a fresh read) and exists independently of the `usedAt` TOCTOU I'm fixing here. Tracking that as a separate follow-up so this PR stays scoped to the two originally-flagged issues. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Invite acceptance now requires the invitee’s verified, normalized (case‑insensitive) email; mismatches return HTTP 403 (TEAM_INVITATION_EMAIL_MISMATCH). * Client APIs now surface the new email-mismatch error alongside verification errors. * **Bug Fixes** * OTP verification codes are now guarded against parallel double‑redeem so only one request succeeds. * **Tests** * Added E2E tests for invitation email validation, non‑consuming rejection, case‑insensitive matching, and OTP concurrency. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 9f79bfb commit c69a270

10 files changed

Lines changed: 264 additions & 18 deletions

File tree

apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
2-
import { sendEmailFromDefaultTemplate } from "@/lib/emails";
2+
import { normalizeEmail, sendEmailFromDefaultTemplate } from "@/lib/emails";
33
import { getItemQuantityForCustomer } from "@/lib/payments/customer-data";
44
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
55
import { getPrismaClientForTenancy } from "@/prisma-client";
@@ -68,7 +68,34 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
6868

6969
return codeObj;
7070
},
71-
async handler(tenancy, {}, data, body, user) {
71+
// Runs before the code is claimed (marked used). Must live here, not in `handler`,
72+
// so a mismatched attempt doesn't burn the invitation for the real recipient.
73+
async validate(tenancy, { email: invitedEmail }, data, body, user) {
74+
if (!user) throw new KnownErrors.UserAuthenticationRequired;
75+
if (user.restricted_reason) {
76+
throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason);
77+
}
78+
79+
const prisma = await getPrismaClientForTenancy(tenancy);
80+
// Contact channels are stored normalized; normalize the invited email to match.
81+
// Legacy invitations created before send-code normalized may still hold a non-
82+
// normalized `method.email`, so do it at compare time too.
83+
const normalized = normalizeEmail(invitedEmail);
84+
const invitedChannel = await prisma.contactChannel.findFirst({
85+
where: {
86+
tenancyId: tenancy.id,
87+
projectUserId: user.id,
88+
type: "EMAIL",
89+
value: normalized,
90+
isVerified: true,
91+
},
92+
select: { id: true },
93+
});
94+
if (!invitedChannel) {
95+
throw new KnownErrors.TeamInvitationEmailMismatch();
96+
}
97+
},
98+
async handler(tenancy, { email: invitedEmail }, data, body, user) {
7299
if (!user) throw new KnownErrors.UserAuthenticationRequired;
73100
if (user.restricted_reason) {
74101
throw new KnownErrors.TeamInvitationRestrictedUserNotAllowed(user.restricted_reason);

apps/backend/src/app/api/latest/team-invitations/send-code/route.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { normalizeEmail } from "@/lib/emails";
12
import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
23
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
34
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
@@ -48,13 +49,15 @@ export const POST = createSmartRouteHandler({
4849
}
4950
});
5051

52+
// Normalize the invited email so accept can compare against stored contact channels,
53+
// which are themselves normalized on creation.
5154
const codeObj = await teamInvitationCodeHandler.sendCode({
5255
tenancy: auth.tenancy,
5356
data: {
5457
team_id: body.team_id,
5558
},
5659
method: {
57-
email: body.email,
60+
email: normalizeEmail(body.email),
5861
},
5962
callbackUrl: body.callback_url,
6063
}, {});

apps/backend/src/route-handlers/verification-code-handler.tsx

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -190,19 +190,23 @@ export function createVerificationCodeHandler<
190190

191191
switch (handlerType) {
192192
case 'post': {
193-
await globalPrismaClient.verificationCode.update({
193+
// Atomic claim — conditional WHERE closes the TOCTOU against the checks above.
194+
const claimResult = await globalPrismaClient.verificationCode.updateMany({
194195
where: {
195-
projectId_branchId_code: {
196-
projectId: auth.project.id,
197-
branchId: auth.tenancy.branchId,
198-
code,
199-
},
196+
projectId: auth.project.id,
197+
branchId: auth.tenancy.branchId,
198+
code,
200199
type: options.type,
200+
usedAt: null,
201+
expiresAt: { gt: new Date() },
201202
},
202203
data: {
203204
usedAt: new Date(),
204205
},
205206
});
207+
if (claimResult.count === 0) {
208+
throw new KnownErrors.VerificationCodeAlreadyUsed();
209+
}
206210

207211
return await options.handler(auth.tenancy, validatedMethod, validatedData, requestBody as any, auth.user);
208212
}

apps/e2e/tests/backend/endpoints/api/v1/auth/otp/sign-in.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,33 @@ it("should use the send-sign-in-code request context when creating a new OTP use
382382
expect(userResponse.body.country_code).toBe("CA");
383383
});
384384

385+
it("should mint exactly one refresh token when the same code is redeemed in parallel", async ({ expect }) => {
386+
// Guards the verification-code TOCTOU fix. Before the fix, the read-then-write pattern
387+
// in verification-code-handler.tsx let N concurrent requests with the same OTP each pass
388+
// the `if (usedAt) throw` check and each call createAuthTokens, minting N independent
389+
// refresh tokens from one code. That enabled session-persistence: revoking one token
390+
// didn't kill the others (no bulk-revoke exists for passwordless users short of a
391+
// password change). The fix claims the code with a conditional updateMany and errors all
392+
// losing racers with VERIFICATION_CODE_ALREADY_USED.
393+
const sendSignInCodeRes = await Auth.Otp.sendSignInCode();
394+
const signInCode = await Auth.Otp.getSignInCodeFromMailbox(sendSignInCodeRes.sendSignInCodeResponse.body.nonce);
395+
396+
const parallelCount = 5;
397+
const responses = await Promise.all(
398+
Array.from({ length: parallelCount }, () => niceBackendFetch("/api/v1/auth/otp/sign-in", {
399+
method: "POST",
400+
accessType: "client",
401+
body: { code: signInCode },
402+
})),
403+
);
404+
405+
const successes = responses.filter(r => r.status === 200);
406+
const alreadyUsed = responses.filter(r => r.status === 409 && (r.body as any)?.code === "VERIFICATION_CODE_ALREADY_USED");
407+
408+
expect(successes).toHaveLength(1);
409+
expect(successes.length + alreadyUsed.length).toBe(parallelCount);
410+
});
411+
385412
it.todo("should not sign in if e-mail's usedForAuth status has changed since sign-in code was sent");
386413

387414
it.todo("should not sign in if account's otpEnabled status has changed since sign-in code was sent");

apps/e2e/tests/backend/endpoints/api/v1/payments/items.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,7 +386,10 @@ it("allows team admins to be added when item quantity is increased", async ({ ex
386386
for (let i = 0; i < mailboxes.length; i++) {
387387
const mailbox = mailboxes[i];
388388
backendContext.set({ mailbox: mailbox });
389-
await Auth.fastSignUp();
389+
await Auth.fastSignUp({
390+
primary_email: mailbox.emailAddress,
391+
primary_email_verified: true,
392+
});
390393

391394
const invitationMessages = await mailbox.waitForMessagesWithSubject("join");
392395
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {

apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1140,3 +1140,174 @@ it("can accept invitation by ID", async ({ expect }) => {
11401140
});
11411141
expect(listResponse.body.items).toHaveLength(0);
11421142
});
1143+
1144+
it("rejects accept when the signed-in user's email does not match the invited email", async ({ expect }) => {
1145+
// Without this check, anyone holding the 45-char code (forwarded email, insider with
1146+
// outbox access, leaked share) could accept the invitation as themselves. The handler
1147+
// must require that the accepting user actually owns the invited email.
1148+
await Project.createAndSwitch();
1149+
await Auth.fastSignUp();
1150+
const { teamId } = await Team.create();
1151+
1152+
const receiveMailbox = createMailbox();
1153+
backendContext.set({ userAuth: null });
1154+
await niceBackendFetch("/api/v1/team-invitations/send-code", {
1155+
method: "POST",
1156+
accessType: "server",
1157+
body: {
1158+
email: receiveMailbox.emailAddress,
1159+
team_id: teamId,
1160+
callback_url: "http://localhost:12345/some-callback-url",
1161+
},
1162+
});
1163+
1164+
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
1165+
const code = invitationMessages
1166+
.findLast((m) => m.subject.includes("join"))
1167+
?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1];
1168+
expect(code).toBeTruthy();
1169+
1170+
// A different user (different verified email) signs in and tries to redeem the code.
1171+
// This simulates an attacker who obtained the invitation link out of band.
1172+
await Auth.fastSignUp();
1173+
1174+
const acceptResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
1175+
method: "POST",
1176+
accessType: "client",
1177+
body: { code },
1178+
});
1179+
expect(acceptResponse.status).toBe(403);
1180+
expect(acceptResponse.body.code).toBe("TEAM_INVITATION_EMAIL_MISMATCH");
1181+
1182+
// The attacker should not have been added to the team.
1183+
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
1184+
accessType: "client",
1185+
method: "GET",
1186+
});
1187+
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeUndefined();
1188+
});
1189+
1190+
it("does not burn the invitation when a wrong-email user attempts to accept", async ({ expect }) => {
1191+
// Regression test for the griefing vector a reviewer flagged: if the email-match
1192+
// check runs after the atomic claim, any attacker with the link can burn the code,
1193+
// leaving the real recipient with VERIFICATION_CODE_ALREADY_USED. The email check
1194+
// must run in the pre-claim validate hook so a mismatched attempt leaves usedAt=null.
1195+
await Project.createAndSwitch();
1196+
await Auth.fastSignUp();
1197+
const { teamId } = await Team.create();
1198+
1199+
const receiveMailbox = createMailbox();
1200+
backendContext.set({ userAuth: null });
1201+
await niceBackendFetch("/api/v1/team-invitations/send-code", {
1202+
method: "POST",
1203+
accessType: "server",
1204+
body: {
1205+
email: receiveMailbox.emailAddress,
1206+
team_id: teamId,
1207+
callback_url: "http://localhost:12345/some-callback-url",
1208+
},
1209+
});
1210+
1211+
const invitationMessages = await receiveMailbox.waitForMessagesWithSubject("join");
1212+
const code = invitationMessages
1213+
.findLast((m) => m.subject.includes("join"))
1214+
?.body?.text.match(/http:\/\/localhost:12345\/some-callback-url\?code=([a-zA-Z0-9]+)/)?.[1];
1215+
expect(code).toBeTruthy();
1216+
1217+
// Attacker (different verified email) tries first — must be rejected with mismatch.
1218+
await Auth.fastSignUp();
1219+
const attackerResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
1220+
method: "POST",
1221+
accessType: "client",
1222+
body: { code },
1223+
});
1224+
expect(attackerResponse.status).toBe(403);
1225+
expect(attackerResponse.body.code).toBe("TEAM_INVITATION_EMAIL_MISMATCH");
1226+
1227+
// Legitimate recipient signs up and redeems the same code — must still succeed.
1228+
backendContext.set({ mailbox: receiveMailbox });
1229+
await Auth.fastSignUp({
1230+
primary_email: receiveMailbox.emailAddress,
1231+
primary_email_verified: true,
1232+
});
1233+
const legitimateResponse = await niceBackendFetch("/api/v1/team-invitations/accept", {
1234+
method: "POST",
1235+
accessType: "client",
1236+
body: { code },
1237+
});
1238+
expect(legitimateResponse.status).toBe(200);
1239+
1240+
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
1241+
accessType: "client",
1242+
method: "GET",
1243+
});
1244+
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
1245+
});
1246+
1247+
it("accepts an invitation sent to a differently-cased email against a normalized channel", async ({ expect }) => {
1248+
// Regression test for the reviewer's Finding 3: contact channels are stored
1249+
// lowercased via normalizeEmail, but send-code used to store body.email raw.
1250+
// Sending to Alice@Example.com must match a channel stored as alice@example.com.
1251+
await Project.createAndSwitch();
1252+
await Auth.fastSignUp();
1253+
const { teamId } = await Team.create();
1254+
1255+
const receiveMailbox = createMailbox();
1256+
// Deliberately send to an uppercased variant of the recipient's address.
1257+
const uppercasedEmail = receiveMailbox.emailAddress.replace(/^(.)/, c => c.toUpperCase());
1258+
backendContext.set({ userAuth: null });
1259+
await niceBackendFetch("/api/v1/team-invitations/send-code", {
1260+
method: "POST",
1261+
accessType: "server",
1262+
body: {
1263+
email: uppercasedEmail,
1264+
team_id: teamId,
1265+
callback_url: "http://localhost:12345/some-callback-url",
1266+
},
1267+
});
1268+
1269+
backendContext.set({ mailbox: receiveMailbox });
1270+
await Auth.fastSignUp({
1271+
primary_email: receiveMailbox.emailAddress,
1272+
primary_email_verified: true,
1273+
});
1274+
await Team.acceptInvitation();
1275+
1276+
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
1277+
accessType: "client",
1278+
method: "GET",
1279+
});
1280+
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
1281+
});
1282+
1283+
it("still allows the legitimate invitee with the matching verified email to accept", async ({ expect }) => {
1284+
// Complements the mismatch test: the new email-match check must not break the happy path.
1285+
await Project.createAndSwitch();
1286+
await Auth.fastSignUp();
1287+
const { teamId } = await Team.create();
1288+
1289+
const receiveMailbox = createMailbox();
1290+
backendContext.set({ userAuth: null });
1291+
await niceBackendFetch("/api/v1/team-invitations/send-code", {
1292+
method: "POST",
1293+
accessType: "server",
1294+
body: {
1295+
email: receiveMailbox.emailAddress,
1296+
team_id: teamId,
1297+
callback_url: "http://localhost:12345/some-callback-url",
1298+
},
1299+
});
1300+
1301+
backendContext.set({ mailbox: receiveMailbox });
1302+
await Auth.fastSignUp({
1303+
primary_email: receiveMailbox.emailAddress,
1304+
primary_email_verified: true,
1305+
});
1306+
await Team.acceptInvitation();
1307+
1308+
const teamsResponse = await niceBackendFetch(`/api/v1/teams?user_id=me`, {
1309+
accessType: "client",
1310+
method: "GET",
1311+
});
1312+
expect(teamsResponse.body.items.find((item: any) => item.id === teamId)).toBeDefined();
1313+
});

packages/stack-shared/src/interface/client-interface.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,7 +1080,7 @@ export class StackClientInterface {
10801080
code: string,
10811081
session: InternalSession,
10821082
type: T,
1083-
}): Promise<Result<T extends 'details' ? { team_display_name: string } : undefined, KnownErrors["VerificationCodeError"]>> {
1083+
}): Promise<Result<T extends 'details' ? { team_display_name: string } : undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
10841084
const res = await this.sendClientRequestAndCatchKnownError(
10851085
options.type === 'check' ?
10861086
"/team-invitations/accept/check-code" :
@@ -1097,7 +1097,7 @@ export class StackClientInterface {
10971097
}),
10981098
},
10991099
options.session,
1100-
[KnownErrors.VerificationCodeError]
1100+
[KnownErrors.VerificationCodeError, KnownErrors.TeamInvitationEmailMismatch]
11011101
);
11021102

11031103
if (res.status === "error") {

packages/stack-shared/src/known-errors.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,6 +1132,16 @@ const TeamInvitationRestrictedUserNotAllowed = createKnownErrorConstructor(
11321132
(json: any) => [json.restricted_reason ?? { type: "anonymous" }] as const,
11331133
);
11341134

1135+
const TeamInvitationEmailMismatch = createKnownErrorConstructor(
1136+
KnownError,
1137+
"TEAM_INVITATION_EMAIL_MISMATCH",
1138+
() => [
1139+
403,
1140+
"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.",
1141+
] as const,
1142+
() => [] as const,
1143+
);
1144+
11351145

11361146
const EmailTemplateAlreadyExists = createKnownErrorConstructor(
11371147
KnownError,
@@ -1942,6 +1952,7 @@ export const KnownErrors = {
19421952
TeamNotFound,
19431953
TeamMembershipNotFound,
19441954
TeamInvitationRestrictedUserNotAllowed,
1955+
TeamInvitationEmailMismatch,
19451956
EmailTemplateAlreadyExists,
19461957
OAuthConnectionNotConnectedToUser,
19471958
OAuthConnectionAlreadyConnectedToAnotherUser,

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2540,15 +2540,15 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
25402540
return await this._interface.verifyPasswordResetCode(code);
25412541
}
25422542

2543-
async verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>> {
2543+
async verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
25442544
return await this._interface.acceptTeamInvitation({
25452545
type: 'check',
25462546
code,
25472547
session: await this._getSession(),
25482548
});
25492549
}
25502550

2551-
async acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>> {
2551+
async acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
25522552
const result = await this._interface.acceptTeamInvitation({
25532553
type: 'use',
25542554
code,
@@ -2562,7 +2562,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
25622562
}
25632563
}
25642564

2565-
async getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"]>> {
2565+
async getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
25662566
const result = await this._interface.acceptTeamInvitation({
25672567
type: 'details',
25682568
code,

packages/template/src/lib/stack-app/apps/interfaces/client-app.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,9 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
7373
sendMagicLinkEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<{ nonce: string }, KnownErrors["RedirectUrlNotWhitelisted"] | KnownErrors["BotChallengeFailed"]>>,
7474
resetPassword(options: { code: string, password: string }): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
7575
verifyPasswordResetCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
76-
verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
77-
acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
78-
getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"]>>,
76+
verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
77+
acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
78+
getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
7979
verifyEmail(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
8080
signInWithMagicLink(code: string, options?: { noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"]>>,
8181
signInWithMfa(otp: string, code: string, options?: { noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"]>>,

0 commit comments

Comments
 (0)