fix: sso account deletion password check

This commit is contained in:
Tiago Farto
2026-05-04 15:30:47 +00:00
parent fae00f6a82
commit 738ff6c2ff
20 changed files with 1501 additions and 35 deletions
@@ -9,19 +9,23 @@ import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountMo
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
type TDeleteAccountProps = {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
requiresPasswordConfirmation: boolean;
};
export const DeleteAccount = ({
session,
IS_FORMBRICKS_CLOUD,
user,
organizationsWithSingleOwner,
isMultiOrgEnabled,
}: {
session: Session | null;
IS_FORMBRICKS_CLOUD: boolean;
user: TUser;
organizationsWithSingleOwner: TOrganization[];
isMultiOrgEnabled: boolean;
}) => {
requiresPasswordConfirmation,
}: Readonly<TDeleteAccountProps>) => {
const [isModalOpen, setModalOpen] = useState(false);
const isDeleteDisabled = !isMultiOrgEnabled && organizationsWithSingleOwner.length > 0;
const { t } = useTranslation();
@@ -32,6 +36,7 @@ export const DeleteAccount = ({
return (
<div>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setModalOpen}
user={user}
@@ -40,7 +40,7 @@ describe("User Library Tests", () => {
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
@@ -56,7 +56,7 @@ describe("User Library Tests", () => {
expect(result).toBe(false);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true },
});
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
@@ -68,27 +68,29 @@ describe("User Library Tests", () => {
await expect(verifyUserPassword(userId, password)).rejects.toThrow(`user with ID ${userId} not found`);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
test("should throw InvalidInputError if identityProvider is not email", async () => {
test("should verify passwords for users with a password regardless of identity provider", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: "hashed-password",
identityProvider: "google", // Not 'email'
identityProvider: "google",
} as any);
mockVerifyPasswordUtil.mockResolvedValue(true);
await expect(verifyUserPassword(userId, password)).rejects.toThrow(InvalidInputError);
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
const result = await verifyUserPassword(userId, password);
expect(result).toBe(true);
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
expect(mockVerifyPasswordUtil).toHaveBeenCalledWith(password, "hashed-password");
});
test("should throw InvalidInputError if password is not set for email provider", async () => {
test("should throw InvalidInputError if password is not set", async () => {
mockPrismaUserFindUnique.mockResolvedValue({
password: null, // Password not set
identityProvider: "email",
@@ -98,7 +100,7 @@ describe("User Library Tests", () => {
await expect(verifyUserPassword(userId, password)).rejects.toThrow("Password is not set for this user");
expect(mockPrismaUserFindUnique).toHaveBeenCalledWith({
where: { id: userId },
select: { password: true, identityProvider: true },
select: { email: true, password: true, identityProvider: true, identityProviderAccountId: true },
});
expect(mockVerifyPasswordUtil).not.toHaveBeenCalled();
});
@@ -5,6 +5,7 @@ import { EMAIL_VERIFICATION_DISABLED, IS_FORMBRICKS_CLOUD, PASSWORD_RESET_DISABL
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccountDeletionAuthRequirements } from "@/modules/account/lib/account-deletion-auth";
import { getIsMultiOrgEnabled, getIsTwoFactorAuthEnabled } from "@/modules/ee/license-check/lib/utils";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { IdBadge } from "@/modules/ui/components/id-badge";
@@ -32,6 +33,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
throw new AuthenticationError(t("common.not_authenticated"));
}
const accountDeletionAuthRequirements = await getAccountDeletionAuthRequirements(user.id);
const isPasswordResetEnabled = !PASSWORD_RESET_DISABLED && user.identityProvider === "email";
return (
@@ -90,6 +92,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
user={user}
organizationsWithSingleOwner={organizationsWithSingleOwner}
isMultiOrgEnabled={isMultiOrgEnabled}
requiresPasswordConfirmation={accountDeletionAuthRequirements.requiresPasswordConfirmation}
/>
</SettingsCard>
<IdBadge id={user.id} label={t("common.profile_id")} variant="column" />
@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
const getIntentToken = (intent: string | string[] | undefined) => {
if (Array.isArray(intent)) {
return intent[0];
}
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
return "/auth/login";
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
export default async function AccountDeletionSsoReauthCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
const intentToken = getIntentToken((await searchParams).intent);
let redirectPath = "/auth/login";
if (intentToken) {
try {
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(intent.returnToUrl);
} catch (error) {
logger.error({ error }, "Failed to resolve account deletion SSO reauth callback");
}
}
redirect(redirectPath);
}
+85
View File
@@ -35,6 +35,16 @@ type TSsoRelinkIntentPayload = {
userId: string;
};
type TAccountDeletionSsoReauthIntentPayload = {
id: string;
email: string;
provider: string;
providerAccountId: string;
purpose: "account_deletion_sso_reauth";
returnToUrl: string;
userId: string;
};
const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification";
const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => {
@@ -262,6 +272,10 @@ const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = {
expiresIn: "15m",
};
const DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS: SignOptions = {
expiresIn: "10m",
};
export const createSsoRelinkIntent = (
payload: TSsoRelinkIntentPayload,
options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS
@@ -323,6 +337,77 @@ export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload =>
};
};
export const createAccountDeletionSsoReauthIntent = (
payload: TAccountDeletionSsoReauthIntentPayload,
options: SignOptions = DEFAULT_ACCOUNT_DELETION_SSO_REAUTH_INTENT_OPTIONS
): string => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
return jwt.sign(
{
id: symmetricEncrypt(payload.id, ENCRYPTION_KEY),
userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY),
email: symmetricEncrypt(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY),
purpose: payload.purpose,
returnToUrl: symmetricEncrypt(payload.returnToUrl, ENCRYPTION_KEY),
},
NEXTAUTH_SECRET,
options
);
};
export const verifyAccountDeletionSsoReauthIntent = (
token: string
): TAccountDeletionSsoReauthIntentPayload => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
}
if (!ENCRYPTION_KEY) {
throw new Error("ENCRYPTION_KEY is not set");
}
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
id: string;
userId: string;
email: string;
provider: string;
providerAccountId: string;
purpose: string;
returnToUrl: string;
};
if (
!payload?.id ||
!payload?.userId ||
!payload?.email ||
!payload?.provider ||
!payload?.providerAccountId ||
payload?.purpose !== "account_deletion_sso_reauth" ||
!payload?.returnToUrl
) {
throw new Error("Token is invalid or missing required fields");
}
return {
id: decryptWithFallback(payload.id, ENCRYPTION_KEY),
userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY),
email: decryptWithFallback(payload.email, ENCRYPTION_KEY),
provider: payload.provider,
providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY),
purpose: "account_deletion_sso_reauth",
returnToUrl: decryptWithFallback(payload.returnToUrl, ENCRYPTION_KEY),
};
};
export const verifyToken = async (token: string): Promise<TVerifyTokenPayload> => {
if (!NEXTAUTH_SECRET) {
throw new Error("NEXTAUTH_SECRET is not set");
+7 -3
View File
@@ -5,15 +5,19 @@ import { prisma } from "@formbricks/database";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { verifyPassword } from "@/modules/auth/lib/utils";
const getUserAuthenticationData = reactCache(
async (userId: string): Promise<Pick<User, "password" | "identityProvider">> => {
export const getUserAuthenticationData = reactCache(
async (
userId: string
): Promise<Pick<User, "email" | "password" | "identityProvider" | "identityProviderAccountId">> => {
const user = await prisma.user.findUnique({
where: {
id: userId,
},
select: {
email: true,
password: true,
identityProvider: true,
identityProviderAccountId: true,
},
});
@@ -28,7 +32,7 @@ const getUserAuthenticationData = reactCache(
export const verifyUserPassword = async (userId: string, password: string): Promise<boolean> => {
const user = await getUserAuthenticationData(userId);
if (user.identityProvider !== "email" || !user.password) {
if (!user.password) {
throw new InvalidInputError("Password is not set for this user");
}
@@ -0,0 +1,254 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import {
AuthorizationError,
InvalidInputError,
OperationNotAllowedError,
TooManyRequestsError,
} from "@formbricks/types/errors";
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
import { DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR, DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
const mocks = vi.hoisted(() => ({
applyRateLimit: vi.fn(),
consumeAccountDeletionSsoReauthentication: vi.fn(),
deleteUser: vi.fn(),
getIsMultiOrgEnabled: vi.fn(),
getOrganizationsWhereUserIsSingleOwner: vi.fn(),
getUser: vi.fn(),
getUserAuthenticationData: vi.fn(),
loggerError: vi.fn(),
startAccountDeletionSsoReauthentication: vi.fn(),
verifyUserPassword: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/lib/organization/service", () => ({
getOrganizationsWhereUserIsSingleOwner: mocks.getOrganizationsWhereUserIsSingleOwner,
}));
vi.mock("@/lib/user/password", () => ({
getUserAuthenticationData: mocks.getUserAuthenticationData,
verifyUserPassword: mocks.verifyUserPassword,
}));
vi.mock("@/lib/user/service", () => ({
deleteUser: mocks.deleteUser,
getUser: mocks.getUser,
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((handler) => handler),
})),
},
}));
vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
consumeAccountDeletionSsoReauthentication: mocks.consumeAccountDeletionSsoReauthentication,
startAccountDeletionSsoReauthentication: mocks.startAccountDeletionSsoReauthentication,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: mocks.applyRateLimit,
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
actions: {
accountDeletion: { interval: 3600, allowedPerInterval: 5, namespace: "action:account-delete" },
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((_action, _target, handler) => handler),
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsMultiOrgEnabled: mocks.getIsMultiOrgEnabled,
}));
const user = {
email: "delete-user@example.com",
id: "user-id",
};
const createActionContext = () => ({
auditLoggingCtx: {},
user,
});
describe("delete account actions", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyRateLimit.mockResolvedValue({ allowed: true });
mocks.consumeAccountDeletionSsoReauthentication.mockResolvedValue(undefined);
mocks.deleteUser.mockResolvedValue(user);
mocks.getIsMultiOrgEnabled.mockResolvedValue(true);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValue([]);
mocks.getUser.mockResolvedValue(user);
mocks.getUserAuthenticationData.mockResolvedValue({
email: user.email,
identityProvider: "email",
identityProviderAccountId: null,
password: "hashed-password",
});
mocks.startAccountDeletionSsoReauthentication.mockResolvedValue({
authorizationParams: { login_hint: user.email, max_age: "0", prompt: "login" },
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("rate-limits malformed delete attempts before parsing the payload", async () => {
const rateLimitError = new TooManyRequestsError("Maximum number of requests reached");
mocks.applyRateLimit.mockRejectedValueOnce(rateLimitError);
await expect(
deleteUserAction({
ctx: createActionContext(),
parsedInput: null,
} as any)
).rejects.toThrow(TooManyRequestsError);
expect(mocks.applyRateLimit).toHaveBeenCalled();
expect(mocks.getUserAuthenticationData).not.toHaveBeenCalled();
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
test("rejects malformed delete payloads after rate limiting", async () => {
await expect(
deleteUserAction({
ctx: createActionContext(),
parsedInput: { confirmationEmail: user.email, extra: "unexpected" },
} as any)
).rejects.toThrow(InvalidInputError);
expect(mocks.applyRateLimit).toHaveBeenCalled();
expect(mocks.getUserAuthenticationData).not.toHaveBeenCalled();
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
test("requires a password for password-backed users", async () => {
await expect(
deleteUserAction({
ctx: createActionContext(),
parsedInput: { confirmationEmail: user.email },
} as any)
).rejects.toThrow(InvalidInputError);
expect(mocks.verifyUserPassword).not.toHaveBeenCalled();
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
test("returns the wrong password error for password-backed users with an invalid password", async () => {
mocks.verifyUserPassword.mockResolvedValueOnce(false);
await expect(
deleteUserAction({
ctx: createActionContext(),
parsedInput: { confirmationEmail: user.email, password: "wrong-password" },
} as any)
).rejects.toThrow(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
expect(mocks.verifyUserPassword).toHaveBeenCalledWith(user.id, "wrong-password");
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
test("deletes password-backed users with matching email and password", async () => {
const result = await deleteUserAction({
ctx: createActionContext(),
parsedInput: { confirmationEmail: user.email.toUpperCase(), password: "correct-password" },
} as any);
expect(mocks.verifyUserPassword).toHaveBeenCalledWith(user.id, "correct-password");
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
expect(result).toEqual({ success: true });
});
test("rejects direct email-only deletion for SSO users without a completed reauth marker", async () => {
mocks.getUserAuthenticationData.mockResolvedValueOnce({
email: user.email,
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
});
mocks.consumeAccountDeletionSsoReauthentication.mockRejectedValueOnce(
new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR)
);
await expect(
deleteUserAction({
ctx: createActionContext(),
parsedInput: { confirmationEmail: user.email },
} as any)
).rejects.toThrow(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
identityProvider: "google",
providerAccountId: "google-account-id",
userId: user.id,
});
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
test("deletes SSO users after consuming a completed reauth marker", async () => {
mocks.getUserAuthenticationData.mockResolvedValueOnce({
email: user.email,
identityProvider: "google",
identityProviderAccountId: "google-account-id",
password: null,
});
const result = await deleteUserAction({
ctx: createActionContext(),
parsedInput: { confirmationEmail: user.email },
} as any);
expect(mocks.verifyUserPassword).not.toHaveBeenCalled();
expect(mocks.consumeAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
identityProvider: "google",
providerAccountId: "google-account-id",
userId: user.id,
});
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
expect(result).toEqual({ success: true });
});
test("preserves the single-owner organization guard", async () => {
mocks.getIsMultiOrgEnabled.mockResolvedValueOnce(false);
mocks.getOrganizationsWhereUserIsSingleOwner.mockResolvedValueOnce([{ id: "org-id" }]);
await expect(
deleteUserAction({
ctx: createActionContext(),
parsedInput: { confirmationEmail: user.email, password: "correct-password" },
} as any)
).rejects.toThrow(OperationNotAllowedError);
expect(mocks.deleteUser).not.toHaveBeenCalled();
});
test("rate-limits malformed SSO reauthentication starts before parsing the payload", async () => {
const rateLimitError = new TooManyRequestsError("Maximum number of requests reached");
mocks.applyRateLimit.mockRejectedValueOnce(rateLimitError);
await expect(
startAccountDeletionSsoReauthenticationAction({
ctx: createActionContext(),
parsedInput: null,
} as any)
).rejects.toThrow(TooManyRequestsError);
expect(mocks.startAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
});
});
@@ -4,9 +4,14 @@ import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { verifyUserPassword } from "@/lib/user/password";
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { requiresPasswordConfirmationForAccountDeletion } from "@/modules/account/lib/account-deletion-auth";
import {
consumeAccountDeletionSsoReauthentication,
startAccountDeletionSsoReauthentication,
} from "@/modules/account/lib/account-deletion-sso-reauth";
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
@@ -23,6 +28,13 @@ const ZDeleteUserConfirmation = z
})
.strict();
const ZStartAccountDeletionSsoReauth = z
.object({
confirmationEmail: z.string().trim().min(1).max(255),
returnToUrl: z.string().trim().min(1).max(2048),
})
.strict();
const parseDeleteUserConfirmation = (input: unknown) => {
const parsedInput = ZDeleteUserConfirmation.safeParse(input);
@@ -33,6 +45,16 @@ const parseDeleteUserConfirmation = (input: unknown) => {
return parsedInput.data;
};
const parseStartAccountDeletionSsoReauthInput = (input: unknown) => {
const parsedInput = ZStartAccountDeletionSsoReauth.safeParse(input);
if (!parsedInput.success) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
}
return parsedInput.data;
};
const getPasswordOrThrow = (password?: string) => {
if (!password) {
throw new InvalidInputError(DELETE_USER_CONFIRMATION_REQUIRED_ERROR);
@@ -45,6 +67,25 @@ const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
.inputSchema(z.unknown())
.action(async ({ ctx, parsedInput }) => {
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, returnToUrl } = parseStartAccountDeletionSsoReauthInput(parsedInput);
return await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl,
userId: ctx.user.id,
});
} catch (error) {
logger.error({ error, userId: ctx.user.id }, "Account deletion SSO reauthentication failed");
throw error;
}
});
export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown()).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
@@ -52,18 +93,25 @@ export const deleteUserAction = authenticatedActionClient.inputSchema(z.unknown(
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const isPasswordBackedAccount = ctx.user.identityProvider === "email";
const { confirmationEmail, password } = parseDeleteUserConfirmation(parsedInput);
if (confirmationEmail.toLowerCase() !== ctx.user.email.toLowerCase()) {
throw new AuthorizationError("Email confirmation does not match");
}
if (isPasswordBackedAccount) {
const userAuthenticationData = await getUserAuthenticationData(ctx.user.id);
if (requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
const isCorrectPassword = await verifyUserPassword(ctx.user.id, getPasswordOrThrow(password));
if (!isCorrectPassword) {
throw new AuthorizationError(DELETE_ACCOUNT_WRONG_PASSWORD_ERROR);
}
} else {
await consumeAccountDeletionSsoReauthentication({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId: ctx.user.id,
});
}
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
@@ -1 +1,4 @@
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
export {
DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR,
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
} from "@/modules/account/constants";
@@ -1,5 +1,6 @@
"use client";
import { signIn } from "next-auth/react";
import { Dispatch, SetStateAction, useState } from "react";
import toast from "react-hot-toast";
import { Trans, useTranslation } from "react-i18next";
@@ -11,10 +12,11 @@ import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { Input } from "@/modules/ui/components/input";
import { PasswordInput } from "@/modules/ui/components/password-input";
import { deleteUserAction } from "./actions";
import { DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
import { deleteUserAction, startAccountDeletionSsoReauthenticationAction } from "./actions";
import { DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR, DELETE_ACCOUNT_WRONG_PASSWORD_ERROR } from "./constants";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
open: boolean;
setOpen: Dispatch<SetStateAction<boolean>>;
user: TUser;
@@ -23,18 +25,18 @@ interface DeleteAccountModalProps {
}
export const DeleteAccountModal = ({
requiresPasswordConfirmation,
setOpen,
open,
user,
isFormbricksCloud,
organizationsWithSingleOwner,
}: DeleteAccountModalProps) => {
}: Readonly<DeleteAccountModalProps>) => {
const { t } = useTranslation();
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const [password, setPassword] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const isPasswordBackedAccount = user.identityProvider === "email";
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -48,9 +50,35 @@ export const DeleteAccountModal = ({
};
const hasValidEmailConfirmation = inputValue.trim().toLowerCase() === user.email.toLowerCase();
const hasValidConfirmation = hasValidEmailConfirmation && (!isPasswordBackedAccount || password.length > 0);
const hasValidConfirmation =
hasValidEmailConfirmation && (!requiresPasswordConfirmation || password.length > 0);
const isDeleteDisabled = !hasValidConfirmation;
const startSsoReauthentication = async () => {
const result = await startAccountDeletionSsoReauthenticationAction({
confirmationEmail: inputValue,
returnToUrl: window.location.href,
});
if (!result?.data) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
const errorMessage = result ? getFormattedErrorMessage(result) : fallbackErrorMessage;
logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed");
toast.error(fallbackErrorMessage);
return;
}
await signIn(
result.data.provider,
{
callbackUrl: result.data.callbackUrl,
redirect: true,
},
result.data.authorizationParams
);
};
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
@@ -59,7 +87,7 @@ export const DeleteAccountModal = ({
setDeleting(true);
const result = await deleteUserAction(
isPasswordBackedAccount
requiresPasswordConfirmation
? {
confirmationEmail: inputValue,
password,
@@ -75,6 +103,9 @@ export const DeleteAccountModal = ({
if (result?.serverError === DELETE_ACCOUNT_WRONG_PASSWORD_ERROR) {
errorMessage = t("environments.settings.profile.wrong_password");
} else if (result?.serverError === DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR) {
await startSsoReauthentication();
return;
} else if (result) {
errorMessage = getFormattedErrorMessage(result);
}
@@ -161,7 +192,7 @@ export const DeleteAccountModal = ({
id="deleteAccountConfirmation"
name="deleteAccountConfirmation"
/>
{isPasswordBackedAccount && (
{requiresPasswordConfirmation && (
<>
<label htmlFor="deleteAccountPassword" className="mt-4 block">
{t("common.password")}
+6
View File
@@ -0,0 +1,6 @@
export const ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH = "/auth/account-deletion/sso/complete";
export const DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR =
"SSO reauthentication is required to delete this account.";
export const DELETE_ACCOUNT_WRONG_PASSWORD_ERROR = "Wrong password";
@@ -0,0 +1,23 @@
import "server-only";
import type { User } from "@prisma/client";
import { getUserAuthenticationData } from "@/lib/user/password";
type TAccountDeletionPasswordAuthData = Pick<User, "password">;
export type TAccountDeletionAuthRequirements = {
requiresPasswordConfirmation: boolean;
};
export const requiresPasswordConfirmationForAccountDeletion = ({
password,
}: TAccountDeletionPasswordAuthData): boolean => Boolean(password);
export const getAccountDeletionAuthRequirements = async (
userId: string
): Promise<TAccountDeletionAuthRequirements> => {
const userAuthenticationData = await getUserAuthenticationData(userId);
return {
requiresPasswordConfirmation: requiresPasswordConfirmationForAccountDeletion(userAuthenticationData),
};
};
@@ -0,0 +1,321 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "@/lib/cache";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import {
completeAccountDeletionSsoReauthentication,
consumeAccountDeletionSsoReauthentication,
startAccountDeletionSsoReauthentication,
validateAccountDeletionSsoReauthenticationCallback,
} from "./account-deletion-sso-reauth";
vi.mock("@formbricks/database", () => ({
prisma: {
account: {
findUnique: vi.fn(),
},
user: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
getRedisClient: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
SAML_PRODUCT: "formbricks",
SAML_TENANT: "formbricks.com",
WEBAPP_URL: "http://localhost:3000",
};
});
vi.mock("@/lib/jwt", () => ({
createAccountDeletionSsoReauthIntent: vi.fn(),
verifyAccountDeletionSsoReauthIntent: vi.fn(),
}));
vi.mock("@/lib/user/password", () => ({
getUserAuthenticationData: vi.fn(),
}));
const mockCache = vi.mocked(cache);
const mockPrismaAccountFindUnique = vi.mocked(prisma.account.findUnique);
const mockPrismaUserFindFirst = vi.mocked(prisma.user.findFirst);
const mockCreateAccountDeletionSsoReauthIntent = vi.mocked(createAccountDeletionSsoReauthIntent);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockGetUserAuthenticationData = vi.mocked(getUserAuthenticationData);
const intent = {
id: "intent-id",
email: "sso-user@example.com",
provider: "google",
providerAccountId: "google-account-id",
purpose: "account_deletion_sso_reauth" as const,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
userId: "user-id",
};
const storedIntent = {
id: intent.id,
provider: intent.provider,
providerAccountId: intent.providerAccountId,
userId: intent.userId,
};
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
describe("account deletion SSO reauthentication", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
mockCache.getRedisClient.mockResolvedValue(null);
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockPrismaUserFindFirst.mockResolvedValue(null);
});
afterEach(() => {
vi.restoreAllMocks();
});
test("starts SSO reauthentication with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: "SSO-USER@example.com",
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(mockCache.set).toHaveBeenCalledWith(expect.any(String), storedIntent, 10 * 60 * 1000);
expect(mockCreateAccountDeletionSsoReauthIntent).toHaveBeenCalledWith({
...intent,
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
});
expect(result).toEqual({
authorizationParams: {
login_hint: intent.email,
max_age: "0",
prompt: "login",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
});
test("does not start SSO reauthentication for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
identityProviderAccountId: intent.providerAccountId,
password: "hashed-password",
} as any);
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow(InvalidInputError);
expect(mockCache.set).not.toHaveBeenCalled();
});
test("fails SSO completion when the callback provider does not match the intent", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).toHaveBeenCalled();
});
test("rejects a mismatched SSO callback before reading or consuming the intent", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "github",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("validates a fresh SSO callback before the normal SSO handler runs", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects stale OIDC auth_time claims", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds - 10 * 60),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockPrismaAccountFindUnique).not.toHaveBeenCalled();
});
test("stores a deletion marker after fresh SSO reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: intent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedIntent),
5 * 60 * 1000
);
});
test("fails SSO completion when the provider account belongs to another user", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockPrismaAccountFindUnique.mockResolvedValue({ userId: "other-user-id" } as any);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
});
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
mockCache.get.mockResolvedValue({ ok: true, data: null });
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO reauthentication marker", async () => {
mockCache.get.mockResolvedValue({
ok: true,
data: {
...storedIntent,
completedAt: Date.now(),
},
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(mockCache.del).toHaveBeenCalled();
});
test("rejects an expired SSO reauthentication marker", async () => {
mockCache.get.mockResolvedValue({
ok: true,
data: {
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
},
});
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).toHaveBeenCalled();
});
});
@@ -0,0 +1,521 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
import jwt, { type JwtPayload } from "jsonwebtoken";
import type { Account } from "next-auth";
import { createCacheKey } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError } from "@formbricks/types/errors";
import { cache } from "@/lib/cache";
import { SAML_PRODUCT, SAML_TENANT, WEBAPP_URL } from "@/lib/constants";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import {
ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH,
DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR,
} from "@/modules/account/constants";
import {
getSsoProviderLookupCandidates,
normalizeSsoProvider,
} from "@/modules/ee/sso/lib/provider-normalization";
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
const OIDC_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
const OIDC_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
type TStoredAccountDeletionSsoReauthIntent = {
id: string;
provider: TSsoIdentityProvider;
providerAccountId: string;
userId: string;
};
type TAccountDeletionSsoReauthMarker = TStoredAccountDeletionSsoReauthIntent & {
completedAt: number;
};
type TStartAccountDeletionSsoReauthenticationInput = {
confirmationEmail: string;
returnToUrl: string;
userId: string;
};
export type TStartAccountDeletionSsoReauthenticationResult = {
authorizationParams: Record<string, string>;
callbackUrl: string;
provider: string;
};
const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
azuread: "azure-ad",
github: "github",
google: "google",
openid: "openid",
saml: "saml",
} as const satisfies Record<TSsoIdentityProvider, string>;
const OIDC_REAUTH_PROVIDERS = new Set<IdentityProvider>(["azuread", "google", "openid"]);
const getAccountDeletionSsoReauthIntentKey = (intentId: string) =>
createCacheKey.custom("account_deletion", "sso_reauth_intent", intentId);
const getAccountDeletionSsoReauthMarkerKey = (userId: string) =>
createCacheKey.custom("account_deletion", userId, "sso_reauth_complete");
const getSsoIdentityProviderOrThrow = (
identityProvider: IdentityProvider,
providerAccountId: string | null
): { provider: TSsoIdentityProvider; providerAccountId: string } => {
if (identityProvider === "email" || !providerAccountId) {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
return { provider: identityProvider, providerAccountId };
};
const getAccountDeletionSsoReauthAuthorizationParams = (
provider: TSsoIdentityProvider,
email: string
): Record<string, string> => {
if (provider === "saml") {
return {
forceAuthn: "true",
product: SAML_PRODUCT,
provider: "saml",
tenant: SAML_TENANT,
};
}
if (OIDC_REAUTH_PROVIDERS.has(provider)) {
return {
login_hint: email,
max_age: "0",
prompt: "login",
};
}
return {
login: email,
prompt: "login",
};
};
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
const callbackUrl = new URL(ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH, WEBAPP_URL);
callbackUrl.searchParams.set("intent", intentToken);
return callbackUrl.toString();
};
export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: string): string | null => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
if (!validatedCallbackUrl) {
return null;
}
const parsedCallbackUrl = new URL(validatedCallbackUrl);
if (parsedCallbackUrl.pathname !== ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH) {
return null;
}
return parsedCallbackUrl.searchParams.get("intent");
};
const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
const cacheKey = getAccountDeletionSsoReauthIntentKey(intent.id);
const result = await cache.set(cacheKey, intent, ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS);
if (!result.ok) {
logger.error(
{ error: result.error, intentId: intent.id, userId: intent.userId },
"Failed to store SSO reauth intent"
);
throw new Error("Unable to start account deletion SSO reauthentication");
}
};
const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoReauthMarker) => {
const cacheKey = getAccountDeletionSsoReauthMarkerKey(marker.userId);
const result = await cache.set(cacheKey, marker, ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS);
if (!result.ok) {
logger.error(
{ error: result.error, intentId: marker.id, userId: marker.userId },
"Failed to store account deletion SSO reauth marker"
);
throw new Error("Unable to complete account deletion SSO reauthentication");
}
};
const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
let redis;
try {
redis = await cache.getRedisClient();
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to resolve Redis client for SSO reauth cache");
throw error;
}
if (redis) {
try {
const serializedValue = await redis.eval(
`
local value = redis.call("GET", KEYS[1])
if value then
redis.call("DEL", KEYS[1])
end
return value
`,
{
arguments: [],
keys: [key],
}
);
if (serializedValue === null) {
return null;
}
if (typeof serializedValue !== "string") {
logger.error({ ...logContext, key, serializedValue }, "Unexpected cached SSO reauth value");
throw new Error("Unexpected cached account deletion SSO reauth value");
}
return JSON.parse(serializedValue) as TValue;
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
throw error;
}
}
const cacheResult = await cache.get<TValue>(key);
if (!cacheResult.ok) {
logger.error({ ...logContext, error: cacheResult.error, key }, "Failed to read SSO reauth cache value");
throw new Error("Unable to read account deletion SSO reauth value");
}
if (!cacheResult.data) {
return null;
}
const deleteResult = await cache.del([key]);
if (!deleteResult.ok) {
logger.error(
{ ...logContext, error: deleteResult.error, key },
"Failed to consume SSO reauth cache value"
);
throw new Error("Unable to consume account deletion SSO reauth value");
}
return cacheResult.data;
};
const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string, unknown>) => {
const cacheResult = await cache.get<TValue>(key);
if (!cacheResult.ok) {
logger.error({ ...logContext, error: cacheResult.error, key }, "Failed to read SSO reauth cache value");
throw new Error("Unable to read account deletion SSO reauth value");
}
return cacheResult.data;
};
const assertStoredAccountDeletionSsoReauthIntentMatches = (
cachedIntent: TStoredAccountDeletionSsoReauthIntent | null,
intent: TStoredAccountDeletionSsoReauthIntent
) => {
if (
!cachedIntent ||
cachedIntent.userId !== intent.userId ||
cachedIntent.provider !== intent.provider ||
cachedIntent.providerAccountId !== intent.providerAccountId
) {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
};
const consumeStoredAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletionSsoReauthIntent) => {
const cachedIntent = await consumeCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
getAccountDeletionSsoReauthIntentKey(intent.id),
{
intentId: intent.id,
userId: intent.userId,
}
);
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
};
const assertStoredAccountDeletionSsoReauthIntentExists = async (
intent: TStoredAccountDeletionSsoReauthIntent
) => {
const cachedIntent = await getCachedJsonValue<TStoredAccountDeletionSsoReauthIntent>(
getAccountDeletionSsoReauthIntentKey(intent.id),
{
intentId: intent.id,
userId: intent.userId,
}
);
assertStoredAccountDeletionSsoReauthIntentMatches(cachedIntent, intent);
};
const findLinkedSsoUserId = async ({
provider,
providerAccountId,
}: {
provider: TSsoIdentityProvider;
providerAccountId: string;
}) => {
const lookupCandidates = getSsoProviderLookupCandidates(provider);
for (const lookupProvider of lookupCandidates) {
const account = await prisma.account.findUnique({
where: {
provider_providerAccountId: {
provider: lookupProvider,
providerAccountId,
},
},
select: {
userId: true,
},
});
if (account) {
return account.userId;
}
}
const legacyUser = await prisma.user.findFirst({
where: {
identityProvider: provider,
identityProviderAccountId: providerAccountId,
},
select: {
id: true,
},
});
return legacyUser?.id ?? null;
};
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
if (!OIDC_REAUTH_PROVIDERS.has(provider)) {
return;
}
if (!idToken) {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
const decodedToken = jwt.decode(idToken) as JwtPayload | string | null;
if (!decodedToken || typeof decodedToken === "string") {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
const { auth_time: authTime } = decodedToken;
if (typeof authTime !== "number") {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
const nowInSeconds = Math.floor(Date.now() / 1000);
const isTooOld = nowInSeconds - authTime > OIDC_AUTH_TIME_MAX_AGE_SECONDS;
const isFromTheFuture = authTime - nowInSeconds > OIDC_AUTH_TIME_FUTURE_SKEW_SECONDS;
if (isTooOld || isFromTheFuture) {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
};
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
const provider = normalizeSsoProvider(intent.provider);
if (!provider || provider === "email") {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
return {
intent,
storedIntent: {
id: intent.id,
provider,
providerAccountId: intent.providerAccountId,
userId: intent.userId,
},
};
};
const getNormalizedSsoProviderFromAccount = (account: Account) => {
const normalizedProvider = normalizeSsoProvider(account.provider);
if (!normalizedProvider || normalizedProvider === "email") {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
return normalizedProvider;
};
const assertAccountMatchesIntent = ({
account,
intentProvider,
provider,
providerAccountId,
}: {
account: Account;
intentProvider: string;
provider: TSsoIdentityProvider;
providerAccountId: string;
}) => {
if (provider !== intentProvider || account.providerAccountId !== providerAccountId) {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
};
export const startAccountDeletionSsoReauthentication = async ({
confirmationEmail,
returnToUrl,
userId,
}: TStartAccountDeletionSsoReauthenticationInput): Promise<TStartAccountDeletionSsoReauthenticationResult> => {
const userAuthenticationData = await getUserAuthenticationData(userId);
if (confirmationEmail.toLowerCase() !== userAuthenticationData.email.toLowerCase()) {
throw new AuthorizationError("Email confirmation does not match");
}
if (userAuthenticationData.password) {
throw new InvalidInputError("Password confirmation is required to delete this account");
}
const { provider, providerAccountId } = getSsoIdentityProviderOrThrow(
userAuthenticationData.identityProvider,
userAuthenticationData.identityProviderAccountId
);
const intentId = crypto.randomUUID();
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
await storeAccountDeletionSsoReauthIntent({
id: intentId,
provider,
providerAccountId,
userId,
});
const intentToken = createAccountDeletionSsoReauthIntent({
id: intentId,
email: userAuthenticationData.email,
provider,
providerAccountId,
purpose: "account_deletion_sso_reauth",
returnToUrl: validatedReturnToUrl,
userId,
});
return {
authorizationParams: getAccountDeletionSsoReauthAuthorizationParams(
provider,
userAuthenticationData.email
),
callbackUrl: createAccountDeletionSsoReauthCallbackUrl(intentToken),
provider: NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER[provider],
};
};
export const completeAccountDeletionSsoReauthentication = async ({
account,
intentToken,
}: {
account: Account;
intentToken: string;
}) => {
const { intent, storedIntent } = getVerifiedAccountDeletionSsoReauthIntent(intentToken);
const normalizedProvider = getNormalizedSsoProviderFromAccount(account);
await consumeStoredAccountDeletionSsoReauthIntent(storedIntent);
assertAccountMatchesIntent({
account,
intentProvider: intent.provider,
provider: normalizedProvider,
providerAccountId: intent.providerAccountId,
});
assertFreshOidcAuthTime(normalizedProvider, account.id_token);
const linkedUserId = await findLinkedSsoUserId({
provider: normalizedProvider,
providerAccountId: account.providerAccountId,
});
if (linkedUserId !== intent.userId) {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
await storeAccountDeletionSsoReauthMarker({
completedAt: Date.now(),
id: intent.id,
provider: normalizedProvider,
providerAccountId: account.providerAccountId,
userId: intent.userId,
});
};
export const validateAccountDeletionSsoReauthenticationCallback = async ({
account,
intentToken,
}: {
account: Account;
intentToken: string;
}) => {
const { intent, storedIntent } = getVerifiedAccountDeletionSsoReauthIntent(intentToken);
const normalizedProvider = getNormalizedSsoProviderFromAccount(account);
assertAccountMatchesIntent({
account,
intentProvider: intent.provider,
provider: normalizedProvider,
providerAccountId: intent.providerAccountId,
});
assertFreshOidcAuthTime(normalizedProvider, account.id_token);
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
};
export const consumeAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId,
userId,
}: {
identityProvider: IdentityProvider;
providerAccountId: string | null;
userId: string;
}) => {
const { provider, providerAccountId: ssoProviderAccountId } = getSsoIdentityProviderOrThrow(
identityProvider,
providerAccountId
);
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
getAccountDeletionSsoReauthMarkerKey(userId),
{ userId }
);
if (
!marker ||
marker.userId !== userId ||
marker.provider !== provider ||
marker.providerAccountId !== ssoProviderAccountId ||
Date.now() - marker.completedAt > ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS
) {
throw new AuthorizationError(DELETE_ACCOUNT_SSO_REAUTH_REQUIRED_ERROR);
}
};
@@ -53,6 +53,12 @@ vi.mock("@/lib/jwt", () => ({
verifyToken: vi.fn(),
}));
vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: vi.fn(),
getAccountDeletionSsoReauthIntentFromCallbackUrl: vi.fn(),
validateAccountDeletionSsoReauthenticationCallback: vi.fn(),
}));
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
@@ -691,6 +697,78 @@ describe("authOptions", () => {
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
const mockUpdateUserLastLoginAt = vi.fn();
const mockCapturePostHogEvent = vi.fn();
const mockCompleteAccountDeletionSsoReauthentication = vi.fn().mockResolvedValueOnce(undefined);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const mockValidateAccountDeletionSsoReauthenticationCallback = vi.fn().mockResolvedValueOnce(undefined);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012",
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: "test-enterprise-license",
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
POSTHOG_KEY: "phc_test_key",
};
});
vi.doMock("@/modules/ee/sso/lib/providers", () => ({
getSSOProviders: vi.fn(() => []),
}));
vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({
handleSsoCallback: mockHandleSsoCallback,
}));
vi.doMock("@/modules/auth/lib/user", () => ({
updateUser: vi.fn(),
updateUserLastLoginAt: mockUpdateUserLastLoginAt,
}));
vi.doMock("@/lib/posthog", () => ({
capturePostHogEvent: mockCapturePostHogEvent,
}));
vi.doMock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
completeAccountDeletionSsoReauthentication: mockCompleteAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthIntentFromCallbackUrl:
mockGetAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback:
mockValidateAccountDeletionSsoReauthenticationCallback,
}));
const { authOptions: enterpriseAuthOptions } = await import("./authOptions");
const user = { ...mockUser, emailVerified: new Date() };
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true);
expect(mockHandleSsoCallback).toHaveBeenCalled();
expect(mockGetAccountDeletionSsoReauthIntentFromCallbackUrl).toHaveBeenCalled();
expect(mockValidateAccountDeletionSsoReauthenticationCallback).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockCompleteAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
account,
intentToken: "intent-token",
});
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
});
describe("Two-Factor Authentication (TOTP)", () => {
+21
View File
@@ -16,6 +16,11 @@ import {
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
import { verifyToken } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import {
completeAccountDeletionSsoReauthentication,
getAccountDeletionSsoReauthIntentFromCallbackUrl,
validateAccountDeletionSsoReauthenticationCallback,
} from "@/modules/account/lib/account-deletion-sso-reauth";
import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url";
import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking";
import { updateUser } from "@/modules/auth/lib/user";
@@ -345,6 +350,8 @@ export const authOptions: NextAuthOptions = {
// get callback url from the cookie store,
const callbackUrl =
getValidatedCallbackUrl(getAuthCallbackUrlFromCookies(cookieStore), WEBAPP_URL) ?? "";
const accountDeletionSsoReauthIntentToken =
getAccountDeletionSsoReauthIntentFromCallbackUrl(callbackUrl);
const userEmail = user.email ?? "";
const userId = user.id as string;
@@ -372,6 +379,13 @@ export const authOptions: NextAuthOptions = {
return true;
}
if (ENTERPRISE_LICENSE_KEY && account) {
if (accountDeletionSsoReauthIntentToken) {
await validateAccountDeletionSsoReauthenticationCallback({
account,
intentToken: accountDeletionSsoReauthIntentToken,
});
}
const result = await handleSsoCallback({
user: user as TUser,
account,
@@ -379,6 +393,13 @@ export const authOptions: NextAuthOptions = {
});
if (result === true) {
if (accountDeletionSsoReauthIntentToken) {
await completeAccountDeletionSsoReauthentication({
account,
intentToken: accountDeletionSsoReauthIntentToken,
});
}
await finalizeSuccessfulSignIn({
userId,
email: userEmail,
@@ -9,10 +9,15 @@ import { Button } from "@/modules/ui/components/button";
interface RemovedFromOrganizationProps {
isFormbricksCloud: boolean;
requiresPasswordConfirmation: boolean;
user: TUser;
}
export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFromOrganizationProps) => {
export const RemovedFromOrganization = ({
user,
isFormbricksCloud,
requiresPasswordConfirmation,
}: Readonly<RemovedFromOrganizationProps>) => {
const { t } = useTranslation();
const [isModalOpen, setIsModalOpen] = useState(false);
return (
@@ -24,6 +29,7 @@ export const RemovedFromOrganization = ({ user, isFormbricksCloud }: RemovedFrom
<hr className="my-4 border-slate-200" />
<p className="text-sm">{t("setup.organization.create.delete_account_description")}</p>
<DeleteAccountModal
requiresPasswordConfirmation={requiresPasswordConfirmation}
open={isModalOpen}
setOpen={setIsModalOpen}
user={user}
@@ -7,6 +7,7 @@ import { gethasNoOrganizations } from "@/lib/instance/service";
import { getOrganizationsByUserId } from "@/lib/organization/service";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccountDeletionAuthRequirements } from "@/modules/account/lib/account-deletion-auth";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { RemovedFromOrganization } from "@/modules/setup/organization/create/components/removed-from-organization";
@@ -38,7 +39,15 @@ export const CreateOrganizationPage = async () => {
}
if (userOrganizations.length === 0) {
return <RemovedFromOrganization user={user} isFormbricksCloud={IS_FORMBRICKS_CLOUD} />;
const accountDeletionAuthRequirements = await getAccountDeletionAuthRequirements(user.id);
return (
<RemovedFromOrganization
user={user}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
requiresPasswordConfirmation={accountDeletionAuthRequirements.requiresPasswordConfirmation}
/>
);
}
return notFound();
+2
View File
@@ -93,8 +93,10 @@ describe("@formbricks/cache types/keys", () => {
describe("CustomCacheNamespace type", () => {
test("should include expected namespaces", () => {
// Type test - this will fail at compile time if types don't match
const accountDeletionNamespace: CustomCacheNamespace = "account_deletion";
const analyticsNamespace: CustomCacheNamespace = "analytics";
const billingNamespace: CustomCacheNamespace = "billing";
expect(accountDeletionNamespace).toBe("account_deletion");
expect(analyticsNamespace).toBe("analytics");
expect(billingNamespace).toBe("billing");
});
+1 -1
View File
@@ -16,4 +16,4 @@ export type CacheKey = z.infer<typeof ZCacheKey>;
* Possible namespaces for custom cache keys
* Add new namespaces here as they are introduced
*/
export type CustomCacheNamespace = "analytics" | "billing";
export type CustomCacheNamespace = "account_deletion" | "analytics" | "billing";