Compare commits

..

4 Commits

Author SHA1 Message Date
Tiago Farto a20bb0bddd chore: address PR comments (nomenclature consistency) 2026-05-14 13:14:26 +00:00
Tiago Farto 674a43abb4 chore: auth redirect login 2026-05-14 12:25:17 +00:00
Tiago Farto 465654a483 chore: renamed env var, additional checks 2026-05-14 11:47:38 +00:00
Tiago Farto fab93c92bf chore: SSO deletion workflow simplification 2026-05-14 11:26:45 +00:00
54 changed files with 590 additions and 1235 deletions
+4 -6
View File
@@ -107,11 +107,12 @@ PASSWORD_RESET_DISABLED=1
# INVITE_DISABLED=1
###########################################
# Account deletion reauthentication #
# Account deletion SSO confirmation #
###########################################
# Danger: disables fresh SSO reauthentication for passwordless account deletion. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
# Danger: skips the SSO identity confirmation redirect for passwordless account deletion.
# Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept the risk.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
##########
@@ -139,9 +140,6 @@ GITHUB_SECRET=
# Configure Google Login
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Google only returns the auth_time proof after Auth Platform Security Bundle "Session age claims" is enabled.
# Keep this unset until that setting is active for the OAuth app.
# GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Configure Azure Active Directory Login
AZUREAD_CLIENT_ID=
@@ -8,8 +8,8 @@ import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { DeleteAccountModal } from "@/modules/account/components/DeleteAccountModal";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import { Button } from "@/modules/ui/components/button";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
@@ -42,21 +42,18 @@ export const DeleteAccount = ({
const hasShownAccountDeletionError = useRef(false);
useEffect(() => {
if (!accountDeletionErrorCode || hasShownAccountDeletionError.current) {
if (
accountDeletionErrorCode !== ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE ||
hasShownAccountDeletionError.current
) {
return;
}
hasShownAccountDeletionError.current = true;
if (accountDeletionErrorCode === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
toast.error(t("environments.settings.profile.google_sso_account_deletion_requires_setup"), {
id: "account-deletion-sso-reauth-error",
});
} else {
toast.error(t("environments.settings.profile.sso_reauthentication_failed"), {
id: "account-deletion-sso-reauth-error",
});
}
toast.error(t("environments.settings.profile.sso_identity_confirmation_failed"), {
id: "account-deletion-sso-confirmation-error",
});
const url = new URL(globalThis.location.href);
url.searchParams.delete(ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM);
@@ -4,8 +4,8 @@ import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./account-deletion-sso-complete";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./account-deletion-sso-complete";
vi.mock("server-only", () => ({}));
@@ -37,15 +37,15 @@ vi.mock("@/modules/auth/lib/authOptions", () => ({
authOptions: {},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: vi.fn(),
vi.mock("@/modules/account/lib/account-deletion-audit", () => ({
queueAccountDeletionAuditEvent: vi.fn(),
}));
const mockGetServerSession = vi.mocked(getServerSession);
const mockLoggerError = vi.mocked(logger.error);
const mockVerifyAccountDeletionSsoReauthIntent = vi.mocked(verifyAccountDeletionSsoReauthIntent);
const mockDeleteUserWithAccountDeletionAuthorization = vi.mocked(deleteUserWithAccountDeletionAuthorization);
const mockQueueAuditEventBackground = vi.mocked(queueAuditEventBackground);
const mockQueueAccountDeletionAuditEvent = vi.mocked(queueAccountDeletionAuditEvent);
const intent = {
id: "intent-id",
@@ -57,7 +57,7 @@ const intent = {
userId: "user-id",
};
describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
describe("completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -71,22 +71,22 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
mockDeleteUserWithAccountDeletionAuthorization.mockResolvedValue({
oldUser: { id: intent.userId } as any,
});
mockQueueAuditEventBackground.mockResolvedValue(undefined);
mockQueueAccountDeletionAuditEvent.mockResolvedValue(undefined);
});
test("returns login without deleting when the callback has no intent", async () => {
await expect(completeAccountDeletionSsoReauthenticationAndGetRedirectPath({})).resolves.toBe(
await expect(completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({})).resolves.toBe(
"/auth/login"
);
expect(mockVerifyAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockQueueAuditEventBackground).not.toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).not.toHaveBeenCalled();
});
test("deletes the account after a completed SSO reauthentication", async () => {
test("deletes the account after a completed SSO identity confirmation", async () => {
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalledWith({
@@ -94,15 +94,10 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
userEmail: intent.email,
userId: intent.userId,
});
expect(mockQueueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: intent.userId,
userType: "user",
targetId: intent.userId,
organizationId: "unknown",
oldObject: { id: intent.userId },
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
oldUser: { id: intent.userId },
status: "success",
targetUserId: intent.userId,
});
});
@@ -115,27 +110,43 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile");
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(AuthorizationError) },
"Failed to complete account deletion after SSO reauth"
"Failed to complete account deletion after SSO identity confirmation"
);
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAuditEventBackground.mockRejectedValue(new Error("audit unavailable"));
test("returns to the profile page with an error when deletion fails after SSO identity confirmation", async () => {
mockDeleteUserWithAccountDeletionAuthorization.mockRejectedValue(
new AuthorizationError("marker missing")
);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: "intent-token" })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockQueueAccountDeletionAuditEvent).toHaveBeenCalledWith({
status: "failure",
targetUserId: intent.userId,
});
});
test("keeps the post-deletion redirect if audit logging fails after deletion", async () => {
mockQueueAccountDeletionAuditEvent.mockRejectedValue(new Error("audit unavailable"));
await expect(
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: "intent-token" })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).toHaveBeenCalled();
expect(mockLoggerError).toHaveBeenCalledWith(
{ error: expect.any(Error) },
"Failed to complete account deletion after SSO reauth"
"Failed to complete account deletion after SSO identity confirmation"
);
});
@@ -152,7 +163,7 @@ describe("completeAccountDeletionSsoReauthenticationAndGetRedirectPath", () => {
} as any);
await expect(
completeAccountDeletionSsoReauthenticationAndGetRedirectPath({ intent: ["intent-token"] })
completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath({ intent: ["intent-token"] })
).resolves.toBe("/auth/login");
expect(mockDeleteUserWithAccountDeletionAuthorization).not.toHaveBeenCalled();
@@ -5,11 +5,14 @@ import { AuthorizationError } from "@formbricks/types/errors";
import { IS_FORMBRICKS_CLOUD, WEBAPP_URL } from "@/lib/constants";
import { verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getValidatedCallbackUrl } from "@/lib/utils/url";
import { FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL } from "@/modules/account/constants";
import {
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
} from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
type TAccountDeletionSsoCompleteSearchParams = {
intent?: string | string[];
@@ -23,7 +26,7 @@ const getIntentToken = (intent: string | string[] | undefined) => {
return intent;
};
const getSafeRedirectPath = (returnToUrl: string) => {
const getSafeFailureRedirectPath = (returnToUrl: string) => {
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL);
if (!validatedReturnToUrl) {
@@ -31,17 +34,23 @@ const getSafeRedirectPath = (returnToUrl: string) => {
}
const parsedReturnToUrl = new URL(validatedReturnToUrl);
parsedReturnToUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE
);
return `${parsedReturnToUrl.pathname}${parsedReturnToUrl.search}${parsedReturnToUrl.hash}`;
};
const getPostDeletionRedirectPath = () =>
IS_FORMBRICKS_CLOUD ? FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL : "/auth/login";
export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = async ({
export const completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath = async ({
intent,
}: TAccountDeletionSsoCompleteSearchParams): Promise<string> => {
const intentToken = getIntentToken(intent);
let deletionSucceeded = false;
let redirectPath = "/auth/login";
let targetUserId: string | null = null;
if (!intentToken) {
return redirectPath;
@@ -49,33 +58,30 @@ export const completeAccountDeletionSsoReauthenticationAndGetRedirectPath = asyn
try {
const verifiedIntent = verifyAccountDeletionSsoReauthIntent(intentToken);
redirectPath = getSafeRedirectPath(verifiedIntent.returnToUrl);
targetUserId = verifiedIntent.userId;
redirectPath = getSafeFailureRedirectPath(verifiedIntent.returnToUrl);
const session = await getServerSession(authOptions);
if (!session?.user?.id || !session.user.email || session.user.id !== verifiedIntent.userId) {
throw new AuthorizationError("Account deletion SSO reauthentication session mismatch");
throw new AuthorizationError("Account deletion SSO identity confirmation session mismatch");
}
logger.info({ userId: session.user.id }, "Completing account deletion after SSO reauth");
logger.info({ userId: session.user.id }, "Completing account deletion after SSO identity confirmation");
const { oldUser } = await deleteUserWithAccountDeletionAuthorization({
confirmationEmail: verifiedIntent.email,
userEmail: session.user.email,
userId: session.user.id,
});
deletionSucceeded = true;
redirectPath = getPostDeletionRedirectPath();
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId: session.user.id,
userType: "user",
targetId: session.user.id,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status: "success",
});
logger.info({ userId: session.user.id }, "Completed account deletion after SSO reauth");
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: session.user.id });
logger.info({ userId: session.user.id }, "Completed account deletion after SSO identity confirmation");
} catch (error) {
logger.error({ error }, "Failed to complete account deletion after SSO reauth");
if (targetUserId && !deletionSucceeded) {
await queueAccountDeletionAuditEvent({ status: "failure", targetUserId });
}
logger.error({ error }, "Failed to complete account deletion after SSO identity confirmation");
}
return redirectPath;
@@ -1,10 +1,10 @@
import { redirect } from "next/navigation";
import { completeAccountDeletionSsoReauthenticationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
import { completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath } from "./lib/account-deletion-sso-complete";
export default async function AccountDeletionSsoReauthCompletePage({
export default async function AccountDeletionSsoConfirmationCompletePage({
searchParams,
}: {
searchParams: Promise<{ intent?: string | string[] }>;
}) {
redirect(await completeAccountDeletionSsoReauthenticationAndGetRedirectPath(await searchParams));
redirect(await completeAccountDeletionSsoIdentityConfirmationAndGetRedirectPath(await searchParams));
}
@@ -2,12 +2,7 @@ import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput } from "@formbricks/types/displays";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
import { createDisplay } from "./display";
@@ -83,7 +78,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -183,17 +177,6 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(getContactByUserId).mockResolvedValue(mockContact);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
code: "P2002",
@@ -1,7 +1,7 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { TDisplayCreateInput, ZDisplayCreateInput } from "@formbricks/types/displays";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { getContactByUserId } from "./contact";
@@ -41,10 +41,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInput): Promise<
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -1,6 +1,6 @@
import { logger } from "@formbricks/logger";
import { ZDisplayCreateInput } from "@formbricks/types/displays";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
@@ -61,12 +61,6 @@ export const POST = withV1ApiWrapper({
return {
response: responses.notFoundResponse("Survey", inputValidation.data.surveyId),
};
} else if (error instanceof InvalidInputError) {
return {
response: responses.forbiddenResponse(error.message, true, {
surveyId: inputValidation.data.surveyId,
}),
};
} else {
logger.error({ error, url: req.url }, "Error in POST /api/v1/client/[environmentId]/displays");
return {
@@ -128,14 +128,6 @@ export const POST = withV1ApiWrapper({
};
}
if (survey.status !== "inProgress") {
return {
response: responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
}),
};
}
const singleUseValidationResult = validateSingleUseResponseInput(
survey,
environmentId,
@@ -1,12 +1,7 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import {
DatabaseError,
InvalidInputError,
ResourceNotFoundError,
ValidationError,
} from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
import { TDisplayCreateInputV2 } from "../types/display";
import { doesContactExist } from "./contact";
@@ -71,7 +66,6 @@ const mockSurvey = {
id: surveyId,
name: "Test Survey",
environmentId,
status: "inProgress",
} as any;
describe("createDisplay", () => {
@@ -155,17 +149,6 @@ describe("createDisplay", () => {
expect(prisma.display.create).not.toHaveBeenCalled();
});
test.each(["draft", "paused", "completed"])(
"should throw InvalidInputError when survey status is %s",
async (status) => {
vi.mocked(doesContactExist).mockResolvedValue(true);
vi.mocked(prisma.survey.findUnique).mockResolvedValue({ ...mockSurvey, status } as any);
await expect(createDisplay(displayInput)).rejects.toThrow(InvalidInputError);
expect(prisma.display.create).not.toHaveBeenCalled();
}
);
test("should throw DatabaseError on other Prisma known request errors", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("DB error", {
code: "P2002",
@@ -1,6 +1,6 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
@@ -26,10 +26,6 @@ export const createDisplay = async (displayInput: TDisplayCreateInputV2): Promis
throw new ResourceNotFoundError("Survey", surveyId);
}
if (survey.status !== "inProgress") {
throw new InvalidInputError("Survey is not accepting submissions");
}
const display = await prisma.display.create({
data: {
survey: {
@@ -1,4 +1,4 @@
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
@@ -79,12 +79,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
}
if (error instanceof InvalidInputError) {
return responses.forbiddenResponse(error.message, true, {
surveyId: displayInputData.surveyId,
});
}
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
@@ -26,7 +26,6 @@ vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })),
notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })),
forbiddenResponse: vi.fn((message) => new Response(message, { status: 403 })),
},
}));
@@ -139,19 +138,6 @@ describe("checkSurveyValidity", () => {
);
});
test.each(["draft", "paused", "completed"] as const)(
"should return forbiddenResponse when survey status is %s",
async (status) => {
const survey = { ...mockSurvey, status } as TSurvey;
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(403);
expect(responses.forbiddenResponse).toHaveBeenCalledWith("Survey is not accepting submissions", true, {
surveyId: mockSurvey.id,
});
}
);
test("should return null if recaptcha is not enabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: false, threshold: 0.5 } };
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
@@ -19,12 +19,6 @@ export const checkSurveyValidity = async (
return responses.badRequestResponse("Survey does not belong to this environment", undefined, true);
}
if (survey.status !== "inProgress") {
return responses.forbiddenResponse("Survey is not accepting submissions", true, {
surveyId: survey.id,
});
}
const singleUseValidationResult = validateSingleUseResponseInput(survey, environmentId, responseInput);
if (singleUseValidationResult) {
if ("response" in singleUseValidationResult) {
+2 -3
View File
@@ -1172,7 +1172,6 @@ checksums:
environments/settings/profile/email_confirmation_does_not_match: eee9d13af9ca8c1f21b46fee764605ac
environments/settings/profile/enable_two_factor_authentication: 476d45754f584b25cc66ab00eccbefaa
environments/settings/profile/enter_the_code_from_your_authenticator_app_below: 9bae7024a84c2be6e2725b187e2244f9
environments/settings/profile/google_sso_account_deletion_requires_setup: b2b60bb8bd1297f8b78af44b461733f5
environments/settings/profile/lost_access: 70292321ff8232218d2261b11c40bc0a
environments/settings/profile/or_enter_the_following_code_manually: c209f319f38984d8718cd272a2a60b97
environments/settings/profile/organizations_delete_message: 9ca1794c9a63c8d82462abcf7109d31f
@@ -1183,8 +1182,8 @@ checksums:
environments/settings/profile/save_the_following_backup_codes_in_a_safe_place: a5b9d38083770375f2372f93ac9a7b2b
environments/settings/profile/scan_the_qr_code_below_with_your_authenticator_app: 5a6b60928590ce3b6be1bdf1d34cd45e
environments/settings/profile/security_description: e833adde4e3e26795e61a93619c6caec
environments/settings/profile/sso_reauthentication_failed: 1b2f4047fcec5571c67ee3235ad70853
environments/settings/profile/sso_reauthentication_may_be_required_for_deletion: f2e0c238a701bd504a9527113b4f22e4
environments/settings/profile/sso_identity_confirmation_failed: 9d0fcabd5321c07af1caf627b0c68bdf
environments/settings/profile/sso_identity_confirmation_may_be_required_for_deletion: a220681b82105f16803bb542853809f4
environments/settings/profile/two_factor_authentication: 97a428a54e41d68810a12dbae075f371
environments/settings/profile/two_factor_authentication_description: 1429e4eeaea193f15fb508875d4fb601
environments/settings/profile/two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app: 308ba145b3dc485ff4f17387e977b1f9
+2 -2
View File
@@ -26,7 +26,8 @@ export const TERMS_URL = env.TERMS_URL;
export const IMPRINT_URL = env.IMPRINT_URL;
export const IMPRINT_ADDRESS = env.IMPRINT_ADDRESS;
export const DISABLE_ACCOUNT_DELETION_SSO_REAUTH = env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH === "1";
export const DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION =
env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION === "1";
export const DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS = env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS === "1";
export const DEBUG_SHOW_RESET_LINK = !IS_PRODUCTION && env.DEBUG_SHOW_RESET_LINK === "1";
export const PASSWORD_RESET_DISABLED = env.PASSWORD_RESET_DISABLED === "1";
@@ -34,7 +35,6 @@ export const PASSWORD_RESET_TOKEN_LIFETIME_MINUTES = env.PASSWORD_RESET_TOKEN_LI
export const EMAIL_VERIFICATION_DISABLED = env.EMAIL_VERIFICATION_DISABLED === "1";
export const GOOGLE_OAUTH_ENABLED = !!(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET);
export const GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED = env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED === "1";
export const GITHUB_OAUTH_ENABLED = !!(env.GITHUB_ID && env.GITHUB_SECRET);
export const AZURE_OAUTH_ENABLED = !!(env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET);
export const OIDC_OAUTH_ENABLED = !!(env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER);
@@ -32,7 +32,6 @@ beforeEach(() => {
id: mockSurveyId,
name: "Test Survey",
environmentId: mockEnvironmentId,
status: "inProgress",
} as any);
});
+2 -4
View File
@@ -123,7 +123,7 @@ const parsedEnv = createEnv({
BREVO_API_KEY: z.string().optional(),
BREVO_LIST_ID: z.string().optional(),
DATABASE_URL: z.url(),
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: z.enum(["1", "0"]).optional(),
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: z.enum(["1", "0"]).optional(),
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: z.enum(["1", "0"]).optional(),
DEBUG: z.enum(["1", "0"]).optional(),
DEBUG_SHOW_RESET_LINK: z.enum(["1", "0"]).optional(),
@@ -137,7 +137,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: z.enum(["production", "staging"]).prefault("production"),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: z.enum(["1", "0"]).optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
AI_GCP_PROJECT: z.string().optional(),
@@ -269,7 +268,7 @@ const parsedEnv = createEnv({
BREVO_LIST_ID: process.env.BREVO_LIST_ID,
CRON_SECRET: process.env.CRON_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: process.env.DISABLE_ACCOUNT_DELETION_SSO_REAUTH,
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: process.env.DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION,
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: process.env.DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS,
DEBUG: process.env.DEBUG,
DEBUG_SHOW_RESET_LINK: process.env.DEBUG_SHOW_RESET_LINK,
@@ -283,7 +282,6 @@ const parsedEnv = createEnv({
ENVIRONMENT: process.env.ENVIRONMENT,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: process.env.GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
AI_GCP_PROJECT: process.env.AI_GCP_PROJECT,
+7 -7
View File
@@ -1085,7 +1085,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
});
});
describe("account deletion SSO reauthentication intents", () => {
describe("account deletion SSO identity confirmation intents", () => {
const accountDeletionIntent = {
id: "intent-id",
userId: mockUser.id,
@@ -1096,7 +1096,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
returnToUrl: "http://localhost:3000/environments/env-1/settings/profile",
};
test("round-trips encrypted account deletion reauth intents", () => {
test("round-trips encrypted account deletion SSO identity confirmation intents", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
expect(verifyAccountDeletionSsoReauthIntent(token)).toEqual(accountDeletionIntent);
@@ -1113,14 +1113,14 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("creates account deletion reauth intents with a ten minute default expiry", () => {
test("creates account deletion SSO identity confirmation intents with a ten minute default expiry", () => {
const token = createAccountDeletionSsoReauthIntent(accountDeletionIntent);
const decoded = jwt.decode(token) as any;
expect(decoded.exp - decoded.iat).toBe(10 * 60);
});
test("rejects account deletion reauth intents with the wrong purpose", () => {
test("rejects account deletion SSO identity confirmation intents with the wrong purpose", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1142,7 +1142,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects account deletion reauth intents missing required fields", () => {
test("rejects account deletion SSO identity confirmation intents missing required fields", () => {
const token = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1163,7 +1163,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
);
});
test("rejects expired account deletion reauth intents", () => {
test("rejects expired account deletion SSO identity confirmation intents", () => {
const expiredToken = jwt.sign(
{
id: crypto.symmetricEncrypt(accountDeletionIntent.id, TEST_ENCRYPTION_KEY),
@@ -1184,7 +1184,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
expect(() => verifyAccountDeletionSsoReauthIntent(expiredToken)).toThrow();
});
test("throws when account deletion reauth intent secrets are missing", async () => {
test("throws when account deletion SSO identity confirmation intent secrets are missing", async () => {
await testMissingSecretsError(createAccountDeletionSsoReauthIntent, [accountDeletionIntent]);
const token = jwt.sign(
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Zwei-Faktor-Authentifizierung aktivieren",
"enter_the_code_from_your_authenticator_app_below": "Gib den Code aus deiner Authentifizierungs-App unten ein.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Zugriff verloren",
"or_enter_the_following_code_manually": "Oder gib den folgenden Code manuell ein:",
"organizations_delete_message": "Du bist der einzige Besitzer dieser Organisationen, also werden sie <b>auch gelöscht.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Speichere die folgenden Backup-Codes an einem sicheren Ort.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanne den QR-Code unten mit deiner Authentifizierungs-App.",
"security_description": "Verwalte dein Passwort und andere Sicherheitseinstellungen wie Zwei-Faktor-Authentifizierung (2FA).",
"sso_reauthentication_failed": "Die SSO-Authentifizierung ist fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_reauthentication_may_be_required_for_deletion": "Bei SSO-Konten kann die Auswahl von Löschen dich zu deinem Identitätsanbieter weiterleiten. Wenn deine Identität bestätigt wird, wird dein Konto automatisch gelöscht.",
"sso_identity_confirmation_failed": "SSO-Identitätsbestätigung fehlgeschlagen. Bitte versuche erneut, dein Konto zu löschen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Bei SSO-Konten kann dich die Auswahl von Löschen zu deinem Identitätsanbieter weiterleiten, um dieses Konto zu bestätigen. Wenn dasselbe Konto bestätigt wird, wird die Löschung automatisch fortgesetzt.",
"two_factor_authentication": "Zwei-Faktor-Authentifizierung",
"two_factor_authentication_description": "Füge eine zusätzliche Sicherheitsebene zu deinem Konto hinzu, falls dein Passwort gestohlen wird.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Zwei-Faktor-Authentifizierung aktiviert. Bitte gib den sechsstelligen Code aus deiner Authentifizierungs-App ein.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Enable two factor authentication",
"enter_the_code_from_your_authenticator_app_below": "Enter the code from your authenticator app below.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Lost access",
"or_enter_the_following_code_manually": "Or enter the following code manually:",
"organizations_delete_message": "You are the only owner of these organizations, so they <b>will be deleted as well.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Save the following backup codes in a safe place.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan the QR code below with your authenticator app.",
"security_description": "Manage your password and other security settings like two-factor authentication (2FA).",
"sso_reauthentication_failed": "SSO reauthentication failed. Please try deleting your account again.",
"sso_reauthentication_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider. If your identity is confirmed, your account will be deleted automatically.",
"sso_identity_confirmation_failed": "SSO identity confirmation failed. Please try deleting your account again.",
"sso_identity_confirmation_may_be_required_for_deletion": "For SSO accounts, selecting Delete may redirect you to your identity provider to confirm this account. If the same account is confirmed, deletion continues automatically.",
"two_factor_authentication": "Two factor authentication",
"two_factor_authentication_description": "Add an extra layer of security to your account in case your password is stolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Two-factor authentication enabled. Please enter the six-digit code from your authenticator app.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activar autenticación de dos factores",
"enter_the_code_from_your_authenticator_app_below": "Introduce el código de tu aplicación de autenticación a continuación.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acceso perdido",
"or_enter_the_following_code_manually": "O introduce el siguiente código manualmente:",
"organizations_delete_message": "Eres el único propietario de estas organizaciones, por lo que <b>también serán eliminadas.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarda los siguientes códigos de respaldo en un lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escanea el código QR a continuación con tu aplicación de autenticación.",
"security_description": "Gestiona tu contraseña y otros ajustes de seguridad como la autenticación de dos factores (2FA).",
"sso_reauthentication_failed": "La reautenticación SSO falló. Intenta eliminar tu cuenta de nuevo.",
"sso_reauthentication_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad. Si se confirma tu identidad, tu cuenta se eliminará automáticamente.",
"sso_identity_confirmation_failed": "No se pudo confirmar la identidad mediante SSO. Intenta eliminar tu cuenta de nuevo.",
"sso_identity_confirmation_may_be_required_for_deletion": "En las cuentas SSO, al seleccionar Eliminar es posible que se te redirija a tu proveedor de identidad para confirmar esta cuenta. Si se confirma la misma cuenta, la eliminación continuará automáticamente.",
"two_factor_authentication": "Autenticación de dos factores",
"two_factor_authentication_description": "Añade una capa adicional de seguridad a tu cuenta en caso de que tu contraseña sea robada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticación de dos factores activada. Por favor, introduce el código de seis dígitos de tu aplicación de autenticación.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activer l'authentification à deux facteurs",
"enter_the_code_from_your_authenticator_app_below": "Entrez le code de votre application d'authentification ci-dessous.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Accès perdu",
"or_enter_the_following_code_manually": "Ou entrez le code suivant manuellement :",
"organizations_delete_message": "Tu es le seul propriétaire de ces organisations, elles <b>seront aussi supprimées.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Enregistrez les codes de sauvegarde suivants dans un endroit sûr.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scannez le code QR ci-dessous avec votre application d'authentification.",
"security_description": "Gérez votre mot de passe et d'autres paramètres de sécurité comme l'authentification à deux facteurs (2FA).",
"sso_reauthentication_failed": "La réauthentification SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_reauthentication_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité. Si votre identité est confirmée, votre compte sera supprimé automatiquement.",
"sso_identity_confirmation_failed": "La confirmation d'identité SSO a échoué. Veuillez réessayer de supprimer votre compte.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pour les comptes SSO, sélectionner Supprimer peut vous rediriger vers votre fournisseur d'identité afin de confirmer ce compte. Si le même compte est confirmé, la suppression se poursuit automatiquement.",
"two_factor_authentication": "Authentification à deux facteurs",
"two_factor_authentication_description": "Ajoutez une couche de sécurité supplémentaire à votre compte au cas où votre mot de passe serait volé.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Authentification à deux facteurs activée. Veuillez entrer le code à six chiffres de votre application d'authentification.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Az e-mail-cím megerősítése nem egyezik.",
"enable_two_factor_authentication": "Kétfaktoros hitelesítés engedélyezése",
"enter_the_code_from_your_authenticator_app_below": "Adja meg a hitelesítő alkalmazásból származó kódot lent.",
"google_sso_account_deletion_requires_setup": "Nem tudtuk megerősíteni a személyazonosságát az SSO-szolgáltatóval. Próbálja meg újra, vagy vegye fel a kapcsolatot az adminisztrátorral.",
"lost_access": "Elvesztett hozzáférés",
"or_enter_the_following_code_manually": "Vagy adja meg a következő kódot kézileg:",
"organizations_delete_message": "Ön az egyetlen tulajdonosa ezeknek a szervezeteknek, ezért <b>azok is törölve lesznek.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Mentse el a következő visszaszerzési kódokat egy biztonságos helyre.",
"scan_the_qr_code_below_with_your_authenticator_app": "Olvassa be a lenti QR-kódot a hitelesítő alkalmazásával.",
"security_description": "A jelszava és egyéb biztonsági beállítások, például a kétfaktoros hitelesítés (2FA) kezelése.",
"sso_reauthentication_failed": "Az SSO újrahitelesítése nem sikerült. Próbálja meg újra törölni a fiókt.",
"sso_reauthentication_may_be_required_for_deletion": "SSO-fiókoknál a Törlés kiválasztása átirányíthatja Önt a személyazonosság-szolgáltatóhoz. Ha a személyazonossága megerősítésre került, akkor a fiókja automatikusan törölve lesz.",
"sso_identity_confirmation_failed": "Az SSO-identitás megerősítése nem sikerült. Kérjük, próbáld meg újra törölni a fiókodat.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO-fiókok esetén a Törlés kiválasztása átirányíthat az identitásszolgáltatóhoz a fiók megerősítéséhez. Ha ugyanazt a fiókot erősítik meg, a törlés automatikusan folytatódik.",
"two_factor_authentication": "Kétfaktoros hitelesítés",
"two_factor_authentication_description": "További biztonsági réteg hozzáadása a fiókjához arra az esetre, ha a jelszavát ellopnák.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "A kétfaktoros hitelesítés engedélyezve van. Adja a meg a 6 számjegyű kódot a hitelesítő alkalmazásából.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "二段階認証を有効にする",
"enter_the_code_from_your_authenticator_app_below": "認証アプリからコードを以下に入力してください。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "アクセスを紛失しましたか",
"or_enter_the_following_code_manually": "または、以下のコードを手動で入力してください:",
"organizations_delete_message": "あなたはこれらの組織の唯一のオーナーであるため、組織も<b>削除されます。</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "以下のバックアップコードを安全な場所に保存してください。",
"scan_the_qr_code_below_with_your_authenticator_app": "以下のQRコードを認証アプリでスキャンしてください。",
"security_description": "パスワードや二段階認証(2FA)などの他のセキュリティ設定を管理します。",
"sso_reauthentication_failed": "SSO の再認証に失敗しました。もう一度アカウントの削除を試しください。",
"sso_reauthentication_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択するとIDプロバイダーリダイレクトされることがあります。本人確認が完了すると、アカウントは自動的に削除されます。",
"sso_identity_confirmation_failed": "SSOでの本人確認に失敗しました。もう一度アカウントの削除を試しください。",
"sso_identity_confirmation_may_be_required_for_deletion": "SSOアカウントの場合、削除を選択すると、このアカウントを確認するためにIDプロバイダーリダイレクトされることがあります。同じアカウントが確認されると、削除は自動的に続行されます。",
"two_factor_authentication": "二段階認証",
"two_factor_authentication_description": "パスワードが盗まれた場合に備えて、アカウントにセキュリティの追加レイヤーを追加します。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "二段階認証が有効になりました。認証アプリから6桁のコードを入力してください。",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Schakel tweefactorauthenticatie in",
"enter_the_code_from_your_authenticator_app_below": "Voer hieronder de code uit uw authenticator-app in.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Toegang verloren",
"or_enter_the_following_code_manually": "Of voer de volgende code handmatig in:",
"organizations_delete_message": "U bent de enige eigenaar van deze organisaties, dus <b>worden ze ook verwijderd.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Bewaar de volgende back-upcodes op een veilige plaats.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scan onderstaande QR-code met uw authenticator-app.",
"security_description": "Beheer uw wachtwoord en andere beveiligingsinstellingen zoals tweefactorauthenticatie (2FA).",
"sso_reauthentication_failed": "SSO-herauthenticatie is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_reauthentication_may_be_required_for_deletion": "Bij SSO-accounts kan het selecteren van Verwijderen u doorsturen naar uw identiteitsprovider. Als uw identiteit is bevestigd, wordt uw account automatisch verwijderd.",
"sso_identity_confirmation_failed": "SSO-identiteitsbevestiging is mislukt. Probeer je account opnieuw te verwijderen.",
"sso_identity_confirmation_may_be_required_for_deletion": "Voor SSO-accounts kan het selecteren van Verwijderen je doorsturen naar je identiteitsprovider om dit account te bevestigen. Als hetzelfde account wordt bevestigd, gaat de verwijdering automatisch verder.",
"two_factor_authentication": "Tweefactorauthenticatie",
"two_factor_authentication_description": "Voeg een extra beveiligingslaag toe aan uw account voor het geval uw wachtwoord wordt gestolen.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tweefactorauthenticatie ingeschakeld. Voer de zescijferige code van uw authenticator-app in.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Digite o código do seu app autenticador abaixo.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdi o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "Você é o único dono dessas organizações, então elas <b>também serão apagadas.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup em um lugar seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Escaneie o código QR abaixo com seu app autenticador.",
"security_description": "Gerencie sua senha e outras configurações de segurança como a autenticação de dois fatores (2FA).",
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente excluir sua conta novamente.",
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para seu provedor de identidade. Se sua identidade for confirmada, sua conta será excluída automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade via SSO falhou. Tente excluir sua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Excluir pode redirecionar você para o provedor de identidade para confirmar esta conta. Se a mesma conta for confirmada, a exclusão continuará automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso sua senha seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Por favor, insira o código de seis dígitos do seu app autenticador.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Ativar autenticação de dois fatores",
"enter_the_code_from_your_authenticator_app_below": "Introduza o código da sua aplicação de autenticação abaixo.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Perdeu o acesso",
"or_enter_the_following_code_manually": "Ou insira o seguinte código manualmente:",
"organizations_delete_message": "É o único proprietário destas organizações, por isso <b>também serão eliminadas.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Guarde os seguintes códigos de backup num local seguro.",
"scan_the_qr_code_below_with_your_authenticator_app": "Digitalize o código QR abaixo com a sua aplicação de autenticação.",
"security_description": "Gerir a sua palavra-passe e outras definições de segurança, como a autenticação de dois fatores (2FA).",
"sso_reauthentication_failed": "A reautenticação SSO falhou. Tente eliminar a sua conta novamente.",
"sso_reauthentication_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecioná-lo para o seu fornecedor de identidade. Se a sua identidade for confirmada, a sua conta será eliminada automaticamente.",
"sso_identity_confirmation_failed": "A confirmação de identidade por SSO falhou. Tenta eliminar a tua conta novamente.",
"sso_identity_confirmation_may_be_required_for_deletion": "Para contas SSO, selecionar Eliminar pode redirecionar-te para o teu fornecedor de identidade para confirmares esta conta. Se a mesma conta for confirmada, a eliminação continuará automaticamente.",
"two_factor_authentication": "Autenticação de dois fatores",
"two_factor_authentication_description": "Adicione uma camada extra de segurança à sua conta caso a sua palavra-passe seja roubada.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autenticação de dois fatores ativada. Introduza o código de seis dígitos da sua aplicação de autenticação.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Activează autentificarea în doi pași",
"enter_the_code_from_your_authenticator_app_below": "Introduceți codul din aplicația dvs. de autentificare mai jos.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Acces pierdut",
"or_enter_the_following_code_manually": "Sau introduceți manual următorul cod:",
"organizations_delete_message": "Ești singurul proprietar al acestor organizații, deci ele <b>vor fi șterse și ele.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Salvează următoarele coduri de rezervă într-un loc sigur.",
"scan_the_qr_code_below_with_your_authenticator_app": "Scanați codul QR de mai jos cu aplicația dvs. de autentificare.",
"security_description": "Gestionează parola și alte setări de securitate, precum autentificarea în doi pași (2FA).",
"sso_reauthentication_failed": "Reautentificarea SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_reauthentication_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul tău de identitate. Dacă identitatea ta este confirmată, contul va fi șters automat.",
"sso_identity_confirmation_failed": "Confirmarea identității SSO a eșuat. Te rugăm să încerci din nou să îți ștergi contul.",
"sso_identity_confirmation_may_be_required_for_deletion": "Pentru conturile SSO, selectarea opțiunii Șterge te poate redirecționa către furnizorul de identitate pentru a confirma acest cont. Dacă același cont este confirmat, ștergerea continuă automat.",
"two_factor_authentication": "Autentificare în doi pași",
"two_factor_authentication_description": "Adăugați un strat suplimentar de securitate la contul dvs. în cazul în care parola este furată.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Autentificare în doi pași activată. Introduceți codul de șase cifre din aplicația dvs. de autentificare.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Включить двухфакторную аутентификацию",
"enter_the_code_from_your_authenticator_app_below": "Введите ниже код из вашего приложения-аутентификатора.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Потерян доступ",
"or_enter_the_following_code_manually": "Или введите следующий код вручную:",
"organizations_delete_message": "Вы являетесь единственным владельцем этих организаций, поэтому они <b>также будут удалены.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Сохраните следующие резервные коды в безопасном месте.",
"scan_the_qr_code_below_with_your_authenticator_app": "Отсканируйте QR-код ниже с помощью вашего приложения-аутентификатора.",
"security_description": "Управляйте паролем и другими настройками безопасности, такими как двухфакторная аутентификация (2FA).",
"sso_reauthentication_failed": "Повторная аутентификация SSO не удалась. Попробуйте удалить аккаунт еще раз.",
"sso_reauthentication_may_be_required_for_deletion": "Для учетных записей SSO выбор Удалить может перенаправить вас к поставщику удостоверений. Если ваша личность будет подтверждена, учетная запись будет удалена автоматически.",
"sso_identity_confirmation_failed": "Не удалось подтвердить личность через SSO. Попробуйте удалить аккаунт ещё раз.",
"sso_identity_confirmation_may_be_required_for_deletion": "Для аккаунтов SSO при выборе «Удалить» вы можете быть перенаправлены к поставщику удостоверений, чтобы подтвердить этот аккаунт. Если будет подтверждён тот же аккаунт, удаление продолжится автоматически.",
"two_factor_authentication": "Двухфакторная аутентификация",
"two_factor_authentication_description": "Добавьте дополнительный уровень защиты вашему аккаунту на случай, если ваш пароль будет украден.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Двухфакторная аутентификация включена. Пожалуйста, введите шестизначный код из вашего приложения-аутентификатора.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "Aktivera tvåfaktorsautentisering",
"enter_the_code_from_your_authenticator_app_below": "Ange koden från din autentiseringsapp nedan.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Förlorad åtkomst",
"or_enter_the_following_code_manually": "Eller ange följande kod manuellt:",
"organizations_delete_message": "Du är den enda ägaren av dessa organisationer, så de <b>kommer också att tas bort.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Spara följande reservkoder på ett säkert ställe.",
"scan_the_qr_code_below_with_your_authenticator_app": "Skanna QR-koden nedan med din autentiseringsapp.",
"security_description": "Hantera ditt lösenord och andra säkerhetsinställningar som tvåfaktorsautentisering (2FA).",
"sso_reauthentication_failed": "SSO-återautentisering misslyckades. Försök ta bort ditt konto igen.",
"sso_reauthentication_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör. Om din identitet bekräftas tas ditt konto bort automatiskt.",
"sso_identity_confirmation_failed": "SSO-identitetsbekräftelsen misslyckades. Försök ta bort ditt konto igen.",
"sso_identity_confirmation_may_be_required_for_deletion": "För SSO-konton kan valet Ta bort omdirigera dig till din identitetsleverantör för att bekräfta kontot. Om samma konto bekräftas fortsätter borttagningen automatiskt.",
"two_factor_authentication": "Tvåfaktorsautentisering",
"two_factor_authentication_description": "Lägg till ett extra säkerhetslager till ditt konto om ditt lösenord blir stulet.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "Tvåfaktorsautentisering aktiverad. Vänligen ange den sexsiffriga koden från din autentiseringsapp.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "İki faktörlü kimlik doğrulamayı etkinleştir",
"enter_the_code_from_your_authenticator_app_below": "Kimlik doğrulayıcı uygulamanızdaki kodu aşağıya girin.",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "Erişim kaybedildi",
"or_enter_the_following_code_manually": "Veya aşağıdaki kodu manuel olarak girin:",
"organizations_delete_message": "Bu organizasyonların tek sahibi sizsiniz, bu nedenle <b>onlar da silinecektir.</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "Aşağıdaki yedek kodları güvenli bir yere kaydedin.",
"scan_the_qr_code_below_with_your_authenticator_app": "Aşağıdaki QR kodunu kimlik doğrulama uygulamanızla tarayın.",
"security_description": "Şifrenizi ve iki faktörlü kimlik doğrulama (2FA) gibi diğer güvenlik ayarlarını yönetin.",
"sso_reauthentication_failed": "SSO yeniden kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_reauthentication_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek sizi kimlik sağlayıcınıza yönlendirebilir. Kimliğiniz doğrulanırsa hesabınız otomatik olarak silinir.",
"sso_identity_confirmation_failed": "SSO kimlik doğrulaması başarısız oldu. Lütfen hesabınızı silmeyi tekrar deneyin.",
"sso_identity_confirmation_may_be_required_for_deletion": "SSO hesaplarında Sil'i seçmek, bu hesabı onaylamanız için sizi kimlik sağlayıcınıza yönlendirebilir. Aynı hesap onaylanırsa silme işlemi otomatik olarak devam eder.",
"two_factor_authentication": "İki faktörlü kimlik doğrulama",
"two_factor_authentication_description": "Şifrenizin çalınması durumuna karşı hesabınıza ekstra bir güvenlik katmanı ekleyin.",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "İki faktörlü kimlik doğrulama etkinleştirildi. Lütfen kimlik doğrulama uygulamanızdaki altı haneli kodu girin.",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "启用 双因素 认证",
"enter_the_code_from_your_authenticator_app_below": "从 你的 身份验证 应用 中 输入 代码 。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "失去访问",
"or_enter_the_following_code_manually": "或者 手动输入 以下 代码:",
"organizations_delete_message": "您 是 这些 组织 的 唯一 所有者,因此它们 <b> 也将 被 删除。 </b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "请 将 以下 备份 代码 保存在 安全地 方。",
"scan_the_qr_code_below_with_your_authenticator_app": "用 你的 身份验证 应用 扫描 下方 的 二维码。",
"security_description": "管理你的密码和其他安全设置,如双因素认证 (2FA)。",
"sso_reauthentication_failed": "SSO 重新认证失败。请再次尝试删除的账。",
"sso_reauthentication_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商。如果您的身份确认成功,您的账户将自动删除。",
"sso_identity_confirmation_failed": "SSO 身份确认失败。请再次尝试删除的账。",
"sso_identity_confirmation_may_be_required_for_deletion": "对于 SSO 账户,选择删除可能会将重定向到身份提供商以确认此账户。如果确认的是同一账户,删除会自动继续。",
"two_factor_authentication": "双因素 认证",
"two_factor_authentication_description": "为你的账户增加额外的安全层,以防密码被盗。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "双因素 认证 已启用 。 请输入 从 你的 身份验证 应用 中 的 六 位 数 字 代码 。",
+2 -3
View File
@@ -1240,7 +1240,6 @@
"email_confirmation_does_not_match": "Email confirmation does not match.",
"enable_two_factor_authentication": "啟用雙重驗證",
"enter_the_code_from_your_authenticator_app_below": "在下方輸入您驗證器應用程式中的程式碼。",
"google_sso_account_deletion_requires_setup": "We couldn't confirm your identity with your SSO provider. Please try again or contact your administrator.",
"lost_access": "無法存取",
"or_enter_the_following_code_manually": "或手動輸入下列程式碼:",
"organizations_delete_message": "您是這些組織的唯一擁有者,因此它們也 <b>將被刪除。</b>",
@@ -1251,8 +1250,8 @@
"save_the_following_backup_codes_in_a_safe_place": "將下列備份碼儲存在安全的地方。",
"scan_the_qr_code_below_with_your_authenticator_app": "使用您的驗證器應用程式掃描下方的 QR 碼。",
"security_description": "管理您的密碼和其他安全性設定,例如雙重驗證 (2FA)。",
"sso_reauthentication_failed": "SSO 重新驗證失敗。請再次嘗試刪除的帳。",
"sso_reauthentication_may_be_required_for_deletion": "對於 SSO 帳戶,選刪除可能會將重新導向至身分提供者。如果您的身分確認成功,您的帳戶將自動刪除。",
"sso_identity_confirmation_failed": "SSO 身分確認失敗。請再次嘗試刪除的帳。",
"sso_identity_confirmation_may_be_required_for_deletion": "對於 SSO 帳戶,選擇「刪除可能會將重新導向至身分提供者以確認此帳戶。如果確認的是同一個帳戶,刪除會自動繼續。",
"two_factor_authentication": "雙重驗證",
"two_factor_authentication_description": "在您的密碼被盜時,為您的帳戶新增額外的安全層。",
"two_factor_authentication_enabled_please_enter_the_six_digit_code_from_your_authenticator_app": "已啟用雙重驗證。請輸入您驗證器應用程式中的六位數程式碼。",
@@ -0,0 +1,147 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { AuthorizationError } from "@formbricks/types/errors";
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
import { deleteUserAction } from "./actions";
vi.mock("server-only", () => ({}));
const mocks = vi.hoisted(() => ({
applyRateLimit: vi.fn(),
capturePostHogEvent: vi.fn(),
deleteUserWithAccountDeletionAuthorization: vi.fn(),
loggerError: vi.fn(),
queueAuditEventBackground: vi.fn(),
startAccountDeletionSsoReauthentication: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "http://localhost:3000",
}));
vi.mock("@/lib/posthog", () => ({
capturePostHogEvent: mocks.capturePostHogEvent,
}));
vi.mock("@/lib/utils/action-client", () => ({
authenticatedActionClient: {
inputSchema: vi.fn(() => ({
action: vi.fn((fn) => fn),
})),
},
}));
vi.mock("@/modules/account/lib/account-deletion", () => ({
deleteUserWithAccountDeletionAuthorization: mocks.deleteUserWithAccountDeletionAuthorization,
}));
vi.mock("@/modules/account/lib/account-deletion-sso-reauth", () => ({
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: "account-deletion-rate-limit",
},
},
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
queueAuditEventBackground: mocks.queueAuditEventBackground,
}));
vi.mock("@/modules/ee/audit-logs/types/audit-log", () => ({
UNKNOWN_DATA: "unknown",
}));
const ctx = {
auditLoggingCtx: {
eventId: "event-id",
},
user: {
email: "sso-user@example.com",
id: "user-id",
},
};
const parsedInput = {
confirmationEmail: "sso-user@example.com",
returnToUrl: "http://localhost:3000/environments/env-id/settings/profile",
};
describe("deleteUserAction", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyRateLimit.mockResolvedValue(undefined);
mocks.queueAuditEventBackground.mockResolvedValue(undefined);
});
test("returns SSO confirmation details without auditing a failed deletion for the normal redirect flow", async () => {
const ssoConfirmation = {
authorizationParams: { login_hint: "sso-user@example.com", prompt: "login" },
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
};
mocks.deleteUserWithAccountDeletionAuthorization.mockRejectedValueOnce(
new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE)
);
mocks.startAccountDeletionSsoReauthentication.mockResolvedValueOnce(ssoConfirmation);
await expect(deleteUserAction({ ctx, parsedInput })).resolves.toEqual({ ssoConfirmation });
expect(mocks.startAccountDeletionSsoReauthentication).toHaveBeenCalledWith({
confirmationEmail: parsedInput.confirmationEmail,
returnToUrl: parsedInput.returnToUrl,
userId: ctx.user.id,
});
expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled();
});
test("queues a success audit event only after the user is deleted", async () => {
const oldUser = { email: ctx.user.email, id: ctx.user.id };
mocks.deleteUserWithAccountDeletionAuthorization.mockResolvedValueOnce({ oldUser });
await expect(deleteUserAction({ ctx, parsedInput })).resolves.toEqual({ success: true });
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: ctx.user.id,
userType: "user",
targetId: ctx.user.id,
organizationId: "unknown",
oldObject: oldUser,
status: "success",
});
expect(mocks.capturePostHogEvent).toHaveBeenCalledWith(ctx.user.id, "delete_account");
});
test("queues a failure audit event for real deletion failures", async () => {
const error = new Error("delete failed");
mocks.deleteUserWithAccountDeletionAuthorization.mockRejectedValueOnce(error);
await expect(deleteUserAction({ ctx, parsedInput })).rejects.toThrow(error);
expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith({
action: "deleted",
targetType: "user",
userId: ctx.user.id,
userType: "user",
targetId: ctx.user.id,
organizationId: "unknown",
oldObject: undefined,
status: "failure",
eventId: ctx.auditLoggingCtx.eventId,
});
});
});
@@ -2,26 +2,23 @@
import { z } from "zod";
import { logger } from "@formbricks/logger";
import { AuthorizationError } from "@formbricks/types/errors";
import { ZUserEmail } from "@formbricks/types/user";
import { WEBAPP_URL } from "@/lib/constants";
import { capturePostHogEvent } from "@/lib/posthog";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE } from "@/modules/account/constants";
import { deleteUserWithAccountDeletionAuthorization } from "@/modules/account/lib/account-deletion";
import { queueAccountDeletionAuditEvent } from "@/modules/account/lib/account-deletion-audit";
import { 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";
const ZDeleteUserConfirmation = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
password: z.string().max(128).optional(),
})
.strict();
const ZStartAccountDeletionSsoReauth = z
.object({
confirmationEmail: z.string().trim().pipe(ZUserEmail),
returnToUrl: z.string().trim().max(2048).pipe(z.url()),
returnToUrl: z.string().trim().max(2048).pipe(z.url()).optional(),
})
.strict();
@@ -29,31 +26,16 @@ const logAccountDeletionError = (userId: string, error: unknown) => {
logger.error({ error, userId }, "Account deletion failed");
};
export const startAccountDeletionSsoReauthenticationAction = authenticatedActionClient
.inputSchema(ZStartAccountDeletionSsoReauth)
const isSsoConfirmationRequiredError = (error: unknown) =>
error instanceof AuthorizationError && error.message === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE;
export const deleteUserAction = authenticatedActionClient
.inputSchema(ZDeleteUserConfirmation)
.action(async ({ ctx, parsedInput }) => {
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
const { confirmationEmail, returnToUrl } = 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(ZDeleteUserConfirmation).action(
withAuditLogging("deleted", "user", async ({ ctx, parsedInput }) => {
ctx.auditLoggingCtx.userId = ctx.user.id;
const userId = ctx.user.id;
try {
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, ctx.user.id);
await applyRateLimit(rateLimitConfigs.actions.accountDeletion, userId);
const { confirmationEmail, password } = parsedInput;
@@ -61,16 +43,45 @@ export const deleteUserAction = authenticatedActionClient.inputSchema(ZDeleteUse
confirmationEmail,
password,
userEmail: ctx.user.email,
userId: ctx.user.id,
userId,
});
ctx.auditLoggingCtx.oldObject = oldUser;
await queueAccountDeletionAuditEvent({ oldUser, status: "success", targetUserId: userId });
capturePostHogEvent(ctx.user.id, "delete_account");
capturePostHogEvent(userId, "delete_account");
return { success: true };
} catch (error) {
logAccountDeletionError(ctx.user.id, error);
if (isSsoConfirmationRequiredError(error)) {
const { confirmationEmail, returnToUrl } = parsedInput;
try {
return {
ssoConfirmation: await startAccountDeletionSsoReauthentication({
confirmationEmail,
returnToUrl: returnToUrl ?? WEBAPP_URL,
userId,
}),
};
} catch (ssoConfirmationError) {
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logger.error(
{ error: ssoConfirmationError, userId },
"Account deletion SSO identity confirmation failed"
);
throw ssoConfirmationError;
}
}
await queueAccountDeletionAuditEvent({
eventId: ctx.auditLoggingCtx.eventId,
status: "failure",
targetUserId: userId,
});
logAccountDeletionError(userId, error);
throw error;
}
})
);
});
@@ -7,20 +7,19 @@ import { Trans, useTranslation } from "react-i18next";
import { logger } from "@formbricks/logger";
import { TOrganization } from "@formbricks/types/organizations";
import { TUser } from "@formbricks/types/user";
import { FORMBRICKS_ENVIRONMENT_ID_LS } from "@/lib/localStorage";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import {
ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
DELETE_ACCOUNT_WRONG_PASSWORD_ERROR,
FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL,
} from "@/modules/account/constants";
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, startAccountDeletionSsoReauthenticationAction } from "./actions";
import { deleteUserAction } from "./actions";
interface DeleteAccountModalProps {
requiresPasswordConfirmation: boolean;
@@ -43,7 +42,6 @@ export const DeleteAccountModal = ({
const [deleting, setDeleting] = useState(false);
const [inputValue, setInputValue] = useState("");
const [password, setPassword] = useState("");
const { signOut: signOutWithAudit } = useSignOut({ id: user.id, email: user.email });
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value);
};
@@ -65,10 +63,6 @@ export const DeleteAccountModal = ({
return t("environments.settings.profile.wrong_password");
}
if (serverError === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return t("environments.settings.profile.google_sso_account_deletion_requires_setup");
}
if (serverError === ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE) {
return t("environments.settings.profile.email_confirmation_does_not_match");
}
@@ -78,39 +72,12 @@ export const DeleteAccountModal = ({
}
if (serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
return t("environments.settings.profile.sso_reauthentication_failed");
return t("environments.settings.profile.sso_identity_confirmation_failed");
}
return null;
};
const startSsoReauthentication = async () => {
const result = await startAccountDeletionSsoReauthenticationAction({
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
});
if (!result?.data) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
const errorMessage =
getLocalizedDeletionErrorMessage(result?.serverError) ??
(result ? getFormattedErrorMessage(result) : fallbackErrorMessage);
logger.error({ errorMessage }, "Account deletion SSO reauthentication action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
await signIn(
result.data.provider,
{
callbackUrl: result.data.callbackUrl,
redirect: true,
},
result.data.authorizationParams
);
};
const deleteAccount = async () => {
try {
if (!hasValidConfirmation) {
@@ -123,37 +90,39 @@ export const DeleteAccountModal = ({
? {
confirmationEmail: inputValue,
password,
returnToUrl: globalThis.location.href,
}
: {
confirmationEmail: inputValue,
returnToUrl: globalThis.location.href,
}
);
if (result?.data?.ssoConfirmation) {
await signIn(
result.data.ssoConfirmation.provider,
{
callbackUrl: result.data.ssoConfirmation.callbackUrl,
redirect: true,
},
result.data.ssoConfirmation.authorizationParams
);
return;
}
if (!result?.data?.success) {
const fallbackErrorMessage = t("common.something_went_wrong_please_try_again");
let errorMessage = getLocalizedDeletionErrorMessage(result?.serverError) ?? fallbackErrorMessage;
if (result?.serverError === ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE) {
await startSsoReauthentication();
return;
} else if (result) {
errorMessage =
getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result);
}
const errorMessage = result
? (getLocalizedDeletionErrorMessage(result.serverError) ?? getFormattedErrorMessage(result))
: fallbackErrorMessage;
logger.error({ errorMessage }, "Account deletion action failed");
toast.error(errorMessage || fallbackErrorMessage);
return;
}
// Sign out with account deletion reason (no automatic redirect)
await signOutWithAudit({
reason: "account_deletion",
redirect: false, // Prevent NextAuth automatic redirect
clearEnvironmentId: true,
});
globalThis.localStorage.removeItem(FORMBRICKS_ENVIRONMENT_ID_LS);
// Manual redirect after signOut completes
if (isFormbricksCloud) {
globalThis.location.replace(FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL);
} else {
@@ -225,7 +194,7 @@ export const DeleteAccountModal = ({
/>
{!requiresPasswordConfirmation && (
<p className="mt-2 text-sm text-slate-600">
{t("environments.settings.profile.sso_reauthentication_may_be_required_for_deletion")}
{t("environments.settings.profile.sso_identity_confirmation_may_be_required_for_deletion")}
</p>
)}
{requiresPasswordConfirmation && (
-1
View File
@@ -4,7 +4,6 @@ export const ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE = "sso_reauth_failed"
export const ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE = "sso_reauth_required";
export const ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE = "account_deletion_email_mismatch";
export const ACCOUNT_DELETION_CONFIRMATION_REQUIRED_ERROR_CODE = "account_deletion_confirmation_required";
export const ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE = "google_reauth_not_configured";
export const FORMBRICKS_CLOUD_ACCOUNT_DELETION_SURVEY_URL =
"https://app.formbricks.com/s/clri52y3z8f221225wjdhsoo2";
@@ -0,0 +1,34 @@
import "server-only";
import { logger } from "@formbricks/logger";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
export const queueAccountDeletionAuditEvent = async ({
eventId,
oldUser,
status,
targetUserId,
userId = targetUserId,
}: {
eventId?: string;
oldUser?: Record<string, unknown> | null;
status: "success" | "failure";
targetUserId: string;
userId?: string;
}) => {
try {
await queueAuditEventBackground({
action: "deleted",
targetType: "user",
userId,
userType: "user",
targetId: targetUserId,
organizationId: UNKNOWN_DATA,
oldObject: oldUser,
status,
...(eventId ? { eventId } : {}),
});
} catch (error) {
logger.error({ error, targetUserId, userId }, "Failed to queue account deletion audit event");
}
};
@@ -1,4 +1,3 @@
import jwt from "jsonwebtoken";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ErrorCode } from "@formbricks/cache";
import { prisma } from "@formbricks/database";
@@ -7,9 +6,8 @@ import { cache } from "@/lib/cache";
import { createAccountDeletionSsoReauthIntent, verifyAccountDeletionSsoReauthIntent } from "@/lib/jwt";
import { getUserAuthenticationData } from "@/lib/user/password";
import {
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
} from "@/modules/account/constants";
import {
completeAccountDeletionSsoReauthentication,
@@ -52,7 +50,6 @@ vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED: true,
SAML_PRODUCT: "formbricks",
SAML_TENANT: "formbricks.com",
WEBAPP_URL: "http://localhost:3000",
@@ -106,15 +103,13 @@ const storedSamlIntent = {
userId: samlIntent.userId,
};
const createIdToken = (authTime: number) => jwt.sign({ auth_time: authTime }, "test-secret");
const createAuthnInstant = (authTime: number) => new Date(authTime * 1000).toISOString();
const mockRedisConsume = (value: unknown) => {
const redisEval = vi.fn().mockResolvedValue(value === null ? null : JSON.stringify(value));
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
return redisEval;
};
describe("account deletion SSO reauthentication", () => {
describe("account deletion SSO identity confirmation", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(crypto, "randomUUID").mockReturnValue("intent-id" as ReturnType<typeof crypto.randomUUID>);
@@ -129,7 +124,7 @@ describe("account deletion SSO reauthentication", () => {
vi.restoreAllMocks();
});
test("starts SSO reauthentication with a signed, cached intent", async () => {
test("starts SSO identity confirmation with a signed, cached intent", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -151,26 +146,45 @@ describe("account deletion SSO reauthentication", () => {
});
expect(result).toEqual({
authorizationParams: {
claims: JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
}),
login_hint: intent.email,
max_age: "0",
prompt: "login",
},
callbackUrl: "http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token",
provider: "google",
});
});
test("starts Azure AD reauthentication with standard OIDC step-up params", async () => {
test("requests interactive login without freshness-only SSO authorization parameters", async () => {
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
for (const identityProvider of ["google", "azuread", "openid"] as const) {
mockGetUserAuthenticationData.mockResolvedValueOnce({
email: intent.email,
identityProvider,
identityProviderAccountId: `${identityProvider}-account-id`,
password: null,
} as any);
const result = await startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
prompt: "login",
});
expect(result.authorizationParams).not.toHaveProperty("claims");
expect(result.authorizationParams).not.toHaveProperty("max_age");
}
});
test("starts GitHub SSO identity confirmation with account picker params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "azuread",
identityProviderAccountId: intent.providerAccountId,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
mockCreateAccountDeletionSsoReauthIntent.mockReturnValue("intent-token");
@@ -182,43 +196,13 @@ describe("account deletion SSO reauthentication", () => {
});
expect(result.authorizationParams).toEqual({
login_hint: intent.email,
max_age: "0",
prompt: "login",
login: intent.email,
prompt: "select_account",
});
expect(result.provider).toBe("azure-ad");
expect(result.provider).toBe("github");
});
test("extracts reauth intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
test("builds a safe profile redirect for SSO reauthentication callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
error: new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE),
intentToken: "intent-token",
})
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE}`
);
});
test("starts SAML reauthentication with forced-authentication params", async () => {
test("starts SAML SSO identity confirmation with Jackson routing and ForceAuthn params", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "saml",
@@ -245,24 +229,32 @@ describe("account deletion SSO reauthentication", () => {
});
});
test("does not start SSO reauthentication for providers without verifiable freshness", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "github",
identityProviderAccountId: "github-account-id",
password: null,
} as any);
test("extracts confirmation intents only from the expected callback URL", () => {
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"http://localhost:3000/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBe("intent-token");
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl("http://localhost:3000/auth/login?intent=intent-token")
).toBeNull();
expect(
getAccountDeletionSsoReauthIntentFromCallbackUrl(
"https://evil.example/auth/account-deletion/sso/complete?intent=intent-token"
)
).toBeNull();
});
await expect(
startAccountDeletionSsoReauthentication({
confirmationEmail: intent.email,
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
test("builds a safe profile redirect for SSO identity confirmation callback failures", () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
expect(
getAccountDeletionSsoReauthFailureRedirectUrl({
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
expect(mockCache.set).not.toHaveBeenCalled();
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
).toBe(
`http://localhost:3000/environments/env-1/settings/profile?${ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM}=${ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE}`
);
});
test("falls back to the web app URL when the return URL is unsafe", async () => {
@@ -287,7 +279,7 @@ describe("account deletion SSO reauthentication", () => {
);
});
test("does not start SSO reauthentication for password-backed users", async () => {
test("does not start SSO identity confirmation for password-backed users", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "email",
@@ -306,7 +298,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication when the confirmation email mismatches", async () => {
test("does not start SSO identity confirmation when the confirmation email mismatches", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -325,7 +317,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not start SSO reauthentication without a linked SSO provider account", async () => {
test("does not start SSO identity confirmation without a linked SSO provider account", async () => {
mockGetUserAuthenticationData.mockResolvedValue({
email: intent.email,
identityProvider: "google",
@@ -359,11 +351,49 @@ describe("account deletion SSO reauthentication", () => {
returnToUrl: "/environments/env-1/settings/profile",
userId: intent.userId,
})
).rejects.toThrow("Unable to start account deletion SSO reauthentication");
).rejects.toThrow("Unable to start account deletion SSO identity confirmation");
expect(mockCreateAccountDeletionSsoReauthIntent).not.toHaveBeenCalled();
});
test("validates a matching SSO callback before the normal SSO handler runs", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("validates a matching SAML callback without AuthnInstant freshness proof", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("fails SSO completion without consuming the intent when the callback provider does not match", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
@@ -421,11 +451,21 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects callbacks when the signed intent is for an unverifiable SSO provider", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue({
test("accepts GitHub callbacks because identity confirmation does not require freshness proof", async () => {
const githubIntent = {
...intent,
provider: "github",
providerAccountId: "github-account-id",
};
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(githubIntent);
mockCache.get.mockResolvedValue({
ok: true,
data: {
id: githubIntent.id,
provider: githubIntent.provider,
providerAccountId: githubIntent.providerAccountId,
userId: githubIntent.userId,
},
});
await expect(
@@ -437,9 +477,9 @@ describe("account deletion SSO reauthentication", () => {
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
).resolves.toBeUndefined();
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
@@ -460,145 +500,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.get).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 OIDC callbacks without an auth_time claim", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: jwt.sign({}, "test-secret"),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("validates a fresh SAML callback with an AuthnInstant", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).resolves.toBeUndefined();
expect(mockCache.get).toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects SAML callbacks without an AuthnInstant", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.get).not.toHaveBeenCalled();
});
test("rejects stale SAML AuthnInstant values without consuming the intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
await expect(
completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds - 10 * 60),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow(AuthorizationError);
expect(mockCache.del).not.toHaveBeenCalled();
expect(mockPrismaAccountFindUnique).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("rejects OIDC auth_time claims too far in the future", 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 + 2 * 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);
test("stores a deletion marker after SSO identity confirmation", async () => {
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
@@ -606,7 +508,6 @@ describe("account deletion SSO reauthentication", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -621,32 +522,7 @@ describe("account deletion SSO reauthentication", () => {
);
});
test("stores a deletion marker after fresh SAML reauthentication", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(samlIntent);
mockCache.get.mockResolvedValue({ ok: true, data: storedSamlIntent });
mockRedisConsume(storedSamlIntent);
mockPrismaAccountFindUnique.mockResolvedValue({ userId: samlIntent.userId } as any);
await completeAccountDeletionSsoReauthentication({
account: {
authn_instant: createAuthnInstant(nowInSeconds),
provider: "saml",
providerAccountId: samlIntent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining(storedSamlIntent),
5 * 60 * 1000
);
});
test("stores a deletion marker when the linked account is found through legacy user fields", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
@@ -655,7 +531,6 @@ describe("account deletion SSO reauthentication", () => {
await completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -680,7 +555,6 @@ describe("account deletion SSO reauthentication", () => {
});
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);
@@ -688,7 +562,6 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -701,7 +574,6 @@ describe("account deletion SSO reauthentication", () => {
});
test("fails SSO completion when the cached intent does not match the signed intent", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({
ok: true,
@@ -714,7 +586,6 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
@@ -728,7 +599,6 @@ describe("account deletion SSO reauthentication", () => {
});
test("fails SSO completion when the deletion marker cannot be cached", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: true, data: storedIntent });
mockRedisConsume(storedIntent);
@@ -738,35 +608,32 @@ describe("account deletion SSO reauthentication", () => {
await expect(
completeAccountDeletionSsoReauthentication({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to complete account deletion SSO reauthentication");
).rejects.toThrow("Unable to complete account deletion SSO identity confirmation");
});
test("surfaces cache read failures while validating callbacks", async () => {
const nowInSeconds = Math.floor(Date.now() / 1000);
mockVerifyAccountDeletionSsoReauthIntent.mockReturnValue(intent);
mockCache.get.mockResolvedValue({ ok: false, error: cacheError });
await expect(
validateAccountDeletionSsoReauthenticationCallback({
account: {
id_token: createIdToken(nowInSeconds),
provider: "google",
providerAccountId: intent.providerAccountId,
type: "oauth",
} as any,
intentToken: "intent-token",
})
).rejects.toThrow("Unable to read account deletion SSO reauth value");
).rejects.toThrow("Unable to read account deletion SSO identity confirmation value");
});
test("requires a completed SSO reauthentication marker before deleting an SSO account", async () => {
test("requires a completed SSO identity confirmation marker before deleting an SSO account", async () => {
mockRedisConsume(null);
await expect(
@@ -778,7 +645,7 @@ describe("account deletion SSO reauthentication", () => {
).rejects.toThrow(AuthorizationError);
});
test("consumes a valid SSO reauthentication marker", async () => {
test("consumes a valid SSO identity confirmation marker", async () => {
const redisEval = mockRedisConsume({
...storedIntent,
completedAt: Date.now(),
@@ -807,37 +674,12 @@ describe("account deletion SSO reauthentication", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unable to consume account deletion SSO reauth value");
).rejects.toThrow("Unable to consume account deletion SSO identity confirmation value");
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("atomically consumes a valid SSO reauthentication marker from Redis", async () => {
const redisEval = vi.fn().mockResolvedValue(
JSON.stringify({
...storedIntent,
completedAt: Date.now(),
})
);
mockCache.getRedisClient.mockResolvedValueOnce({ eval: redisEval } as any);
await expect(
consumeAccountDeletionSsoReauthentication({
identityProvider: "google",
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).resolves.toBeUndefined();
expect(redisEval).toHaveBeenCalledWith(expect.any(String), {
arguments: [],
keys: [expect.any(String)],
});
expect(mockCache.get).not.toHaveBeenCalled();
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects unexpected Redis values while consuming a marker", async () => {
mockCache.getRedisClient.mockResolvedValueOnce({
eval: vi.fn().mockResolvedValue(42),
@@ -849,7 +691,7 @@ describe("account deletion SSO reauthentication", () => {
providerAccountId: intent.providerAccountId,
userId: intent.userId,
})
).rejects.toThrow("Unexpected cached account deletion SSO reauth value");
).rejects.toThrow("Unexpected cached account deletion SSO identity confirmation value");
});
test("surfaces atomic Redis failures while consuming a marker", async () => {
@@ -885,7 +727,7 @@ describe("account deletion SSO reauthentication", () => {
expect(mockCache.del).not.toHaveBeenCalled();
});
test("rejects an expired SSO reauthentication marker", async () => {
test("rejects an expired SSO identity confirmation marker", async () => {
mockRedisConsume({
...storedIntent,
completedAt: Date.now() - 6 * 60 * 1000,
@@ -1,25 +1,18 @@
import "server-only";
import type { IdentityProvider } from "@prisma/client";
import jwt 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 {
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED,
SAML_PRODUCT,
SAML_TENANT,
WEBAPP_URL,
} from "@/lib/constants";
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_CONFIRMATION_REQUIRED_ERROR_CODE,
ACCOUNT_DELETION_EMAIL_MISMATCH_ERROR_CODE,
ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE,
ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH,
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE,
@@ -33,13 +26,8 @@ import {
const ACCOUNT_DELETION_SSO_REAUTH_INTENT_TTL_MS = 10 * 60 * 1000;
const ACCOUNT_DELETION_SSO_REAUTH_MARKER_TTL_MS = 5 * 60 * 1000;
const SSO_AUTH_TIME_MAX_AGE_SECONDS = 5 * 60;
const SSO_AUTH_TIME_FUTURE_SKEW_SECONDS = 60;
type TSsoIdentityProvider = Exclude<IdentityProvider, "email">;
type TAccountWithSamlAuthnInstant = Account & {
authn_instant?: unknown;
};
type TStoredAccountDeletionSsoReauthIntent = {
id: string;
@@ -72,22 +60,12 @@ const NEXT_AUTH_PROVIDER_BY_IDENTITY_PROVIDER = {
saml: "saml",
} as const satisfies Record<TSsoIdentityProvider, string>;
const OIDC_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([
const INTERACTIVE_SSO_CONFIRMATION_PROVIDERS = new Set<TSsoIdentityProvider>([
"azuread",
...(GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED ? (["google"] as const) : []),
"google",
"openid",
"saml",
]);
// GitHub OAuth does not return a verifiable auth_time/max_age proof, so it cannot secure this
// destructive action without another app-controlled step-up.
const FRESH_SSO_REAUTH_PROVIDERS = new Set<TSsoIdentityProvider>([...OIDC_REAUTH_PROVIDERS, "saml"]);
// Google only returns auth_time when it is explicitly requested as an ID token claim.
const GOOGLE_AUTH_TIME_CLAIMS_REQUEST = JSON.stringify({
id_token: {
auth_time: {
essential: true,
},
},
});
const getAccountDeletionSsoReauthIntentKey = (intentId: string) =>
createCacheKey.custom("account_deletion", "sso_reauth_intent", intentId);
@@ -106,70 +84,45 @@ const getSsoIdentityProviderOrThrow = (
return { provider: identityProvider, providerAccountId };
};
const assertSsoProviderSupportsFreshReauthentication = (provider: TSsoIdentityProvider) => {
if (provider === "google" && !GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"Google SSO account deletion reauthentication is not enabled"
);
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
if (!FRESH_SSO_REAUTH_PROVIDERS.has(provider)) {
logger.warn(
{ googleAccountDeletionReauthEnabled: GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED, provider },
"SSO provider does not support verifiable account deletion reauthentication"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const getAccountDeletionSsoReauthAuthorizationParams = (
provider: TSsoIdentityProvider,
email: string
): Record<string, string> => {
// This flow asks supported providers for an interactive login, but still only treats the callback
// as same-identity confirmation. Do not add max_age=0, Google auth_time claims, or AuthnInstant
// validation here unless the product decision changes back to strict step-up authentication.
// A future lower-friction alternative would be a short-lived email confirmation link that deletes
// the account after verifying the signed deletion intent, making the inbox the confirmation factor.
if (provider === "saml") {
return {
forceAuthn: "true",
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { forceAuthn: "true" } : {}),
product: SAML_PRODUCT,
provider: "saml",
tenant: SAML_TENANT,
};
}
if (OIDC_REAUTH_PROVIDERS.has(provider)) {
if (provider === "google") {
return {
claims: GOOGLE_AUTH_TIME_CLAIMS_REQUEST,
login_hint: email,
max_age: "0",
};
}
if (provider === "github") {
return {
login_hint: email,
max_age: "0",
prompt: "login",
login: email,
prompt: "select_account",
};
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
return {
login_hint: email,
...(INTERACTIVE_SSO_CONFIRMATION_PROVIDERS.has(provider) ? { prompt: "login" } : {}),
};
};
const getAccountDeletionSsoReauthErrorCode = () => ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
const createAccountDeletionSsoReauthCallbackUrl = (intentToken: string) => {
const callbackUrl = new URL(ACCOUNT_DELETION_SSO_REAUTH_CALLBACK_PATH, WEBAPP_URL);
callbackUrl.searchParams.set("intent", intentToken);
return callbackUrl.toString();
};
const getAccountDeletionSsoReauthErrorCode = (error: unknown) => {
if (error instanceof Error && error.message === ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE) {
return ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE;
}
return ACCOUNT_DELETION_SSO_REAUTH_FAILED_ERROR_CODE;
};
export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: string): string | null => {
const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL);
@@ -187,10 +140,8 @@ export const getAccountDeletionSsoReauthIntentFromCallbackUrl = (callbackUrl: st
};
export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
error,
intentToken,
}: {
error: unknown;
intentToken: string | null;
}): string | null => {
if (!intentToken) {
@@ -208,11 +159,11 @@ export const getAccountDeletionSsoReauthFailureRedirectUrl = ({
const redirectUrl = new URL(validatedReturnToUrl);
redirectUrl.searchParams.set(
ACCOUNT_DELETION_SSO_REAUTH_ERROR_QUERY_PARAM,
getAccountDeletionSsoReauthErrorCode(error)
getAccountDeletionSsoReauthErrorCode()
);
return redirectUrl.toString();
} catch (redirectError) {
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO reauth failure URL");
logger.error({ error: redirectError }, "Failed to resolve account deletion SSO confirmation failure URL");
return null;
}
};
@@ -224,9 +175,9 @@ const storeAccountDeletionSsoReauthIntent = async (intent: TStoredAccountDeletio
if (!result.ok) {
logger.error(
{ error: result.error, intentId: intent.id, userId: intent.userId },
"Failed to store SSO reauth intent"
"Failed to store SSO identity confirmation intent"
);
throw new Error("Unable to start account deletion SSO reauthentication");
throw new Error("Unable to start account deletion SSO identity confirmation");
}
};
@@ -237,9 +188,9 @@ const storeAccountDeletionSsoReauthMarker = async (marker: TAccountDeletionSsoRe
if (!result.ok) {
logger.error(
{ error: result.error, intentId: marker.id, userId: marker.userId },
"Failed to store account deletion SSO reauth marker"
"Failed to store account deletion SSO identity confirmation marker"
);
throw new Error("Unable to complete account deletion SSO reauthentication");
throw new Error("Unable to complete account deletion SSO identity confirmation");
}
};
@@ -249,13 +200,19 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
try {
redis = await cache.getRedisClient();
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to resolve Redis client for SSO reauth cache");
logger.error(
{ ...logContext, error, key },
"Failed to resolve Redis client for SSO identity confirmation cache"
);
throw error;
}
if (!redis) {
logger.error({ ...logContext, key }, "Redis is required to atomically consume SSO reauth cache value");
throw new Error("Unable to consume account deletion SSO reauth value");
logger.error(
{ ...logContext, key },
"Redis is required to atomically consume SSO identity confirmation cache value"
);
throw new Error("Unable to consume account deletion SSO identity confirmation value");
}
try {
@@ -278,13 +235,19 @@ const consumeCachedJsonValue = async <TValue>(key: string, logContext: Record<st
}
if (typeof serializedValue !== "string") {
logger.error({ ...logContext, key, serializedValue }, "Unexpected cached SSO reauth value");
throw new Error("Unexpected cached account deletion SSO reauth value");
logger.error(
{ ...logContext, key, serializedValue },
"Unexpected cached SSO identity confirmation value"
);
throw new Error("Unexpected cached account deletion SSO identity confirmation value");
}
return JSON.parse(serializedValue) as TValue;
} catch (error) {
logger.error({ ...logContext, error, key }, "Failed to atomically consume SSO reauth cache value");
logger.error(
{ ...logContext, error, key },
"Failed to atomically consume SSO identity confirmation cache value"
);
throw error;
}
};
@@ -293,8 +256,11 @@ const getCachedJsonValue = async <TValue>(key: string, logContext: Record<string
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");
logger.error(
{ ...logContext, error: cacheResult.error, key },
"Failed to read SSO identity confirmation cache value"
);
throw new Error("Unable to read account deletion SSO identity confirmation value");
}
return cacheResult.data;
@@ -379,89 +345,6 @@ const findLinkedSsoUserId = async ({
return legacyUser?.id ?? null;
};
const assertFreshAuthTime = (authTimeInSeconds: number, logContext: Record<string, unknown>) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
const isTooOld = nowInSeconds - authTimeInSeconds > SSO_AUTH_TIME_MAX_AGE_SECONDS;
const isFromTheFuture = authTimeInSeconds - nowInSeconds > SSO_AUTH_TIME_FUTURE_SKEW_SECONDS;
if (isTooOld || isFromTheFuture) {
logger.warn(
{
...logContext,
ageSeconds: nowInSeconds - authTimeInSeconds,
authTimeInSeconds,
futureSkewSeconds: authTimeInSeconds - nowInSeconds,
maxAgeSeconds: SSO_AUTH_TIME_MAX_AGE_SECONDS,
},
"SSO account deletion reauthentication timestamp is not fresh"
);
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
};
const assertFreshOidcAuthTime = (provider: TSsoIdentityProvider, idToken?: string) => {
if (!OIDC_REAUTH_PROVIDERS.has(provider)) {
return;
}
if (!idToken) {
logger.warn({ provider }, "OIDC account deletion reauthentication callback is missing an ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const decodedToken = jwt.decode(idToken);
if (!decodedToken || typeof decodedToken === "string") {
logger.warn({ provider }, "OIDC account deletion reauthentication callback has an invalid ID token");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const { auth_time: authTime } = decodedToken;
if (typeof authTime !== "number") {
logger.warn(
{ claimKeys: Object.keys(decodedToken), provider },
"OIDC account deletion reauthentication callback is missing numeric auth_time"
);
if (provider === "google") {
throw new AuthorizationError(ACCOUNT_DELETION_GOOGLE_REAUTH_NOT_CONFIGURED_ERROR_CODE);
}
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(authTime, { claim: "auth_time", provider });
};
const assertFreshSamlAuthnInstant = (
provider: TSsoIdentityProvider,
account: TAccountWithSamlAuthnInstant
) => {
if (provider !== "saml") {
return;
}
if (typeof account.authn_instant !== "string") {
logger.warn({ provider }, "SAML account deletion reauthentication callback is missing AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
const authnInstantTimestamp = Date.parse(account.authn_instant);
if (Number.isNaN(authnInstantTimestamp)) {
logger.warn({ provider }, "SAML account deletion reauthentication callback has invalid AuthnInstant");
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertFreshAuthTime(Math.floor(authnInstantTimestamp / 1000), { claim: "authn_instant", provider });
};
const assertFreshSsoAuthentication = (provider: TSsoIdentityProvider, account: Account) => {
assertSsoProviderSupportsFreshReauthentication(provider);
assertFreshOidcAuthTime(provider, account.id_token);
assertFreshSamlAuthnInstant(provider, account);
};
const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
const intent = verifyAccountDeletionSsoReauthIntent(intentToken);
const provider = normalizeSsoProvider(intent.provider);
@@ -470,8 +353,6 @@ const getVerifiedAccountDeletionSsoReauthIntent = (intentToken: string) => {
throw new AuthorizationError(ACCOUNT_DELETION_SSO_REAUTH_REQUIRED_ERROR_CODE);
}
assertSsoProviderSupportsFreshReauthentication(provider);
return {
intent,
storedIntent: {
@@ -525,7 +406,6 @@ const validateAccountDeletionSsoReauthenticationCallbackContext = async ({
expectedProviderAccountId: storedIntent.providerAccountId,
provider: normalizedProvider,
});
assertFreshSsoAuthentication(normalizedProvider, account);
await assertStoredAccountDeletionSsoReauthIntentExists(storedIntent);
return { intent, normalizedProvider, storedIntent };
@@ -550,8 +430,7 @@ export const startAccountDeletionSsoReauthentication = async ({
userAuthenticationData.identityProvider,
userAuthenticationData.identityProviderAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
logger.info({ provider, userId }, "Starting account deletion SSO reauthentication");
logger.info({ provider, userId }, "Starting account deletion SSO identity confirmation");
const intentId = crypto.randomUUID();
const validatedReturnToUrl = getValidatedCallbackUrl(returnToUrl, WEBAPP_URL) ?? WEBAPP_URL;
@@ -616,7 +495,7 @@ export const completeAccountDeletionSsoReauthentication = async ({
});
logger.info(
{ intentId: intent.id, provider: normalizedProvider, userId: intent.userId },
"Completed account deletion SSO reauthentication"
"Completed account deletion SSO identity confirmation"
);
};
@@ -646,7 +525,6 @@ export const consumeAccountDeletionSsoReauthentication = async ({
identityProvider,
providerAccountId
);
assertSsoProviderSupportsFreshReauthentication(provider);
const marker = await consumeCachedJsonValue<TAccountDeletionSsoReauthMarker>(
getAccountDeletionSsoReauthMarkerKey(userId),
@@ -22,9 +22,9 @@ const oldUser = {
};
const loadAccountDeletionModule = async ({
dangerouslyDisableSsoReauth = false,
dangerouslyDisableSsoConfirmation = false,
}: {
dangerouslyDisableSsoReauth?: boolean;
dangerouslyDisableSsoConfirmation?: boolean;
} = {}) => {
vi.resetModules();
@@ -35,7 +35,7 @@ const loadAccountDeletionModule = async ({
}));
vi.doMock("@/lib/constants", () => ({
DISABLE_ACCOUNT_DELETION_SSO_REAUTH: dangerouslyDisableSsoReauth,
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION: dangerouslyDisableSsoConfirmation,
}));
vi.doMock("@/lib/organization/service", () => ({
@@ -81,7 +81,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
mocks.verifyUserPassword.mockResolvedValue(true);
});
test("requires the completed SSO reauthentication marker by default", async () => {
test("requires the completed SSO identity confirmation marker by default", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule();
await expect(
@@ -102,9 +102,9 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
test("can dangerously bypass SSO reauthentication for passwordless SSO users", async () => {
test("can dangerously bypass SSO identity confirmation for passwordless SSO users", async () => {
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
dangerouslyDisableSsoConfirmation: true,
});
await expect(
@@ -118,7 +118,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
expect(mocks.consumeAccountDeletionSsoReauthentication).not.toHaveBeenCalled();
expect(mocks.loggerWarn).toHaveBeenCalledWith(
{ identityProvider: "google", userId: user.id },
"Account deletion SSO reauthentication bypassed by environment configuration"
"Account deletion SSO identity confirmation bypassed by environment configuration"
);
expect(mocks.deleteUser).toHaveBeenCalledWith(user.id);
});
@@ -131,7 +131,7 @@ describe("deleteUserWithAccountDeletionAuthorization", () => {
password: "hashed-password",
});
const { deleteUserWithAccountDeletionAuthorization } = await loadAccountDeletionModule({
dangerouslyDisableSsoReauth: true,
dangerouslyDisableSsoConfirmation: true,
});
await expect(
@@ -2,7 +2,7 @@ import "server-only";
import type { IdentityProvider } from "@prisma/client";
import { logger } from "@formbricks/logger";
import { AuthorizationError, InvalidInputError, OperationNotAllowedError } from "@formbricks/types/errors";
import { DISABLE_ACCOUNT_DELETION_SSO_REAUTH } from "@/lib/constants";
import { DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION } from "@/lib/constants";
import { getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { getUserAuthenticationData, verifyUserPassword } from "@/lib/user/password";
import { deleteUser, getUser } from "@/lib/user/service";
@@ -29,10 +29,10 @@ const assertConfirmationEmailMatches = (confirmationEmail: string, expectedEmail
}
};
const canBypassSsoReauthentication = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_REAUTH && identityProvider !== "email";
const canBypassSsoIdentityConfirmation = (identityProvider: IdentityProvider) =>
DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION && identityProvider !== "email";
const assertAccountDeletionSsoReauthentication = async ({
const assertAccountDeletionSsoIdentityConfirmation = async ({
identityProvider,
providerAccountId,
userId,
@@ -41,10 +41,10 @@ const assertAccountDeletionSsoReauthentication = async ({
providerAccountId: string | null;
userId: string;
}) => {
if (canBypassSsoReauthentication(identityProvider)) {
if (canBypassSsoIdentityConfirmation(identityProvider)) {
logger.warn(
{ identityProvider, userId },
"Account deletion SSO reauthentication bypassed by environment configuration"
"Account deletion SSO identity confirmation bypassed by environment configuration"
);
return;
}
@@ -95,7 +95,7 @@ export const deleteUserWithAccountDeletionAuthorization = async ({
}
if (!requiresPasswordConfirmationForAccountDeletion(userAuthenticationData)) {
await assertAccountDeletionSsoReauthentication({
await assertAccountDeletionSsoIdentityConfirmation({
identityProvider: userAuthenticationData.identityProvider,
providerAccountId: userAuthenticationData.identityProviderAccountId,
userId,
@@ -699,7 +699,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should complete account deletion SSO reauthentication before finalizing sign-in", async () => {
test("should complete account deletion SSO identity confirmation before finalizing sign-in", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true);
@@ -771,7 +771,7 @@ describe("authOptions", () => {
expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email);
});
test("should redirect account deletion SSO reauthentication failures back to the profile page", async () => {
test("should redirect account deletion SSO identity confirmation failures back to the profile page", async () => {
vi.resetModules();
const mockHandleSsoCallback = vi.fn();
@@ -781,17 +781,15 @@ describe("authOptions", () => {
const mockGetAccountDeletionSsoReauthFailureRedirectUrl = vi
.fn()
.mockReturnValueOnce(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
);
const mockGetAccountDeletionSsoReauthIntentFromCallbackUrl = vi
.fn()
.mockReturnValueOnce("intent-token");
const reauthError = new Error(
"Google account deletion requires Google Auth Platform Session age claims to be enabled."
);
const confirmationError = new Error("SSO identity confirmation failed");
const mockValidateAccountDeletionSsoReauthenticationCallback = vi
.fn()
.mockRejectedValueOnce(reauthError);
.mockRejectedValueOnce(confirmationError);
vi.doMock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
@@ -840,11 +838,10 @@ describe("authOptions", () => {
const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any;
await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=google_reauth_not_configured"
"http://localhost:3000/environments/env-id/settings/profile?accountDeletionError=sso_reauth_failed"
);
expect(mockGetAccountDeletionSsoReauthFailureRedirectUrl).toHaveBeenCalledWith({
error: reauthError,
intentToken: "intent-token",
});
expect(mockHandleSsoCallback).not.toHaveBeenCalled();
+4 -5
View File
@@ -92,7 +92,7 @@ const handleCredentialsOrTokenSignIn = async ({
return true;
};
const maybeValidateAccountDeletionSsoReauth = async ({
const maybeValidateAccountDeletionSsoReauthenticationCallback = async ({
account,
intentToken,
}: {
@@ -109,7 +109,7 @@ const maybeValidateAccountDeletionSsoReauth = async ({
});
};
const maybeCompleteAccountDeletionSsoReauth = async ({
const maybeCompleteAccountDeletionSsoReauthentication = async ({
account,
intentToken,
}: {
@@ -141,7 +141,7 @@ const handleEnterpriseSsoSignIn = async ({
userEmail: string;
userId: string;
}) => {
await maybeValidateAccountDeletionSsoReauth({ account, intentToken });
await maybeValidateAccountDeletionSsoReauthenticationCallback({ account, intentToken });
const result = await handleSsoCallback({
user: user as TUser,
@@ -150,7 +150,7 @@ const handleEnterpriseSsoSignIn = async ({
});
if (result === true) {
await maybeCompleteAccountDeletionSsoReauth({ account, intentToken });
await maybeCompleteAccountDeletionSsoReauthentication({ account, intentToken });
await finalizeSuccessfulSignIn({
userId,
@@ -495,7 +495,6 @@ export const authOptions: NextAuthOptions = {
});
} catch (error) {
const failureRedirectUrl = getAccountDeletionSsoReauthFailureRedirectUrl({
error,
intentToken: accountDeletionSsoReauthIntentToken,
});
@@ -1,7 +1,5 @@
import { redirect } from "next/navigation";
import { logger } from "@formbricks/logger";
import { responses } from "@/app/lib/api/response";
import { storeSamlAuthnInstantFromSamlResponse } from "@/modules/ee/auth/saml/lib/authn-instant";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
interface SAMLCallbackBody {
@@ -14,7 +12,7 @@ export const POST = async (req: Request) => {
if (!jacksonInstance) {
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
}
const { connectionController, oauthController } = jacksonInstance;
const { oauthController } = jacksonInstance;
const formData = await req.formData();
const body = Object.fromEntries(formData.entries());
@@ -30,15 +28,5 @@ export const POST = async (req: Request) => {
return responses.internalServerErrorResponse("Failed to get redirect URL");
}
try {
await storeSamlAuthnInstantFromSamlResponse({
connectionController,
redirectUrl: redirect_url,
samlResponse: SAMLResponse,
});
} catch (error) {
logger.error({ error }, "Failed to persist SAML AuthnInstant");
}
return redirect(redirect_url);
};
@@ -1,7 +1,5 @@
import type { OAuthTokenReq } from "@boxyhq/saml-jackson";
import { logger } from "@formbricks/logger";
import { responses } from "@/app/lib/api/response";
import { consumeSamlAuthnInstantForCode } from "@/modules/ee/auth/saml/lib/authn-instant";
import jackson from "@/modules/ee/auth/saml/lib/jackson";
export const POST = async (req: Request) => {
@@ -15,13 +13,6 @@ export const POST = async (req: Request) => {
const formData = Object.fromEntries(body.entries());
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
let authnInstant: string | null = null;
try {
authnInstant = await consumeSamlAuthnInstantForCode(formData.code);
} catch (error) {
logger.error({ error }, "Failed to consume SAML AuthnInstant");
}
return Response.json(authnInstant ? { ...response, authn_instant: authnInstant } : response);
return Response.json(response);
};
@@ -1,189 +0,0 @@
import { createHash } from "node:crypto";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { cache } from "@/lib/cache";
import {
consumeSamlAuthnInstantForCode,
getSamlAuthnInstantFromResponse,
getSamlAuthnInstantFromXml,
storeSamlAuthnInstantFromSamlResponse,
} from "./authn-instant";
vi.mock("@/lib/cache", () => ({
cache: {
del: vi.fn(),
get: vi.fn(),
set: vi.fn(),
},
}));
vi.mock("@boxyhq/saml20", () => ({
default: {
decryptXml: vi.fn(),
parseIssuer: vi.fn(),
validateSignature: vi.fn(),
},
}));
vi.mock("@boxyhq/saml-jackson/dist/saml/x509", () => ({
getDefaultCertificate: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
const saml20 = await import("@boxyhq/saml20");
const x509 = await import("@boxyhq/saml-jackson/dist/saml/x509");
const mockCache = vi.mocked(cache);
const mockSaml20 = vi.mocked(saml20.default);
const mockGetDefaultCertificate = vi.mocked(x509.getDefaultCertificate);
const connectionController = {
getConnections: vi.fn(),
};
const encodeSamlResponse = (xml: string) => Buffer.from(xml, "utf8").toString("base64");
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
const signedSamlResponse = `
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
`;
describe("SAML AuthnInstant handoff", () => {
beforeEach(() => {
vi.resetAllMocks();
mockCache.set.mockResolvedValue({ ok: true, data: undefined });
mockCache.del.mockResolvedValue({ ok: true, data: undefined });
mockGetDefaultCertificate.mockResolvedValue({
privateKey: "sp-private-key",
publicKey: "sp-public-key",
});
mockSaml20.parseIssuer.mockReturnValue("https://idp.example.com/metadata");
mockSaml20.decryptXml.mockReturnValue({ assertion: signedSamlResponse, decrypted: true });
mockSaml20.validateSignature.mockReturnValue(signedSamlResponse);
connectionController.getConnections.mockResolvedValue([
{
idpMetadata: {
publicKey: "trusted-public-key",
},
},
]);
});
test("extracts and normalizes AuthnInstant from signed SAML XML", () => {
expect(getSamlAuthnInstantFromXml(signedSamlResponse)).toBe("2026-05-04T12:30:00.000Z");
});
test("extracts AuthnInstant from the signature-validated SAML response", async () => {
const samlResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:00:00Z" />
</saml:Assertion>
</samlp:Response>
`;
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(samlResponse),
})
).resolves.toBe("2026-05-04T12:30:00.000Z");
expect(mockSaml20.validateSignature).toHaveBeenCalledWith(samlResponse, "trusted-public-key", null);
});
test("extracts AuthnInstant from encrypted signature-validated SAML responses", async () => {
const encryptedSignedResponse = `
<samlp:Response>
<saml:EncryptedAssertion>encrypted-assertion</saml:EncryptedAssertion>
</samlp:Response>
`;
const decryptedSignedResponse = `
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:45:00Z" />
</saml:Assertion>
</samlp:Response>
`;
mockSaml20.validateSignature.mockReturnValue(encryptedSignedResponse);
mockSaml20.decryptXml.mockReturnValue({ assertion: decryptedSignedResponse, decrypted: true });
await expect(
getSamlAuthnInstantFromResponse({
connectionController: connectionController as any,
samlResponse: encodeSamlResponse(encryptedSignedResponse),
})
).resolves.toBe("2026-05-04T12:45:00.000Z");
expect(mockGetDefaultCertificate).toHaveBeenCalled();
expect(mockSaml20.decryptXml).toHaveBeenCalledWith(encryptedSignedResponse, {
privateKey: "sp-private-key",
});
});
test("stores signed AuthnInstant by the one-time OAuth code from the Jackson redirect", async () => {
const samlResponse = encodeSamlResponse(`
<samlp:Response>
<saml:Assertion>
<saml:AuthnStatement AuthnInstant="2026-05-04T12:30:00Z" />
</saml:Assertion>
</samlp:Response>
`);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse,
});
expect(mockCache.set).toHaveBeenCalledWith(
expect.any(String),
{ authnInstant: "2026-05-04T12:30:00.000Z" },
5 * 60 * 1000
);
const cacheKey = mockCache.set.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
});
test("does not store when the signed SAML XML has no AuthnInstant", async () => {
mockSaml20.validateSignature.mockReturnValue("<saml:Assertion />");
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("does not store when the SAML signature cannot be validated with known IdP metadata", async () => {
mockSaml20.validateSignature.mockReturnValue(null);
await storeSamlAuthnInstantFromSamlResponse({
connectionController: connectionController as any,
redirectUrl: "http://localhost:3000/api/auth/callback/saml?code=oauth-code&state=state",
samlResponse: encodeSamlResponse("<samlp:Response />"),
});
expect(mockCache.set).not.toHaveBeenCalled();
});
test("consumes a stored AuthnInstant for the token response", async () => {
mockCache.get.mockResolvedValue({
ok: true,
data: {
authnInstant: "2026-05-04T12:30:00.000Z",
},
});
await expect(consumeSamlAuthnInstantForCode("oauth-code")).resolves.toBe("2026-05-04T12:30:00.000Z");
const cacheKey = mockCache.get.mock.calls[0][0] as string;
expect(cacheKey).toContain(getSamlCodeHash("oauth-code"));
expect(cacheKey).not.toContain("oauth-code");
expect(mockCache.del).toHaveBeenCalledWith([cacheKey]);
});
});
@@ -1,185 +0,0 @@
import "server-only";
import saml20 from "@boxyhq/saml20";
import type { IConnectionAPIController, SAMLSSORecord } from "@boxyhq/saml-jackson";
import { getDefaultCertificate } from "@boxyhq/saml-jackson/dist/saml/x509";
import { createHash } from "node:crypto";
import { createCacheKey } from "@formbricks/cache";
import { logger } from "@formbricks/logger";
import { cache } from "@/lib/cache";
const SAML_AUTHN_INSTANT_TTL_MS = 5 * 60 * 1000;
type TSamlAuthnInstantCacheValue = {
authnInstant: string;
};
type TSamlConnection = Awaited<ReturnType<IConnectionAPIController["getConnections"]>>[number];
const authnInstantRegex = /<[\w:-]*AuthnStatement\b[^>]*\bAuthnInstant\s*=\s*["']([^"']+)["']/;
const encryptedAssertionRegex = /<[\w:-]*EncryptedAssertion\b/;
const getSamlCodeHash = (code: string) => createHash("sha256").update(code).digest("hex");
const getSamlAuthnInstantCacheKey = (code: string) =>
createCacheKey.custom("account_deletion", "saml_authn_instant", getSamlCodeHash(code));
const isSamlConnection = (connection: TSamlConnection): connection is SAMLSSORecord =>
"idpMetadata" in connection;
const getCodeFromRedirectUrl = (redirectUrl: string) => {
try {
return new URL(redirectUrl).searchParams.get("code");
} catch {
return null;
}
};
export const getSamlAuthnInstantFromXml = (samlXml: string): string | null => {
// Use .exec() instead of .match()
const match = authnInstantRegex.exec(samlXml);
const authnInstant = match?.[1];
if (!authnInstant) {
return null;
}
const authnInstantTimestamp = Date.parse(authnInstant);
if (Number.isNaN(authnInstantTimestamp)) {
return null;
}
return new Date(authnInstantTimestamp).toISOString();
};
const getSignedSamlXml = async ({
connectionController,
decodedSamlResponse,
}: {
connectionController: IConnectionAPIController;
decodedSamlResponse: string;
}) => {
const issuer = saml20.parseIssuer(decodedSamlResponse);
if (!issuer) {
return null;
}
const connections = await connectionController.getConnections({ entityId: issuer });
for (const connection of connections) {
if (!isSamlConnection(connection)) {
continue;
}
const { publicKey, thumbprint } = connection.idpMetadata;
if (!publicKey && !thumbprint) {
continue;
}
try {
const signedXml = saml20.validateSignature(decodedSamlResponse, publicKey ?? null, thumbprint ?? null);
if (signedXml) {
return signedXml;
}
} catch {
continue;
}
}
return null;
};
const getReadableSignedSamlXml = async (signedSamlXml: string) => {
if (!encryptedAssertionRegex.test(signedSamlXml)) {
return signedSamlXml;
}
const { privateKey } = await getDefaultCertificate();
return saml20.decryptXml(signedSamlXml, { privateKey }).assertion;
};
export const getSamlAuthnInstantFromResponse = async ({
connectionController,
samlResponse,
}: {
connectionController: IConnectionAPIController;
samlResponse: string;
}): Promise<string | null> => {
const decodedSamlResponse = Buffer.from(samlResponse, "base64").toString("utf8");
const signedSamlXml = await getSignedSamlXml({
connectionController,
decodedSamlResponse,
});
if (!signedSamlXml) {
return null;
}
return getSamlAuthnInstantFromXml(await getReadableSignedSamlXml(signedSamlXml));
};
export const storeSamlAuthnInstantFromSamlResponse = async ({
connectionController,
redirectUrl,
samlResponse,
}: {
connectionController: IConnectionAPIController;
redirectUrl: string;
samlResponse: string;
}) => {
const code = getCodeFromRedirectUrl(redirectUrl);
if (!code) {
return;
}
const authnInstant = await getSamlAuthnInstantFromResponse({
connectionController,
samlResponse,
}).catch((error: unknown) => {
logger.error({ error }, "Failed to extract SAML AuthnInstant");
return null;
});
if (!authnInstant) {
return;
}
const result = await cache.set(
getSamlAuthnInstantCacheKey(code),
{ authnInstant },
SAML_AUTHN_INSTANT_TTL_MS
);
if (!result.ok) {
logger.error({ error: result.error }, "Failed to store SAML AuthnInstant");
}
};
export const consumeSamlAuthnInstantForCode = async (code: unknown): Promise<string | null> => {
if (typeof code !== "string" || !code) {
return null;
}
const cacheKey = getSamlAuthnInstantCacheKey(code);
const result = await cache.get<TSamlAuthnInstantCacheValue>(cacheKey);
if (!result.ok) {
logger.error({ error: result.error }, "Failed to read SAML AuthnInstant");
return null;
}
if (!result.data) {
return null;
}
const deleteResult = await cache.del([cacheKey]);
if (!deleteResult.ok) {
logger.error({ error: deleteResult.error }, "Failed to consume SAML AuthnInstant");
}
return result.data.authnInstant;
};
-1
View File
@@ -20,7 +20,6 @@
},
"dependencies": {
"@boxyhq/saml-jackson": "26.2.0",
"@boxyhq/saml20": "1.15.2",
"@dnd-kit/core": "6.3.1",
"@dnd-kit/modifiers": "9.0.0",
"@dnd-kit/sortable": "10.0.0",
@@ -16,25 +16,16 @@ Integrating Google OAuth with your Formbricks instance allows users to log in us
- A Formbricks instance running
### Account deletion reauthentication
### Account Deletion SSO Confirmation
For SSO-only users, Formbricks requires a fresh Google `auth_time` claim before deleting the account. Google only returns this claim when your OAuth app is published, verified, and has **Session age claims** enabled in Google Auth Platform.
For SSO-only users, Formbricks asks the user to type their email address and then redirects them through Google OAuth before deleting the account. Formbricks asks Google for an interactive login prompt, and the deletion continues only when Google returns the same linked provider account.
To enable it, open Google Auth Platform, select your app project, go to **Settings**, and under **Advanced Settings** enable **Session age claims**. Then set:
```sh
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
```
If this Google setting and environment variable are not enabled together, Google login can still work, but SSO-only account deletion will fail closed.
Google does not support app-triggered Google Account reauthentication requests. If the returned `auth_time` is too old, the deletion flow is rejected and the user must complete Google sign-in from a fresh Google session before trying again.
This confirms the Google identity for the current deletion attempt, but it does not validate a provider-side freshness proof. Google still controls whether it asks for a password or MFA.
<Warning>
If you need to allow SSO-only users to delete their accounts without a fresh SSO reauthentication check, set
`DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1`. This bypasses the deletion reauthentication marker for passwordless
SSO accounts, so users can delete their account with email confirmation only. Keep it unset unless you
accept this security trade-off.
If you set `DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1`, Formbricks skips this SSO identity confirmation
redirect for passwordless SSO accounts. Users can delete their account with only the in-app email text
confirmation. Keep it unset unless you accept this security trade-off.
</Warning>
### How to connect your Formbricks instance to Google
@@ -77,10 +68,8 @@ Google does not support app-triggered Google Account reauthentication requests.
```sh
GOOGLE_CLIENT_ID=your-client-id-here
GOOGLE_CLIENT_SECRET=your-client-secret-here
# Optional: only when Google Auth Platform Session age claims are enabled.
GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED=1
# Optional: dangerous fallback that disables fresh SSO reauthentication for account deletion.
# DISABLE_ACCOUNT_DELETION_SSO_REAUTH=1
# Optional: dangerous fallback that skips SSO identity confirmation for account deletion.
# DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION=1
```
- Alternatively, you can add the environment variables directly to the running container using the following commands (replace `container_id` with your actual Docker container ID):
@@ -35,7 +35,7 @@ These variables are present inside your machine's docker-compose file. Restart t
| PASSWORD_RESET_DISABLED | Disables password reset functionality if set to 1. | optional | |
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
| DISABLE_ACCOUNT_DELETION_SSO_REAUTH | Disables fresh SSO reauthentication for passwordless SSO account deletion if set to 1. Users can delete their account with email confirmation only. Keep unset unless you accept this security trade-off. | optional | |
| DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION | Skips the SSO identity confirmation redirect for passwordless SSO account deletion if set to 1. Users can delete SSO accounts with only the in-app email text confirmation. Keep unset unless you accept this security trade-off. | optional | |
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
@@ -57,7 +57,6 @@ These variables are present inside your machine's docker-compose file. Restart t
| GITHUB_SECRET | Secret for GitHub. | optional (required if GitHub auth is enabled) | |
| GOOGLE_CLIENT_ID | Client ID for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_CLIENT_SECRET | Secret for Google. | optional (required if Google auth is enabled) | |
| GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED | Enables Google `auth_time` validation for SSO-only account deletion if set to 1. Only enable after Google Auth Platform Session age claims are enabled for the OAuth app. | optional | |
| AI_PROVIDER | Instance-level AI provider used in the background. Supported values: `aws`, `gcp`, `azure`. | optional (required if AI is enabled) | |
| AI_MODEL | Instance-level AI model or deployment name used by the active provider. | optional (required if `AI_PROVIDER` is set) | |
| AI_GCP_PROJECT | Google Cloud project ID for Vertex AI. | optional (required if `AI_PROVIDER=gcp`) | |
-23
View File
@@ -118,9 +118,6 @@ importers:
'@boxyhq/saml-jackson':
specifier: 26.2.0
version: 26.2.0(@types/node@25.4.0)(socks@2.8.7)(sqlite3@5.1.7)(ts-node@10.9.2(@types/node@25.4.0)(typescript@5.9.3))
'@boxyhq/saml20':
specifier: 1.15.2
version: 1.15.2
'@dnd-kit/core':
specifier: 6.3.1
version: 6.3.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
@@ -1724,9 +1721,6 @@ packages:
'@boxyhq/saml20@1.13.2':
resolution: {integrity: sha512-iMbFK2I/fB7Iu8qsUTfGBwd2KyVviRPwslxthWMHHSOLbty6GQvrqB07XO3w8kVZVUs20uHfa/azxZO8opMdHA==}
'@boxyhq/saml20@1.15.2':
resolution: {integrity: sha512-kdceDRQMfVft/CdpsKOAwYe44EZPRkqvIt6yh1Hh+ugoab/lKjb13wUvLJwNrWwftMRYrV7oS7N05viu+ljQDw==}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
@@ -11422,9 +11416,6 @@ packages:
xml-encryption@3.1.0:
resolution: {integrity: sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==}
xml-encryption@4.0.0:
resolution: {integrity: sha512-UvSSRKoDfmyH/ECiKPbhHXMKhcXKOYLva7ifmzitN4BNXLAfdgez+nQANJ3jllmY42D5bdeVvIK0Y7hzcAAlyQ==}
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -13112,14 +13103,6 @@ snapshots:
xml2js: 0.6.2
xmlbuilder: 15.1.1
'@boxyhq/saml20@1.15.2':
dependencies:
'@xmldom/xmldom': 0.9.10
xml-crypto: 6.1.2
xml-encryption: 4.0.0
xml2js: 0.6.2
xmlbuilder: 15.1.1
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
@@ -23914,12 +23897,6 @@ snapshots:
escape-html: 1.0.3
xpath: 0.0.32
xml-encryption@4.0.0:
dependencies:
'@xmldom/xmldom': 0.9.10
escape-html: 1.0.3
xpath: 0.0.32
xml-name-validator@5.0.0: {}
xml-naming@0.1.0: {}
+1 -2
View File
@@ -190,7 +190,7 @@
"BREVO_API_KEY",
"BREVO_LIST_ID",
"CRON_SECRET",
"DISABLE_ACCOUNT_DELETION_SSO_REAUTH",
"DISABLE_ACCOUNT_DELETION_SSO_CONFIRMATION",
"DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS",
"DATABASE_URL",
"DEBUG",
@@ -203,7 +203,6 @@
"ENVIRONMENT",
"GITHUB_ID",
"GITHUB_SECRET",
"GOOGLE_ACCOUNT_DELETION_REAUTH_ENABLED",
"GOOGLE_CLIENT_ID",
"GOOGLE_CLIENT_SECRET",
"GOOGLE_SHEETS_CLIENT_ID",