mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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 -->
This commit is contained in:
parent
9f79bfbe5c
commit
c69a27017b
@ -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);
|
||||
|
||||
@ -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,
|
||||
}, {});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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", {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -1080,7 +1080,7 @@ export class StackClientInterface {
|
||||
code: string,
|
||||
session: InternalSession,
|
||||
type: T,
|
||||
}): Promise<Result<T extends 'details' ? { team_display_name: string } : undefined, KnownErrors["VerificationCodeError"]>> {
|
||||
}): Promise<Result<T extends 'details' ? { team_display_name: string } : undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
|
||||
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") {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -2540,7 +2540,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
return await this._interface.verifyPasswordResetCode(code);
|
||||
}
|
||||
|
||||
async verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>> {
|
||||
async verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
|
||||
return await this._interface.acceptTeamInvitation({
|
||||
type: 'check',
|
||||
code,
|
||||
@ -2548,7 +2548,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
});
|
||||
}
|
||||
|
||||
async acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>> {
|
||||
async acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
|
||||
const result = await this._interface.acceptTeamInvitation({
|
||||
type: 'use',
|
||||
code,
|
||||
@ -2562,7 +2562,7 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
}
|
||||
}
|
||||
|
||||
async getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"]>> {
|
||||
async getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>> {
|
||||
const result = await this._interface.acceptTeamInvitation({
|
||||
type: 'details',
|
||||
code,
|
||||
|
||||
@ -73,9 +73,9 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
|
||||
sendMagicLinkEmail(email: string, options?: { callbackUrl?: string }): Promise<Result<{ nonce: string }, KnownErrors["RedirectUrlNotWhitelisted"] | KnownErrors["BotChallengeFailed"]>>,
|
||||
resetPassword(options: { code: string, password: string }): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
|
||||
verifyPasswordResetCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
|
||||
verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
|
||||
acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
|
||||
getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"]>>,
|
||||
verifyTeamInvitationCode(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
|
||||
acceptTeamInvitation(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
|
||||
getTeamInvitationDetails(code: string): Promise<Result<{ teamDisplayName: string }, KnownErrors["VerificationCodeError"] | KnownErrors["TeamInvitationEmailMismatch"]>>,
|
||||
verifyEmail(code: string): Promise<Result<undefined, KnownErrors["VerificationCodeError"]>>,
|
||||
signInWithMagicLink(code: string, options?: { noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"]>>,
|
||||
signInWithMfa(otp: string, code: string, options?: { noRedirect?: boolean }): Promise<Result<undefined, KnownErrors["VerificationCodeError"] | KnownErrors["InvalidTotpCode"]>>,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user