mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-12 19:39:00 -05:00
fix: sso account deletion password check
This commit is contained in:
+12
-7
@@ -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}
|
||||
|
||||
+13
-11
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
+2
@@ -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");
|
||||
});
|
||||
|
||||
Vendored
+1
-1
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user