diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index bf12561d94f2..76ed9715aabb 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -85,65 +85,48 @@ jobs: echo "S3_REGION=us-east-1" >> .env echo "S3_BUCKET_NAME=formbricks-e2e" >> .env echo "S3_ENDPOINT_URL=http://localhost:9000" >> .env - echo "S3_ACCESS_KEY=devminio" >> .env - echo "S3_SECRET_KEY=devminio123" >> .env + echo "S3_ACCESS_KEY=devrustfs-service" >> .env + echo "S3_SECRET_KEY=devrustfs-service123" >> .env echo "S3_FORCE_PATH_STYLE=1" >> .env shell: bash - - name: Install MinIO client (mc) + - name: Start RustFS Server run: | set -euo pipefail - MC_VERSION="RELEASE.2025-08-13T08-35-41Z" - MC_BASE="https://dl.min.io/client/mc/release/linux-amd64/archive" - MC_BIN="mc.${MC_VERSION}" - MC_SUM="${MC_BIN}.sha256sum" - curl -fsSL "${MC_BASE}/${MC_BIN}" -o "${MC_BIN}" - curl -fsSL "${MC_BASE}/${MC_SUM}" -o "${MC_SUM}" - - sha256sum -c "${MC_SUM}" - - chmod +x "${MC_BIN}" - sudo mv "${MC_BIN}" /usr/local/bin/mc - - - name: Start MinIO Server - run: | - set -euo pipefail - - # Start MinIO server in background + # Start RustFS server in background docker run -d \ - --name minio-server \ + --name rustfs-server \ -p 9000:9000 \ -p 9001:9001 \ - -e MINIO_ROOT_USER=devminio \ - -e MINIO_ROOT_PASSWORD=devminio123 \ - minio/minio:RELEASE.2025-09-07T16-13-09Z \ - server /data --console-address :9001 + -e RUSTFS_ACCESS_KEY=devrustfs \ + -e RUSTFS_SECRET_KEY=devrustfs123 \ + -e RUSTFS_ADDRESS=:9000 \ + -e RUSTFS_CONSOLE_ENABLE=true \ + -e RUSTFS_CONSOLE_ADDRESS=:9001 \ + rustfs/rustfs:1.0.0-alpha.93 \ + /data - echo "MinIO server started" + echo "RustFS server started" - - name: Wait for MinIO and create S3 bucket + - name: Bootstrap RustFS bucket and browser upload CORS run: | set -euo pipefail - echo "Waiting for MinIO to be ready..." - ready=0 - for i in {1..60}; do - if curl -fsS http://localhost:9000/minio/health/live >/dev/null; then - echo "MinIO is up after ${i} seconds" - ready=1 - break - fi - sleep 1 - done - - if [ "$ready" -ne 1 ]; then - echo "::error::MinIO did not become ready within 60 seconds" - exit 1 - fi - - mc alias set local http://localhost:9000 devminio devminio123 - mc mb --ignore-existing local/formbricks-e2e + docker run --rm \ + --network host \ + --entrypoint /bin/sh \ + -e RUSTFS_ENDPOINT_URL=http://127.0.0.1:9000 \ + -e RUSTFS_ADMIN_USER=devrustfs \ + -e RUSTFS_ADMIN_PASSWORD=devrustfs123 \ + -e RUSTFS_SERVICE_USER=devrustfs-service \ + -e RUSTFS_SERVICE_PASSWORD=devrustfs-service123 \ + -e RUSTFS_BUCKET_NAME=formbricks-e2e \ + -e RUSTFS_POLICY_NAME=formbricks-e2e-policy \ + -e RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000 \ + -v "$PWD/docker/rustfs-init.sh:/tmp/rustfs-init.sh:ro" \ + minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 \ + /tmp/rustfs-init.sh - name: Build App run: | @@ -242,8 +225,14 @@ jobs: if: failure() with: name: app-logs + if-no-files-found: ignore path: app.log - name: Output App Logs if: failure() - run: cat app.log + run: | + if [ -f app.log ]; then + cat app.log + else + echo "app.log not found because the Run App step did not execute or failed before log creation." + fi diff --git a/apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts b/apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts index 8a7904f38625..7d70a25304b7 100644 --- a/apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts +++ b/apps/web/app/(app)/(onboarding)/lib/onboarding.test.ts @@ -1,4 +1,4 @@ -import { Prisma } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { DatabaseError } from "@formbricks/types/errors"; @@ -32,7 +32,7 @@ describe("getTeamsByOrganizationId", () => { test("throws DatabaseError on Prisma error", async () => { vi.mocked(prisma.team.findMany).mockRejectedValueOnce( - new Prisma.PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) + new PrismaClientKnownRequestError("fail", { code: "P2002", clientVersion: "1.0.0" }) ); await expect(getTeamsByOrganizationId("org1")).rejects.toThrow(DatabaseError); }); diff --git a/apps/web/app/(app)/(onboarding)/lib/onboarding.ts b/apps/web/app/(app)/(onboarding)/lib/onboarding.ts index 5f4b6cd8861e..bf13e77e3603 100644 --- a/apps/web/app/(app)/(onboarding)/lib/onboarding.ts +++ b/apps/web/app/(app)/(onboarding)/lib/onboarding.ts @@ -1,6 +1,6 @@ "use server"; -import { Prisma } from "@prisma/client"; +import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library"; import { cache as reactCache } from "react"; import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; @@ -27,7 +27,7 @@ export const getTeamsByOrganizationId = reactCache( name: team.name, })); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error instanceof PrismaClientKnownRequestError) { throw new DatabaseError(error.message); } diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx index 27a03baef4d4..b239f8e24230 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyAnalysisNavigation.tsx @@ -3,25 +3,22 @@ import { InboxIcon, PresentationIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { useTranslation } from "react-i18next"; -import { TSurvey } from "@formbricks/types/surveys/types"; +import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { revalidateSurveyIdPath } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions"; +import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context"; import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation"; interface SurveyAnalysisNavigationProps { - environmentId: string; - survey: TSurvey; activeId: string; } -export const SurveyAnalysisNavigation = ({ - environmentId, - survey, - activeId, -}: SurveyAnalysisNavigationProps) => { +export const SurveyAnalysisNavigation = ({ activeId }: SurveyAnalysisNavigationProps) => { const pathname = usePathname(); const { t } = useTranslation(); + const { environment } = useEnvironment(); + const { survey } = useSurvey(); - const url = `/environments/${environmentId}/surveys/${survey.id}`; + const url = `/environments/${environment.id}/surveys/${survey.id}`; const navigation = [ { @@ -31,7 +28,7 @@ export const SurveyAnalysisNavigation = ({ href: `${url}/summary?referer=true`, current: pathname?.includes("/summary"), onClick: () => { - revalidateSurveyIdPath(environmentId, survey.id); + revalidateSurveyIdPath(environment.id, survey.id); }, }, { @@ -41,7 +38,7 @@ export const SurveyAnalysisNavigation = ({ href: `${url}/responses?referer=true`, current: pathname?.includes("/responses"), onClick: () => { - revalidateSurveyIdPath(environmentId, survey.id); + revalidateSurveyIdPath(environment.id, survey.id); }, }, ]; diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx index d2d3a0e51b0c..3f281f219454 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/page.tsx @@ -64,8 +64,6 @@ const Page = async (props: { params: Promise<{ environmentId: string; surveyId: pageTitle={survey.name} cta={ }> - + { +export const SuccessMessage = () => { + const { environment } = useEnvironment(); + const { survey } = useSurvey(); const { t } = useTranslation(); const searchParams = useSearchParams(); const [confetti, setConfetti] = useState(false); diff --git a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx index 0554188b6903..b6b2d97c20fe 100644 --- a/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx +++ b/apps/web/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SurveyAnalysisCTA.tsx @@ -5,14 +5,13 @@ import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState } from "react"; import toast from "react-hot-toast"; import { useTranslation } from "react-i18next"; -import { TEnvironment } from "@formbricks/types/environment"; import { TSegment } from "@formbricks/types/segment"; -import { TSurvey } from "@formbricks/types/surveys/types"; import { TUser } from "@formbricks/types/user"; import { useEnvironment } from "@/app/(app)/environments/[environmentId]/context/environment-context"; import { SuccessMessage } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SuccessMessage"; import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/share-survey-modal"; import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown"; +import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context"; import { getFormattedErrorMessage } from "@/lib/utils/helper"; import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog"; import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId"; @@ -23,8 +22,6 @@ import { IconBar } from "@/modules/ui/components/iconbar"; import { resetSurveyAction } from "../actions"; interface SurveyAnalysisCTAProps { - survey: TSurvey; - environment: TEnvironment; isReadOnly: boolean; user: TUser; publicDomain: string; @@ -41,8 +38,6 @@ interface ModalState { } export const SurveyAnalysisCTA = ({ - survey, - environment, isReadOnly, user, publicDomain, @@ -64,7 +59,8 @@ export const SurveyAnalysisCTA = ({ const [isResetModalOpen, setIsResetModalOpen] = useState(false); const [isResetting, setIsResetting] = useState(false); - const { project } = useEnvironment(); + const { environment, project } = useEnvironment(); + const { survey } = useSurvey(); const { refreshSingleUseId } = useSingleUseId(survey, isReadOnly); const appSetupCompleted = survey.type === "app" && environment.appSetupCompleted; @@ -183,7 +179,7 @@ export const SurveyAnalysisCTA = ({ return (
{!isReadOnly && (appSetupCompleted || survey.type === "link") && survey.status !== "draft" && ( - + )} @@ -215,7 +211,7 @@ export const SurveyAnalysisCTA = ({ projectCustomScripts={project.customHeadScripts} /> )} - + {responseCount > 0 && ( }> - + void; - survey: TSurvey; -} - -export const SurveyStatusDropdown = ({ - environment, - updateLocalSurveyStatus, - survey, -}: SurveyStatusDropdownProps) => { +export const SurveyStatusDropdown = () => { + const { environment } = useEnvironment(); + const { survey } = useSurvey(); const { t } = useTranslation(); const router = useRouter(); @@ -46,10 +39,6 @@ export const SurveyStatusDropdown = ({ toast.success(toastMessage); } - if (updateLocalSurveyStatus) { - updateLocalSurveyStatus(resultingStatus); - } - router.refresh(); } else { const errorMessage = getFormattedErrorMessage(updateSurveyActionResponse); diff --git a/apps/web/app/api/auth/[...nextauth]/route.test.ts b/apps/web/app/api/auth/[...nextauth]/route.test.ts index 7d31891f0ba9..58b2079f4e1c 100644 --- a/apps/web/app/api/auth/[...nextauth]/route.test.ts +++ b/apps/web/app/api/auth/[...nextauth]/route.test.ts @@ -185,4 +185,20 @@ describe("auth route audit logging", () => { }) ); }); + + test("does not log a completed sign-in for the intermediate SSO recovery verification step", async () => { + const authOptions = await getWrappedAuthOptions("req-sso-recovery"); + const user = { + id: "user_4", + email: "user4@example.com", + authFlowPurpose: "sso_recovery", + }; + const account = { provider: "token" }; + + await expect(authOptions.callbacks.signIn({ user, account })).resolves.toBe(true); + await authOptions.events.signIn({ user, account, isNewUser: false }); + + expect(mocks.baseEventSignIn).not.toHaveBeenCalled(); + expect(mocks.queueAuditEventBackground).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts index ed6586f6f6ba..190acb20f021 100644 --- a/apps/web/app/api/auth/[...nextauth]/route.ts +++ b/apps/web/app/api/auth/[...nextauth]/route.ts @@ -26,6 +26,12 @@ const getAuthMethod = (account: Account | null) => { return "unknown"; }; +const isSsoRecoveryVerificationFlow = (account: Account | null, user: User | AdapterUser) => + account?.provider === "token" && + "authFlowPurpose" in user && + typeof user.authFlowPurpose === "string" && + user.authFlowPurpose === "sso_recovery"; + const handler = async (req: Request, ctx: any) => { const eventId = req.headers.get("x-request-id") ?? undefined; @@ -117,6 +123,10 @@ const handler = async (req: Request, ctx: any) => { events: { ...baseAuthOptions.events, async signIn({ user, account, isNewUser }: any) { + if (isSsoRecoveryVerificationFlow(account, user)) { + return; + } + try { await baseAuthOptions.events?.signIn?.({ user, account, isNewUser }); } catch (err) { diff --git a/apps/web/app/api/auth/sso/recovery/complete/route.ts b/apps/web/app/api/auth/sso/recovery/complete/route.ts new file mode 100644 index 000000000000..d7d6d1618a35 --- /dev/null +++ b/apps/web/app/api/auth/sso/recovery/complete/route.ts @@ -0,0 +1,67 @@ +import { getServerSession } from "next-auth"; +import { NextResponse } from "next/server"; +import { logger } from "@formbricks/logger"; +import { verifySsoRelinkIntent } from "@/lib/jwt"; +import { deleteSessionBySessionToken } from "@/modules/auth/lib/auth-session-repository"; +import { authOptions } from "@/modules/auth/lib/authOptions"; +import { + NEXT_AUTH_SESSION_COOKIE_NAMES, + getSessionTokenFromCookieHeader, +} from "@/modules/auth/lib/session-cookie"; +import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl } from "@/modules/ee/sso/lib/sso-recovery"; + +const clearSessionCookies = (response: NextResponse) => { + for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) { + response.cookies.set({ + name: cookieName, + value: "", + expires: new Date(0), + path: "/", + secure: cookieName.startsWith("__Secure-"), + }); + } +}; + +const buildFailedRecoveryResponse = async (request: Request, callbackUrl?: string) => { + const response = NextResponse.redirect(getSsoRecoveryFailureRedirectUrl(callbackUrl)); + clearSessionCookies(response); + + const sessionToken = getSessionTokenFromCookieHeader(request.headers.get("cookie")); + if (!sessionToken) { + return response; + } + + try { + await deleteSessionBySessionToken(sessionToken); + } catch (error) { + logger.error(error, "Failed to delete SSO recovery session after recovery completion error"); + } + + return response; +}; + +export const GET = async (request: Request) => { + const url = new URL(request.url); + const intentToken = url.searchParams.get("intent"); + + if (!intentToken) { + return NextResponse.redirect(getSsoRecoveryFailureRedirectUrl()); + } + + try { + const session = await getServerSession(authOptions); + const callbackUrl = await completeSsoRecovery({ + intentToken, + sessionUserId: session?.user.id, + }); + + return NextResponse.redirect(callbackUrl); + } catch { + try { + const intent = verifySsoRelinkIntent(intentToken); + return await buildFailedRecoveryResponse(request, intent.callbackUrl); + } catch { + return await buildFailedRecoveryResponse(request); + } + } +}; diff --git a/apps/web/lib/jwt.test.ts b/apps/web/lib/jwt.test.ts index 28cca14686a4..5f4145c0604c 100644 --- a/apps/web/lib/jwt.test.ts +++ b/apps/web/lib/jwt.test.ts @@ -6,11 +6,13 @@ import { createEmailChangeToken, createEmailToken, createInviteToken, + createSsoRelinkIntent, createToken, createTokenForLinkSurvey, getEmailFromEmailToken, verifyEmailChangeToken, verifyInviteToken, + verifySsoRelinkIntent, verifyToken, verifyTokenForLinkSurvey, } from "./jwt"; @@ -380,6 +382,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => { expect(verified).toEqual({ id: mockUser.id, // Returns the decrypted user ID email: mockUser.email, + purpose: "email_verification", }); }); @@ -414,6 +417,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => { expect(verified).toEqual({ id: mockUser.id, // Returns the raw ID from payload email: mockUser.email, + purpose: "email_verification", }); }); @@ -425,6 +429,7 @@ describe("JWT Functions - Comprehensive Security Tests", () => { expect(verified).toEqual({ id: mockUser.id, // Returns the decrypted user ID email: mockUser.email, + purpose: "email_verification", }); }); @@ -1004,5 +1009,78 @@ describe("JWT Functions - Comprehensive Security Tests", () => { expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID }); }); + + describe("SSO recovery support", () => { + test("creates verification tokens that preserve the recovery purpose", async () => { + const token = createToken(mockUser.id, { purpose: "sso_recovery", expiresIn: "15m" }); + + await expect(verifyToken(token)).resolves.toEqual( + expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + purpose: "sso_recovery", + }) + ); + }); + + test("defaults legacy verification tokens to email_verification when purpose is missing", async () => { + const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET); + + await expect(verifyToken(legacyToken)).resolves.toEqual( + expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + purpose: "email_verification", + }) + ); + }); + + test("round-trips SSO relink intents without losing callback state", () => { + const intent = createSsoRelinkIntent({ + userId: mockUser.id, + email: mockUser.email, + provider: "google", + providerAccountId: "provider-123", + callbackUrl: "http://localhost:3000/invite?token=invite-token", + }); + + expect(verifySsoRelinkIntent(intent)).toEqual({ + userId: mockUser.id, + email: mockUser.email, + provider: "google", + providerAccountId: "provider-123", + callbackUrl: "http://localhost:3000/invite?token=invite-token", + }); + }); + + test("rejects expired SSO relink intents", () => { + const expiredIntent = jwt.sign( + { + userId: crypto.symmetricEncrypt(mockUser.id, TEST_ENCRYPTION_KEY), + email: crypto.symmetricEncrypt(mockUser.email, TEST_ENCRYPTION_KEY), + provider: "google", + providerAccountId: crypto.symmetricEncrypt("provider-123", TEST_ENCRYPTION_KEY), + callbackUrl: crypto.symmetricEncrypt("http://localhost:3000", TEST_ENCRYPTION_KEY), + exp: Math.floor(Date.now() / 1000) - 3600, + }, + TEST_NEXTAUTH_SECRET + ); + + expect(() => verifySsoRelinkIntent(expiredIntent)).toThrow(); + }); + + test("rejects tampered SSO relink intents", () => { + const intent = createSsoRelinkIntent({ + userId: mockUser.id, + email: mockUser.email, + provider: "google", + providerAccountId: "provider-123", + callbackUrl: "http://localhost:3000", + }); + + const tamperedIntent = `${intent.slice(0, -1)}x`; + expect(() => verifySsoRelinkIntent(tamperedIntent)).toThrow(); + }); + }); }); }); diff --git a/apps/web/lib/jwt.ts b/apps/web/lib/jwt.ts index 66e305e2b8c8..866d586c5e87 100644 --- a/apps/web/lib/jwt.ts +++ b/apps/web/lib/jwt.ts @@ -1,4 +1,4 @@ -import jwt, { JwtPayload } from "jsonwebtoken"; +import jwt, { JwtPayload, SignOptions } from "jsonwebtoken"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants"; @@ -13,7 +13,39 @@ const decryptWithFallback = (encryptedText: string, key: string): string => { } }; -export const createToken = (userId: string, options = {}): string => { +export const VERIFICATION_TOKEN_PURPOSES = ["email_verification", "sso_recovery"] as const; + +export type TVerificationTokenPurpose = (typeof VERIFICATION_TOKEN_PURPOSES)[number]; + +export type TVerifyTokenPayload = JwtPayload & { + id: string; + email: string; + purpose: TVerificationTokenPurpose; +}; + +type TVerificationTokenOptions = SignOptions & { + purpose?: TVerificationTokenPurpose; +}; + +type TSsoRelinkIntentPayload = { + callbackUrl: string; + email: string; + provider: string; + providerAccountId: string; + userId: string; +}; + +const DEFAULT_VERIFICATION_TOKEN_PURPOSE: TVerificationTokenPurpose = "email_verification"; + +const getVerificationTokenPurpose = (purpose: unknown): TVerificationTokenPurpose => { + if (purpose && VERIFICATION_TOKEN_PURPOSES.includes(purpose as TVerificationTokenPurpose)) { + return purpose as TVerificationTokenPurpose; + } + + return DEFAULT_VERIFICATION_TOKEN_PURPOSE; +}; + +export const createToken = (userId: string, options: TVerificationTokenOptions = {}): string => { if (!NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -23,7 +55,9 @@ export const createToken = (userId: string, options = {}): string => { } const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY); - return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options); + const { purpose = DEFAULT_VERIFICATION_TOKEN_PURPOSE, ...jwtOptions } = options; + + return jwt.sign({ id: encryptedUserId, purpose }, NEXTAUTH_SECRET, jwtOptions); }; export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => { if (!NEXTAUTH_SECRET) { @@ -224,7 +258,72 @@ const getUserEmailForLegacyVerification = async ( return { userId: decryptedId, userEmail: foundUser.email }; }; -export const verifyToken = async (token: string): Promise => { +const DEFAULT_SSO_RELINK_INTENT_OPTIONS: SignOptions = { + expiresIn: "15m", +}; + +export const createSsoRelinkIntent = ( + payload: TSsoRelinkIntentPayload, + options: SignOptions = DEFAULT_SSO_RELINK_INTENT_OPTIONS +): string => { + if (!NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is not set"); + } + + if (!ENCRYPTION_KEY) { + throw new Error("ENCRYPTION_KEY is not set"); + } + + return jwt.sign( + { + userId: symmetricEncrypt(payload.userId, ENCRYPTION_KEY), + email: symmetricEncrypt(payload.email, ENCRYPTION_KEY), + provider: payload.provider, + providerAccountId: symmetricEncrypt(payload.providerAccountId, ENCRYPTION_KEY), + callbackUrl: symmetricEncrypt(payload.callbackUrl, ENCRYPTION_KEY), + }, + NEXTAUTH_SECRET, + options + ); +}; + +export const verifySsoRelinkIntent = (token: string): TSsoRelinkIntentPayload => { + if (!NEXTAUTH_SECRET) { + throw new Error("NEXTAUTH_SECRET is not set"); + } + + if (!ENCRYPTION_KEY) { + throw new Error("ENCRYPTION_KEY is not set"); + } + + const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & { + userId: string; + email: string; + provider: string; + providerAccountId: string; + callbackUrl: string; + }; + + if ( + !payload?.userId || + !payload?.email || + !payload?.provider || + !payload?.providerAccountId || + !payload?.callbackUrl + ) { + throw new Error("Token is invalid or missing required fields"); + } + + return { + userId: decryptWithFallback(payload.userId, ENCRYPTION_KEY), + email: decryptWithFallback(payload.email, ENCRYPTION_KEY), + provider: payload.provider, + providerAccountId: decryptWithFallback(payload.providerAccountId, ENCRYPTION_KEY), + callbackUrl: decryptWithFallback(payload.callbackUrl, ENCRYPTION_KEY), + }; +}; + +export const verifyToken = async (token: string): Promise => { if (!NEXTAUTH_SECRET) { throw new Error("NEXTAUTH_SECRET is not set"); } @@ -263,7 +362,11 @@ export const verifyToken = async (token: string): Promise => { // Get user email if we don't have it yet userData ??= await getUserEmailForLegacyVerification(token, payload.id); - return { id: userData.userId, email: userData.userEmail }; + return { + id: userData.userId, + email: userData.userEmail, + purpose: getVerificationTokenPurpose(payload.purpose), + }; }; export const verifyInviteToken = (token: string): { inviteId: string; email: string } => { diff --git a/apps/web/modules/auth/lib/auth-session-repository.ts b/apps/web/modules/auth/lib/auth-session-repository.ts index b103b53f0abb..2d2550de5a6d 100644 --- a/apps/web/modules/auth/lib/auth-session-repository.ts +++ b/apps/web/modules/auth/lib/auth-session-repository.ts @@ -1,5 +1,6 @@ import "server-only"; import { Prisma, PrismaClient } from "@prisma/client"; +import { z } from "zod"; import { prisma } from "@formbricks/database"; import { ZId } from "@formbricks/types/common"; import { DatabaseError } from "@formbricks/types/errors"; @@ -35,3 +36,22 @@ export const deleteSessionsByUserId = async ( return handleDatabaseError(error); } }; + +export const deleteSessionBySessionToken = async ( + sessionToken: string, + tx?: Prisma.TransactionClient +): Promise => { + validateInputs([sessionToken, z.string().min(1)]); + + try { + const result = await getDbClient(tx).session.deleteMany({ + where: { + sessionToken, + }, + }); + + return result.count; + } catch (error) { + return handleDatabaseError(error); + } +}; diff --git a/apps/web/modules/auth/lib/authOptions.test.ts b/apps/web/modules/auth/lib/authOptions.test.ts index a1067316b23a..4d41895f94db 100644 --- a/apps/web/modules/auth/lib/authOptions.test.ts +++ b/apps/web/modules/auth/lib/authOptions.test.ts @@ -3,8 +3,11 @@ import { Provider } from "next-auth/providers/index"; import { afterEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants"; +import { verifyToken } from "@/lib/jwt"; import { capturePostHogEvent } from "@/lib/posthog"; +import { createBrevoCustomer } from "@/modules/auth/lib/brevo"; // Import mocked rate limiting functions +import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user"; import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs"; import { authOptions } from "./authOptions"; @@ -133,6 +136,10 @@ vi.mock("@/lib/posthog", () => ({ capturePostHogEvent: vi.fn(), })); +vi.mock("@/modules/auth/lib/brevo", () => ({ + createBrevoCustomer: vi.fn(), +})); + // Helper to get the provider by id from authOptions.providers. function getProviderById(id: string): Provider { const provider = authOptions.providers.find((p) => p.options.id === id); @@ -315,6 +322,105 @@ describe("authOptions", () => { ); }); + test("allows verified users through the token provider when the token purpose is sso_recovery", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); + vi.mocked(verifyToken).mockResolvedValue({ + id: mockUser.id, + email: mockUser.email, + purpose: "sso_recovery", + } as any); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + ...mockUser, + emailVerified: new Date(), + } as any); + + const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {}); + + expect(result).toEqual( + expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + authFlowPurpose: "sso_recovery", + }) + ); + }); + + test("defers verification side effects for unverified users when the token purpose is sso_recovery", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); + vi.mocked(verifyToken).mockResolvedValue({ + id: mockUser.id, + email: mockUser.email, + purpose: "sso_recovery", + } as any); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + ...mockUser, + emailVerified: null, + } as any); + + const result = await tokenProvider.options.authorize({ token: "recovery-token" }, {}); + + expect(result).toEqual( + expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + emailVerified: null, + authFlowPurpose: "sso_recovery", + }) + ); + expect(updateUser).not.toHaveBeenCalled(); + }); + + test("verifies unverified users during the standard email verification flow", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); + vi.mocked(verifyToken).mockResolvedValue({ + id: mockUser.id, + email: mockUser.email, + purpose: "email_verification", + } as any); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + ...mockUser, + emailVerified: null, + } as any); + vi.mocked(updateUser).mockResolvedValue({ + ...mockUser, + emailVerified: new Date("2026-04-16T00:00:00.000Z"), + } as any); + + const result = await tokenProvider.options.authorize({ token: "verify-token" }, {}); + + expect(updateUser).toHaveBeenCalledWith(mockUser.id, { emailVerified: expect.any(Date) }); + expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email }); + expect(result).toEqual( + expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + authFlowPurpose: "email_verification", + emailVerified: new Date("2026-04-16T00:00:00.000Z"), + }) + ); + }); + + test("rejects inactive users even when the verification token is otherwise valid", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); + vi.mocked(verifyToken).mockResolvedValue({ + id: mockUser.id, + email: mockUser.email, + purpose: "email_verification", + } as any); + vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ + ...mockUser, + emailVerified: null, + isActive: false, + } as any); + + await expect(tokenProvider.options.authorize({ token: "inactive-token" }, {})).rejects.toThrow( + "Your account is currently inactive. Please contact the organization admin." + ); + + expect(updateUser).not.toHaveBeenCalled(); + expect(createBrevoCustomer).not.toHaveBeenCalled(); + }); + describe("Rate Limiting", () => { test("should apply rate limiting before token verification", async () => { vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); @@ -432,6 +538,51 @@ describe("authOptions", () => { expect(capturePostHogEvent).not.toHaveBeenCalled(); } }); + + test("should not record a completed sign-in while the recovery token is only proving inbox ownership", async () => { + const user = { + ...mockUser, + emailVerified: new Date(), + authFlowPurpose: "sso_recovery", + }; + const account = { provider: "token" } as any; + + if (authOptions.callbacks?.signIn) { + const result = await authOptions.callbacks.signIn({ user, account } as any); + + expect(result).toBe(true); + expect(updateUserLastLoginAt).not.toHaveBeenCalled(); + expect(capturePostHogEvent).not.toHaveBeenCalled(); + } + }); + + test("should allow an unverified recovery session through until SSO completion finishes the reclaim", async () => { + const user = { + ...mockUser, + emailVerified: null, + authFlowPurpose: "sso_recovery", + }; + const account = { provider: "token" } as any; + + if (authOptions.callbacks?.signIn) { + const result = await authOptions.callbacks.signIn({ user, account } as any); + + expect(result).toBe(true); + expect(updateUserLastLoginAt).not.toHaveBeenCalled(); + expect(capturePostHogEvent).not.toHaveBeenCalled(); + } + }); + + test("should finalize successful sign-in when no provider information is available", async () => { + const user = { ...mockUser, emailVerified: new Date() }; + + if (authOptions.callbacks?.signIn) { + const result = await authOptions.callbacks.signIn({ user, account: undefined } as any); + + expect(result).toBe(true); + expect(updateUserLastLoginAt).toHaveBeenCalledWith(user.email); + } + }); }); }); @@ -489,6 +640,57 @@ describe("authOptions", () => { expect(mockUpdateUserLastLoginAt).not.toHaveBeenCalled(); expect(mockCapturePostHogEvent).not.toHaveBeenCalled(); }); + + test("should finalize successful sign-in after a successful enterprise SSO callback", async () => { + vi.resetModules(); + + const mockHandleSsoCallback = vi.fn().mockResolvedValueOnce(true); + const mockUpdateUserLastLoginAt = vi.fn(); + const mockCapturePostHogEvent = vi.fn(); + + vi.doMock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + EMAIL_VERIFICATION_DISABLED: false, + SESSION_MAX_AGE: 86400, + NEXTAUTH_SECRET: "test-secret", + WEBAPP_URL: "http://localhost:3000", + ENCRYPTION_KEY: "12345678901234567890123456789012", + REDIS_URL: undefined, + AUDIT_LOG_ENABLED: false, + AUDIT_LOG_GET_USER_IP: false, + ENTERPRISE_LICENSE_KEY: "test-enterprise-license", + SENTRY_DSN: undefined, + BREVO_API_KEY: undefined, + RATE_LIMITING_DISABLED: false, + CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", + POSTHOG_KEY: "phc_test_key", + }; + }); + vi.doMock("@/modules/ee/sso/lib/providers", () => ({ + getSSOProviders: vi.fn(() => []), + })); + vi.doMock("@/modules/ee/sso/lib/sso-handlers", () => ({ + handleSsoCallback: mockHandleSsoCallback, + })); + vi.doMock("@/modules/auth/lib/user", () => ({ + updateUser: vi.fn(), + updateUserLastLoginAt: mockUpdateUserLastLoginAt, + })); + vi.doMock("@/lib/posthog", () => ({ + capturePostHogEvent: mockCapturePostHogEvent, + })); + + const { authOptions: enterpriseAuthOptions } = await import("./authOptions"); + const user = { ...mockUser, emailVerified: new Date() }; + const account = { provider: "google", type: "oauth", providerAccountId: "provider-123" } as any; + + await expect(enterpriseAuthOptions.callbacks?.signIn?.({ user, account } as any)).resolves.toBe(true); + + expect(mockHandleSsoCallback).toHaveBeenCalled(); + expect(mockUpdateUserLastLoginAt).toHaveBeenCalledWith(user.email); + }); }); describe("Two-Factor Authentication (TOTP)", () => { diff --git a/apps/web/modules/auth/lib/authOptions.ts b/apps/web/modules/auth/lib/authOptions.ts index 6d92e4e19db7..ae1befe96719 100644 --- a/apps/web/modules/auth/lib/authOptions.ts +++ b/apps/web/modules/auth/lib/authOptions.ts @@ -10,16 +10,15 @@ import { EMAIL_VERIFICATION_DISABLED, ENCRYPTION_KEY, ENTERPRISE_LICENSE_KEY, - POSTHOG_KEY, SESSION_MAX_AGE, WEBAPP_URL, } from "@/lib/constants"; import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import { verifyToken } from "@/lib/jwt"; -import { capturePostHogEvent } from "@/lib/posthog"; import { getValidatedCallbackUrl } from "@/lib/utils/url"; import { getAuthCallbackUrlFromCookies } from "@/modules/auth/lib/callback-url"; -import { updateUser, updateUserLastLoginAt } from "@/modules/auth/lib/user"; +import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking"; +import { updateUser } from "@/modules/auth/lib/user"; import { logAuthAttempt, logAuthEvent, @@ -267,12 +266,13 @@ export const authOptions: NextAuthOptions = { throw new Error("Token not found"); } - const { id } = await verifyToken(credentials?.token); - user = await prisma.user.findUnique({ + const { id, purpose } = await verifyToken(credentials?.token); + const foundUser = await prisma.user.findUnique({ where: { id: id, }, }); + user = foundUser ? { ...foundUser, authFlowPurpose: purpose } : null; } catch (e) { logger.error(e, "Error in CredentialsProvider authorize"); @@ -291,7 +291,10 @@ export const authOptions: NextAuthOptions = { throw new Error("Either a user does not match the provided token or the token is invalid"); } - if (user.emailVerified) { + const authFlowPurpose = user.authFlowPurpose ?? "email_verification"; + const isSsoRecovery = authFlowPurpose === "sso_recovery"; + + if (user.emailVerified && !isSsoRecovery) { logEmailVerificationAttempt(false, "email_already_verified", user.id, user.email); throw new Error("Email already verified"); } @@ -301,14 +304,20 @@ export const authOptions: NextAuthOptions = { throw new Error("Your account is currently inactive. Please contact the organization admin."); } - user = await updateUser(user.id, { emailVerified: new Date() }); + if (!user.emailVerified && !isSsoRecovery) { + const updatedUser = await updateUser(user.id, { emailVerified: new Date() }); + user = { + ...updatedUser, + authFlowPurpose, + }; - logEmailVerificationAttempt(true, undefined, user.id, user.email, { - emailVerifiedAt: user.emailVerified, - }); + logEmailVerificationAttempt(true, undefined, user.id, user.email, { + emailVerifiedAt: user.emailVerified, + }); - // send new user to brevo after email verification - createBrevoCustomer({ id: user.id, email: user.email }); + // send new user to brevo after email verification + createBrevoCustomer({ id: user.id, email: user.email }); + } return user; }, @@ -339,37 +348,27 @@ export const authOptions: NextAuthOptions = { const userEmail = user.email ?? ""; const userId = user.id as string; + const authFlowPurpose = + "authFlowPurpose" in user && typeof user.authFlowPurpose === "string" + ? user.authFlowPurpose + : undefined; - // Capture sign-in event for PostHog (query BEFORE updating lastLoginAt) - const captureSignIn = async (provider: string) => { - if (!POSTHOG_KEY) return; - - try { - const [membershipCount, userData] = await Promise.all([ - prisma.membership.count({ where: { userId } }), - prisma.user.findUnique({ where: { id: userId }, select: { lastLoginAt: true } }), - ]); - const isFirstLoginToday = - userData?.lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10); - - capturePostHogEvent(userId, "user_signed_in", { - auth_provider: provider, - organization_count: membershipCount, - is_first_login_today: isFirstLoginToday, - }); - } catch (error) { - logger.warn({ error }, "Failed to capture PostHog sign-in event"); + if (account?.provider === "credentials" || account?.provider === "token") { + if (account.provider === "token" && authFlowPurpose === "sso_recovery") { + return true; } - }; - if (account?.provider === "credentials" || account?.provider === "token") { // check if user's email is verified or not if ("emailVerified" in user && !user.emailVerified && !EMAIL_VERIFICATION_DISABLED) { logger.error("Email Verification is Pending"); throw new Error("Email Verification is Pending"); } - void captureSignIn(account.provider); - await updateUserLastLoginAt(userEmail); + + await finalizeSuccessfulSignIn({ + userId, + email: userEmail, + provider: account.provider, + }); return true; } if (ENTERPRISE_LICENSE_KEY && account) { @@ -379,14 +378,20 @@ export const authOptions: NextAuthOptions = { callbackUrl, }); - if (result) { - void captureSignIn(account.provider); - await updateUserLastLoginAt(userEmail); + if (result === true) { + await finalizeSuccessfulSignIn({ + userId, + email: userEmail, + provider: account.provider, + }); } return result; } - void captureSignIn(account?.provider ?? "unknown"); - await updateUserLastLoginAt(userEmail); + await finalizeSuccessfulSignIn({ + userId, + email: userEmail, + provider: account?.provider ?? "unknown", + }); return true; }, }, diff --git a/apps/web/modules/auth/lib/proxy-session.ts b/apps/web/modules/auth/lib/proxy-session.ts index 2f2569dcaea9..76daaced93b7 100644 --- a/apps/web/modules/auth/lib/proxy-session.ts +++ b/apps/web/modules/auth/lib/proxy-session.ts @@ -1,9 +1,5 @@ import { prisma } from "@formbricks/database"; - -const NEXT_AUTH_SESSION_COOKIE_NAMES = [ - "__Secure-next-auth.session-token", - "next-auth.session-token", -] as const; +import { getSessionTokenFromCookieStore } from "./session-cookie"; type TCookieStore = { get: (name: string) => { value: string } | undefined; @@ -14,14 +10,7 @@ type TRequestWithCookies = { }; export const getSessionTokenFromRequest = (request: TRequestWithCookies): string | null => { - for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) { - const cookie = request.cookies.get(cookieName); - if (cookie?.value) { - return cookie.value; - } - } - - return null; + return getSessionTokenFromCookieStore(request.cookies); }; export const getProxySession = async (request: TRequestWithCookies) => { diff --git a/apps/web/modules/auth/lib/session-cookie.ts b/apps/web/modules/auth/lib/session-cookie.ts new file mode 100644 index 000000000000..6827dedcd862 --- /dev/null +++ b/apps/web/modules/auth/lib/session-cookie.ts @@ -0,0 +1,49 @@ +export const NEXT_AUTH_SESSION_COOKIE_NAMES = [ + "__Secure-next-auth.session-token", + "next-auth.session-token", +] as const; + +type TCookieStore = { + get: (name: string) => { value: string } | undefined; +}; + +const getCookieValueFromHeader = (cookieHeader: string, cookieName: string): string | null => { + const cookies = cookieHeader.split(";").map((cookie) => cookie.trim()); + + for (const cookie of cookies) { + if (!cookie.startsWith(`${cookieName}=`)) { + continue; + } + + const cookieValue = cookie.slice(cookieName.length + 1); + return cookieValue.length > 0 ? decodeURIComponent(cookieValue) : null; + } + + return null; +}; + +export const getSessionTokenFromCookieStore = (cookieStore: TCookieStore): string | null => { + for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) { + const cookie = cookieStore.get(cookieName); + if (cookie?.value) { + return cookie.value; + } + } + + return null; +}; + +export const getSessionTokenFromCookieHeader = (cookieHeader: string | null): string | null => { + if (!cookieHeader) { + return null; + } + + for (const cookieName of NEXT_AUTH_SESSION_COOKIE_NAMES) { + const cookieValue = getCookieValueFromHeader(cookieHeader, cookieName); + if (cookieValue) { + return cookieValue; + } + } + + return null; +}; diff --git a/apps/web/modules/auth/lib/sign-in-tracking.test.ts b/apps/web/modules/auth/lib/sign-in-tracking.test.ts new file mode 100644 index 000000000000..dc501301b803 --- /dev/null +++ b/apps/web/modules/auth/lib/sign-in-tracking.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test, vi } from "vitest"; + +const prismaMembershipCount = vi.fn(); +const prismaUserFindUnique = vi.fn(); +const capturePostHogEvent = vi.fn(); +const updateUserLastLoginAt = vi.fn(); + +vi.mock("@formbricks/database", () => ({ + prisma: { + membership: { + count: prismaMembershipCount, + }, + user: { + findUnique: prismaUserFindUnique, + }, + }, +})); + +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + POSTHOG_KEY: undefined, + }; +}); + +vi.mock("@/lib/posthog", () => ({ + capturePostHogEvent, +})); + +vi.mock("@/modules/auth/lib/user", () => ({ + updateUserLastLoginAt, +})); + +describe("captureSignIn", () => { + test("returns early when PostHog is disabled", async () => { + const { captureSignIn } = await import("./sign-in-tracking"); + + await captureSignIn({ + userId: "user_1", + provider: "google", + }); + + expect(prismaMembershipCount).not.toHaveBeenCalled(); + expect(prismaUserFindUnique).not.toHaveBeenCalled(); + expect(capturePostHogEvent).not.toHaveBeenCalled(); + }); +}); + +describe("finalizeSuccessfulSignIn", () => { + test("uses the previous lastLoginAt returned by the update path to avoid a second user lookup", async () => { + vi.resetModules(); + + const membershipCount = vi.fn().mockResolvedValue(3); + const userFindUnique = vi.fn(); + const postHogCapture = vi.fn(); + const updateLastLoginAt = vi.fn().mockResolvedValue(new Date()); + + vi.doMock("@formbricks/database", () => ({ + prisma: { + membership: { + count: membershipCount, + }, + user: { + findUnique: userFindUnique, + }, + }, + })); + vi.doMock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + POSTHOG_KEY: "phc_test_key", + }; + }); + vi.doMock("@/lib/posthog", () => ({ + capturePostHogEvent: postHogCapture, + })); + vi.doMock("@/modules/auth/lib/user", () => ({ + updateUserLastLoginAt: updateLastLoginAt, + })); + + const { finalizeSuccessfulSignIn } = await import("./sign-in-tracking"); + await finalizeSuccessfulSignIn({ + userId: "user_1", + email: "john.doe@example.com", + provider: "google", + }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(updateLastLoginAt).toHaveBeenCalledWith("john.doe@example.com"); + expect(membershipCount).toHaveBeenCalledWith({ where: { userId: "user_1" } }); + expect(userFindUnique).not.toHaveBeenCalled(); + expect(postHogCapture).toHaveBeenCalledWith("user_1", "user_signed_in", { + auth_provider: "google", + organization_count: 3, + is_first_login_today: false, + }); + }); +}); diff --git a/apps/web/modules/auth/lib/sign-in-tracking.ts b/apps/web/modules/auth/lib/sign-in-tracking.ts new file mode 100644 index 000000000000..6ac748d3219d --- /dev/null +++ b/apps/web/modules/auth/lib/sign-in-tracking.ts @@ -0,0 +1,57 @@ +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { POSTHOG_KEY } from "@/lib/constants"; +import { capturePostHogEvent } from "@/lib/posthog"; +import { updateUserLastLoginAt } from "@/modules/auth/lib/user"; + +const getIsFirstLoginToday = (lastLoginAt: Date | null | undefined) => + lastLoginAt?.toISOString().slice(0, 10) !== new Date().toISOString().slice(0, 10); + +export const captureSignIn = async ({ + userId, + provider, + previousLastLoginAt, +}: { + userId: string; + provider: string; + previousLastLoginAt?: Date | null; +}) => { + if (!POSTHOG_KEY) { + return; + } + + try { + const membershipCountPromise = prisma.membership.count({ where: { userId } }); + const resolvedPreviousLastLoginAt = + previousLastLoginAt === undefined + ? ( + await prisma.user.findUnique({ + where: { id: userId }, + select: { lastLoginAt: true }, + }) + )?.lastLoginAt + : previousLastLoginAt; + const membershipCount = await membershipCountPromise; + + capturePostHogEvent(userId, "user_signed_in", { + auth_provider: provider, + organization_count: membershipCount, + is_first_login_today: getIsFirstLoginToday(resolvedPreviousLastLoginAt), + }); + } catch (error) { + logger.warn({ error }, "Failed to capture PostHog sign-in event"); + } +}; + +export const finalizeSuccessfulSignIn = async ({ + userId, + email, + provider, +}: { + userId: string; + email: string; + provider: string; +}) => { + const previousLastLoginAt = await updateUserLastLoginAt(email); + void captureSignIn({ userId, provider, previousLastLoginAt }); +}; diff --git a/apps/web/modules/auth/lib/user.test.ts b/apps/web/modules/auth/lib/user.test.ts index ecc7d69753b5..ed21e67f6913 100644 --- a/apps/web/modules/auth/lib/user.test.ts +++ b/apps/web/modules/auth/lib/user.test.ts @@ -17,6 +17,7 @@ const mockPrismaUser = { vi.mock("@formbricks/database", () => ({ prisma: { + $transaction: vi.fn(), user: { create: vi.fn(), update: vi.fn(), @@ -29,6 +30,14 @@ vi.mock("@formbricks/database", () => ({ describe("User Management", () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(prisma.$transaction).mockImplementation(async (callback) => + callback({ + $queryRaw: vi.fn(), + user: { + update: vi.mocked(prisma.user.update), + }, + } as any) + ); }); describe("createUser", () => { @@ -84,22 +93,31 @@ describe("User Management", () => { }); describe("updateUserLastLoginAt", () => { - const mockUpdateData = { name: "Updated Name" }; - - test("updates a user successfully", async () => { - vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name }); + test("updates a user successfully and returns the previous login timestamp", async () => { + const previousLastLoginAt = new Date("2025-04-16T10:00:00.000Z"); + vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => + callback({ + $queryRaw: vi.fn().mockResolvedValue([{ id: mockUser.id, lastLoginAt: previousLastLoginAt }]), + user: { + update: vi.fn().mockResolvedValue({ ...mockPrismaUser, lastLoginAt: new Date() }), + }, + } as any) + ); const result = await updateUserLastLoginAt(mockUser.email); - expect(result).toEqual(void 0); + expect(result).toEqual(previousLastLoginAt); }); test("throws ResourceNotFoundError when user doesn't exist", async () => { - const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", { - code: PrismaErrorType.RecordDoesNotExist, - clientVersion: "0.0.1", - }); - vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow); + vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => + callback({ + $queryRaw: vi.fn().mockResolvedValue([]), + user: { + update: vi.fn(), + }, + } as any) + ); await expect(updateUserLastLoginAt(mockUser.email)).rejects.toThrow(ResourceNotFoundError); }); diff --git a/apps/web/modules/auth/lib/user.ts b/apps/web/modules/auth/lib/user.ts index 365b49ce99e6..f59a1c4d4dfd 100644 --- a/apps/web/modules/auth/lib/user.ts +++ b/apps/web/modules/auth/lib/user.ts @@ -44,15 +44,35 @@ export const updateUserLastLoginAt = async (email: string) => { validateInputs([email, ZUserEmail]); try { - await prisma.user.update({ - where: { - email, - }, - data: { - lastLoginAt: new Date(), - }, + return await prisma.$transaction(async (tx) => { + const lockedUsers = await tx.$queryRaw>` + SELECT "id", "lastLoginAt" + FROM "User" + WHERE "email" = ${email} + FOR UPDATE + `; + const lockedUser = lockedUsers[0]; + + if (!lockedUser) { + throw new ResourceNotFoundError("email", email); + } + + await tx.user.update({ + where: { + id: lockedUser.id, + }, + data: { + lastLoginAt: new Date(), + }, + }); + + return lockedUser.lastLoginAt; }); } catch (error) { + if (error instanceof ResourceNotFoundError) { + throw error; + } + if ( error instanceof Prisma.PrismaClientKnownRequestError && error.code === PrismaErrorType.RecordDoesNotExist diff --git a/apps/web/modules/auth/lib/verification-links.test.ts b/apps/web/modules/auth/lib/verification-links.test.ts index 0186e14eb8d0..0f36a188c23a 100644 --- a/apps/web/modules/auth/lib/verification-links.test.ts +++ b/apps/web/modules/auth/lib/verification-links.test.ts @@ -21,6 +21,18 @@ describe("verification link helpers", () => { ); }); + test("builds a verification requested path that preserves SSO recovery purpose", () => { + expect( + buildVerificationRequestedPath({ + token: "abc123", + callbackUrl: "http://localhost:3000/invite?token=invite-token", + purpose: "sso_recovery", + }) + ).toBe( + "/auth/verification-requested?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Finvite%3Ftoken%3Dinvite-token&purpose=sso_recovery" + ); + }); + test("builds absolute verification links that preserve a valid callback URL", () => { expect( buildVerificationLinks({ @@ -48,4 +60,21 @@ describe("verification link helpers", () => { verifyLink: "http://localhost:3000/auth/verify?token=abc123", }); }); + + test("preserves SSO recovery purpose on the verification requested email link", () => { + expect( + buildVerificationLinks({ + token: "abc123", + webAppUrl: WEBAPP_URL, + callbackUrl: "http://localhost:3000/environments/test?foo=bar", + purpose: "sso_recovery", + verificationRequestToken: "email-token", + }) + ).toEqual({ + verificationRequestLink: + "http://localhost:3000/auth/verification-requested?token=email-token&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar&purpose=sso_recovery", + verifyLink: + "http://localhost:3000/auth/verify?token=abc123&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Fenvironments%2Ftest%3Ffoo%3Dbar", + }); + }); }); diff --git a/apps/web/modules/auth/lib/verification-links.ts b/apps/web/modules/auth/lib/verification-links.ts index 76af5bec5f23..8a8ca228aebe 100644 --- a/apps/web/modules/auth/lib/verification-links.ts +++ b/apps/web/modules/auth/lib/verification-links.ts @@ -1,13 +1,18 @@ import { getValidatedCallbackUrl } from "@/lib/utils/url"; const RELATIVE_URL_BASE = "http://localhost"; +export const VERIFICATION_REQUEST_PURPOSES = ["email_verification", "sso_recovery"] as const; +export type TVerificationRequestPurpose = (typeof VERIFICATION_REQUEST_PURPOSES)[number]; +const DEFAULT_VERIFICATION_REQUEST_PURPOSE: TVerificationRequestPurpose = "email_verification"; export const buildVerificationRequestedPath = ({ token, callbackUrl, + purpose = DEFAULT_VERIFICATION_REQUEST_PURPOSE, }: { token: string; callbackUrl?: string | null; + purpose?: TVerificationRequestPurpose; }): string => { const verificationRequestedUrl = new URL("/auth/verification-requested", RELATIVE_URL_BASE); verificationRequestedUrl.searchParams.set("token", token); @@ -16,6 +21,10 @@ export const buildVerificationRequestedPath = ({ verificationRequestedUrl.searchParams.set("callbackUrl", callbackUrl); } + if (purpose !== DEFAULT_VERIFICATION_REQUEST_PURPOSE) { + verificationRequestedUrl.searchParams.set("purpose", purpose); + } + return `${verificationRequestedUrl.pathname}${verificationRequestedUrl.search}`; }; @@ -23,23 +32,31 @@ export const buildVerificationLinks = ({ token, webAppUrl, callbackUrl, + purpose = DEFAULT_VERIFICATION_REQUEST_PURPOSE, + verificationRequestToken = token, }: { token: string; webAppUrl: string; callbackUrl?: string | null; + purpose?: TVerificationRequestPurpose; + verificationRequestToken?: string; }): { verificationRequestLink: string; verifyLink: string } => { const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, webAppUrl); const verifyLink = new URL("/auth/verify", webAppUrl); verifyLink.searchParams.set("token", token); const verificationRequestLink = new URL("/auth/verification-requested", webAppUrl); - verificationRequestLink.searchParams.set("token", token); + verificationRequestLink.searchParams.set("token", verificationRequestToken); if (validatedCallbackUrl) { verifyLink.searchParams.set("callbackUrl", validatedCallbackUrl); verificationRequestLink.searchParams.set("callbackUrl", validatedCallbackUrl); } + if (purpose !== DEFAULT_VERIFICATION_REQUEST_PURPOSE) { + verificationRequestLink.searchParams.set("purpose", purpose); + } + return { verificationRequestLink: verificationRequestLink.toString(), verifyLink: verifyLink.toString(), diff --git a/apps/web/modules/auth/verification-requested/actions.test.ts b/apps/web/modules/auth/verification-requested/actions.test.ts index 7b79e71bdd08..0249fc35f3f7 100644 --- a/apps/web/modules/auth/verification-requested/actions.test.ts +++ b/apps/web/modules/auth/verification-requested/actions.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { ResourceNotFoundError } from "@formbricks/types/errors"; +import { verifySsoRelinkIntent } from "@/lib/jwt"; import { getUserByEmail } from "@/modules/auth/lib/user"; // Import mocked functions import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers"; @@ -29,10 +30,18 @@ vi.mock("@/modules/email", () => ({ sendVerificationEmail: vi.fn(), })); -vi.mock("@/lib/constants", () => ({ - WEBAPP_URL: "http://localhost:3000", +vi.mock("@/lib/jwt", () => ({ + verifySsoRelinkIntent: vi.fn(), })); +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + WEBAPP_URL: "http://localhost:3000", + }; +}); + vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ withAuditLogging: vi.fn((_type: string, _object: string, fn: Function) => fn), })); @@ -71,6 +80,9 @@ describe("resendVerificationEmailAction", () => { beforeEach(() => { vi.resetAllMocks(); + vi.mocked(verifySsoRelinkIntent).mockImplementation(() => { + throw new Error("invalid"); + }); }); afterEach(() => { @@ -150,6 +162,7 @@ describe("resendVerificationEmailAction", () => { expect(sendVerificationEmail).toHaveBeenCalledWith({ ...mockUser, callbackUrl: undefined, + purpose: "email_verification", }); expect(result).toEqual({ success: true }); }); @@ -169,6 +182,7 @@ describe("resendVerificationEmailAction", () => { expect(sendVerificationEmail).toHaveBeenCalledWith({ ...mockUser, callbackUrl: "http://localhost:3000/invite?token=invite-token", + purpose: "email_verification", }); }); @@ -187,6 +201,7 @@ describe("resendVerificationEmailAction", () => { expect(sendVerificationEmail).toHaveBeenCalledWith({ ...mockUser, callbackUrl: undefined, + purpose: "email_verification", }); }); @@ -205,6 +220,86 @@ describe("resendVerificationEmailAction", () => { expect(result).toEqual({ success: true }); }); + test("should resend an SSO recovery email for a verified user", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); + const verifiedUserWithLocale: NonNullable>> = { + ...mockVerifiedUser, + locale: "en-US", + }; + vi.mocked(getUserByEmail).mockResolvedValue(verifiedUserWithLocale); + vi.mocked(verifySsoRelinkIntent).mockReturnValue({ + callbackUrl: "http://localhost:3000", + email: mockVerifiedUser.email, + provider: "google", + providerAccountId: "provider_123", + userId: mockVerifiedUser.id, + }); + + const result = await resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: { + ...validInput, + callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent", + }, + } as any); + + expect(sendVerificationEmail).toHaveBeenCalledWith({ + id: mockVerifiedUser.id, + email: mockVerifiedUser.email, + locale: "en-US", + callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent", + purpose: "sso_recovery", + }); + expect(result).toEqual({ success: true }); + }); + + test("should not treat a client-supplied recovery callback as recovery without a valid intent", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); + const verifiedUserWithLocale: NonNullable>> = { + ...mockVerifiedUser, + locale: "en-US", + }; + vi.mocked(getUserByEmail).mockResolvedValue(verifiedUserWithLocale); + + const result = await resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: { + ...validInput, + callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=forged-intent", + }, + } as any); + + expect(sendVerificationEmail).not.toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + + test("should fall back to a normal verification email when the relink intent belongs to a different email", async () => { + vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); + vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any); + vi.mocked(verifySsoRelinkIntent).mockReturnValue({ + callbackUrl: "http://localhost:3000", + email: "other@example.com", + provider: "google", + providerAccountId: "provider_123", + userId: "user_123", + }); + + const result = await resendVerificationEmailAction({ + ctx: mockCtx, + parsedInput: { + ...validInput, + callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent", + }, + } as any); + + expect(sendVerificationEmail).toHaveBeenCalledWith({ + ...mockUser, + callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=test-intent", + purpose: "email_verification", + }); + expect(result).toEqual({ success: true }); + }); + test("should throw ResourceNotFoundError when user doesn't exist", async () => { vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true }); vi.mocked(getUserByEmail).mockResolvedValue(null); diff --git a/apps/web/modules/auth/verification-requested/actions.ts b/apps/web/modules/auth/verification-requested/actions.ts index a8c5c846a4a6..faa20b6547f3 100644 --- a/apps/web/modules/auth/verification-requested/actions.ts +++ b/apps/web/modules/auth/verification-requested/actions.ts @@ -4,12 +4,15 @@ import { z } from "zod"; import { ResourceNotFoundError } from "@formbricks/types/errors"; import { ZUserEmail } from "@formbricks/types/user"; import { WEBAPP_URL } from "@/lib/constants"; +import { verifySsoRelinkIntent } from "@/lib/jwt"; import { actionClient } from "@/lib/utils/action-client"; import { getValidatedCallbackUrl } from "@/lib/utils/url"; import { getUserByEmail } from "@/modules/auth/lib/user"; +import { TVerificationRequestPurpose } from "@/modules/auth/lib/verification-links"; import { applyIPRateLimit } 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"; +import { SSO_RECOVERY_COMPLETION_PATH } from "@/modules/ee/sso/lib/constants"; import { sendVerificationEmail } from "@/modules/email"; const ZResendVerificationEmailAction = z.object({ @@ -17,6 +20,36 @@ const ZResendVerificationEmailAction = z.object({ callbackUrl: z.string().max(2000).optional(), }); +const getVerificationRequestPurpose = ({ + callbackUrl, + userEmail, +}: { + callbackUrl?: string; + userEmail: string; +}): TVerificationRequestPurpose => { + const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL); + if (!validatedCallbackUrl) { + return "email_verification"; + } + + const parsedCallbackUrl = new URL(validatedCallbackUrl); + if (parsedCallbackUrl.pathname !== SSO_RECOVERY_COMPLETION_PATH) { + return "email_verification"; + } + + const intentToken = parsedCallbackUrl.searchParams.get("intent"); + if (!intentToken) { + return "email_verification"; + } + + try { + const intent = verifySsoRelinkIntent(intentToken); + return intent.email.toLowerCase() === userEmail.toLowerCase() ? "sso_recovery" : "email_verification"; + } catch { + return "email_verification"; + } +}; + export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVerificationEmailAction).action( withAuditLogging("verificationEmailSent", "user", async ({ ctx, parsedInput }) => { await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail); @@ -25,7 +58,12 @@ export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVer if (!user) { throw new ResourceNotFoundError("user", parsedInput.email); } - if (user.emailVerified) { + const validatedCallbackUrl = getValidatedCallbackUrl(parsedInput.callbackUrl, WEBAPP_URL) ?? undefined; + const purpose = getVerificationRequestPurpose({ + callbackUrl: validatedCallbackUrl, + userEmail: user.email, + }); + if (user.emailVerified && purpose !== "sso_recovery") { return { success: true, }; @@ -35,7 +73,8 @@ export const resendVerificationEmailAction = actionClient.inputSchema(ZResendVer id: user.id, email: user.email, locale: user.locale, - callbackUrl: getValidatedCallbackUrl(parsedInput.callbackUrl, WEBAPP_URL) ?? undefined, + callbackUrl: validatedCallbackUrl, + purpose, }); return { success: true, diff --git a/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx b/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx index d1b486b3f2bc..3cf480346658 100644 --- a/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx +++ b/apps/web/modules/auth/verification-requested/components/request-verification-email.tsx @@ -30,7 +30,10 @@ export const RequestVerificationEmail = ({ email, callbackUrl }: RequestVerifica const requestVerificationEmail = async () => { if (!email) return toast.error(t("auth.verification-requested.no_email_provided")); - const response = await resendVerificationEmailAction({ email, callbackUrl: callbackUrl ?? undefined }); + const response = await resendVerificationEmailAction({ + email, + callbackUrl: callbackUrl ?? undefined, + }); if (response?.data) { toast.success(t("auth.verification-requested.verification_email_resent_successfully")); } else { diff --git a/apps/web/modules/ee/audit-logs/types/audit-log.ts b/apps/web/modules/ee/audit-logs/types/audit-log.ts index ac86cf61d05c..c4d93dee5522 100644 --- a/apps/web/modules/ee/audit-logs/types/audit-log.ts +++ b/apps/web/modules/ee/audit-logs/types/audit-log.ts @@ -52,6 +52,9 @@ export const ZAuditAction = z.enum([ "userSignedOut", "passwordReset", "bulkCreated", + "sso_recovery_started", + "sso_recovery_completed", + "sso_recovery_failed", ]); export const ZActor = z.enum(["user", "api", "system"]); export const ZAuditStatus = z.enum(["success", "failure"]); diff --git a/apps/web/modules/ee/sso/lib/account-linking.test.ts b/apps/web/modules/ee/sso/lib/account-linking.test.ts new file mode 100644 index 000000000000..c692ca4011b6 --- /dev/null +++ b/apps/web/modules/ee/sso/lib/account-linking.test.ts @@ -0,0 +1,229 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { syncSsoIdentityForUser } from "./account-linking"; +import { OAUTH_ACCOUNT_NOT_LINKED_ERROR } from "./constants"; + +const mocks = vi.hoisted(() => ({ + accountFindUnique: vi.fn(), + accountDelete: vi.fn(), + accountUpdate: vi.fn(), + accountCreate: vi.fn(), + userUpdate: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + $transaction: vi.fn(), + account: { + findUnique: mocks.accountFindUnique, + delete: mocks.accountDelete, + update: mocks.accountUpdate, + create: mocks.accountCreate, + }, + user: { + update: mocks.userUpdate, + }, + }, +})); + +describe("syncSsoIdentityForUser", () => { + const account = { + type: "oauth" as const, + provider: "google", + providerAccountId: "provider-account-1", + access_token: "access-token", + refresh_token: "refresh-token", + scope: "openid email profile", + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.$transaction).mockImplementation(async (callback) => + callback({ + account: { + findUnique: mocks.accountFindUnique, + delete: mocks.accountDelete, + update: mocks.accountUpdate, + create: mocks.accountCreate, + }, + user: { + update: mocks.userUpdate, + }, + } as any) + ); + mocks.accountFindUnique.mockResolvedValue(null); + mocks.accountDelete.mockResolvedValue(undefined); + mocks.accountUpdate.mockResolvedValue(undefined); + mocks.accountCreate.mockResolvedValue(undefined); + mocks.userUpdate.mockResolvedValue(undefined); + }); + + test("throws when the canonical account is already linked to a different user", async () => { + mocks.accountFindUnique.mockResolvedValue({ + id: "account_1", + userId: "user_2", + }); + + await expect( + syncSsoIdentityForUser({ + userId: "user_1", + provider: "google", + account, + }) + ).rejects.toThrow(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + + expect(mocks.accountUpdate).not.toHaveBeenCalled(); + expect(mocks.accountCreate).not.toHaveBeenCalled(); + expect(mocks.userUpdate).not.toHaveBeenCalled(); + }); + + test("removes a legacy account row and refreshes the canonical account tokens when both exist", async () => { + mocks.accountFindUnique.mockResolvedValue({ + id: "account_1", + userId: "user_1", + }); + + await syncSsoIdentityForUser({ + userId: "user_1", + provider: "google", + account, + legacyAccountIdToNormalize: "legacy_account_1", + }); + + expect(mocks.accountDelete).toHaveBeenCalledWith({ + where: { + id: "legacy_account_1", + }, + }); + expect(mocks.accountUpdate).toHaveBeenCalledWith({ + where: { + id: "account_1", + }, + data: { + access_token: "access-token", + refresh_token: "refresh-token", + scope: "openid email profile", + }, + }); + expect(mocks.userUpdate).toHaveBeenCalledWith({ + where: { + id: "user_1", + }, + data: { + identityProvider: "google", + identityProviderAccountId: "provider-account-1", + }, + }); + }); + + test("reassigns a legacy account row when no canonical account exists yet", async () => { + await syncSsoIdentityForUser({ + userId: "user_1", + provider: "google", + account, + legacyAccountIdToNormalize: "legacy_account_1", + }); + + expect(mocks.accountUpdate).toHaveBeenCalledWith({ + where: { + id: "legacy_account_1", + }, + data: { + userId: "user_1", + type: "oauth", + provider: "google", + providerAccountId: "provider-account-1", + access_token: "access-token", + refresh_token: "refresh-token", + scope: "openid email profile", + }, + }); + expect(mocks.accountCreate).not.toHaveBeenCalled(); + }); + + test("wraps non-transactional calls in a prisma transaction", async () => { + await syncSsoIdentityForUser({ + userId: "user_1", + provider: "google", + account, + }); + + expect(prisma.$transaction).toHaveBeenCalledOnce(); + }); + + test("uses the transaction client when one is provided", async () => { + const txAccountFindUnique = vi.fn().mockResolvedValue({ + id: "account_1", + userId: "user_1", + }); + const txAccountUpdate = vi.fn().mockResolvedValue(undefined); + const txUserUpdate = vi.fn().mockResolvedValue(undefined); + const tx = { + account: { + findUnique: txAccountFindUnique, + update: txAccountUpdate, + }, + user: { + update: txUserUpdate, + }, + }; + + await syncSsoIdentityForUser({ + userId: "user_1", + provider: "google", + account, + tx: tx as any, + }); + + expect(txAccountFindUnique).toHaveBeenCalledOnce(); + expect(txAccountUpdate).toHaveBeenCalledWith({ + where: { + id: "account_1", + }, + data: { + access_token: "access-token", + refresh_token: "refresh-token", + scope: "openid email profile", + }, + }); + expect(txUserUpdate).toHaveBeenCalledWith({ + where: { + id: "user_1", + }, + data: { + identityProvider: "google", + identityProviderAccountId: "provider-account-1", + }, + }); + expect(prisma.account.findUnique).not.toHaveBeenCalled(); + }); + + test("creates a canonical account when no account rows exist yet", async () => { + await syncSsoIdentityForUser({ + userId: "user_1", + provider: "google", + account: { + ...account, + expires_at: 1234, + token_type: "Bearer", + id_token: "id-token", + }, + }); + + expect(mocks.accountCreate).toHaveBeenCalledWith({ + data: { + userId: "user_1", + type: "oauth", + provider: "google", + providerAccountId: "provider-account-1", + access_token: "access-token", + refresh_token: "refresh-token", + expires_at: 1234, + scope: "openid email profile", + token_type: "Bearer", + id_token: "id-token", + }, + }); + expect(mocks.userUpdate).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/modules/ee/sso/lib/account-linking.ts b/apps/web/modules/ee/sso/lib/account-linking.ts new file mode 100644 index 000000000000..3eab93e4c3a7 --- /dev/null +++ b/apps/web/modules/ee/sso/lib/account-linking.ts @@ -0,0 +1,179 @@ +import type { IdentityProvider, Prisma } from "@prisma/client"; +import type { Account } from "next-auth"; +import { prisma } from "@formbricks/database"; +import { OAUTH_ACCOUNT_NOT_LINKED_ERROR } from "@/modules/ee/sso/lib/constants"; + +export const LINKED_SSO_LOOKUP_SELECT = { + id: true, + email: true, + locale: true, + emailVerified: true, + isActive: true, + identityProvider: true, + identityProviderAccountId: true, +} as const; + +export type TSsoLookupUser = Prisma.UserGetPayload<{ + select: typeof LINKED_SSO_LOOKUP_SELECT; +}>; + +export type TSsoAccountLinkInput = Pick & + Partial< + Pick + >; + +const ACCOUNT_TOKEN_FIELDS = [ + "access_token", + "refresh_token", + "expires_at", + "scope", + "token_type", + "id_token", +] as const; + +type TAccountTokenField = (typeof ACCOUNT_TOKEN_FIELDS)[number]; +type TAccountTokenUpdate = Partial>; + +const setAccountTokenField = ( + accountTokenUpdate: TAccountTokenUpdate, + account: TSsoAccountLinkInput, + field: TField +) => { + const value = account[field]; + + if (value !== undefined) { + accountTokenUpdate[field] = value; + } +}; + +const getAccountTokenUpdate = (account: TSsoAccountLinkInput): TAccountTokenUpdate => { + const accountTokenUpdate: TAccountTokenUpdate = {}; + + for (const field of ACCOUNT_TOKEN_FIELDS) { + setAccountTokenField(accountTokenUpdate, account, field); + } + + return accountTokenUpdate; +}; + +const syncSsoIdentityForUserWithTx = async ({ + userId, + provider, + account, + tx, + legacyAccountIdToNormalize, +}: { + userId: string; + provider: IdentityProvider; + account: TSsoAccountLinkInput; + tx: Prisma.TransactionClient; + legacyAccountIdToNormalize?: string; +}) => { + const existingCanonicalAccount = await tx.account.findUnique({ + where: { + provider_providerAccountId: { + provider, + providerAccountId: account.providerAccountId, + }, + }, + select: { + id: true, + userId: true, + }, + }); + + if (existingCanonicalAccount && existingCanonicalAccount.userId !== userId) { + throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + } + + if (legacyAccountIdToNormalize) { + if (existingCanonicalAccount) { + await tx.account.delete({ + where: { + id: legacyAccountIdToNormalize, + }, + }); + await tx.account.update({ + where: { + id: existingCanonicalAccount.id, + }, + data: getAccountTokenUpdate(account), + }); + } else { + await tx.account.update({ + where: { + id: legacyAccountIdToNormalize, + }, + data: { + userId, + type: account.type, + provider, + providerAccountId: account.providerAccountId, + ...getAccountTokenUpdate(account), + }, + }); + } + } else if (existingCanonicalAccount) { + await tx.account.update({ + where: { + id: existingCanonicalAccount.id, + }, + data: getAccountTokenUpdate(account), + }); + } else { + await tx.account.create({ + data: { + userId, + type: account.type, + provider, + providerAccountId: account.providerAccountId, + ...getAccountTokenUpdate(account), + }, + }); + } + + await tx.user.update({ + where: { + id: userId, + }, + data: { + identityProvider: provider, + identityProviderAccountId: account.providerAccountId, + }, + }); +}; + +export const syncSsoIdentityForUser = async ({ + userId, + provider, + account, + tx, + legacyAccountIdToNormalize, +}: { + userId: string; + provider: IdentityProvider; + account: TSsoAccountLinkInput; + tx?: Prisma.TransactionClient; + legacyAccountIdToNormalize?: string; +}) => { + if (tx) { + await syncSsoIdentityForUserWithTx({ + userId, + provider, + account, + tx, + legacyAccountIdToNormalize, + }); + return; + } + + await prisma.$transaction(async (transactionTx) => { + await syncSsoIdentityForUserWithTx({ + userId, + provider, + account, + tx: transactionTx, + legacyAccountIdToNormalize, + }); + }); +}; diff --git a/apps/web/modules/ee/sso/lib/constants.ts b/apps/web/modules/ee/sso/lib/constants.ts new file mode 100644 index 000000000000..d060f5aa339c --- /dev/null +++ b/apps/web/modules/ee/sso/lib/constants.ts @@ -0,0 +1,2 @@ +export const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked"; +export const SSO_RECOVERY_COMPLETION_PATH = "/api/auth/sso/recovery/complete"; diff --git a/apps/web/modules/ee/sso/lib/provider-normalization.test.ts b/apps/web/modules/ee/sso/lib/provider-normalization.test.ts new file mode 100644 index 000000000000..d91456b1086a --- /dev/null +++ b/apps/web/modules/ee/sso/lib/provider-normalization.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vitest"; +import { + getLegacySsoProviderAliases, + getSsoProviderLookupCandidates, + normalizeSsoProvider, +} from "./provider-normalization"; + +describe("SSO provider normalization", () => { + test("normalizes supported provider ids to canonical values", () => { + expect(normalizeSsoProvider("google")).toBe("google"); + expect(normalizeSsoProvider("github")).toBe("github"); + expect(normalizeSsoProvider("azure-ad")).toBe("azuread"); + expect(normalizeSsoProvider("azuread")).toBe("azuread"); + expect(normalizeSsoProvider("openid")).toBe("openid"); + expect(normalizeSsoProvider("saml")).toBe("saml"); + expect(normalizeSsoProvider("unsupported")).toBeNull(); + }); + + test("returns legacy lookup aliases for canonical providers", () => { + expect(getLegacySsoProviderAliases("azuread")).toEqual(["azure-ad"]); + expect(getLegacySsoProviderAliases("google")).toEqual([]); + }); + + test("includes canonical and legacy provider ids when searching for linked accounts", () => { + expect(getSsoProviderLookupCandidates("azuread")).toEqual(["azuread", "azure-ad"]); + expect(getSsoProviderLookupCandidates("google")).toEqual(["google"]); + }); +}); diff --git a/apps/web/modules/ee/sso/lib/provider-normalization.ts b/apps/web/modules/ee/sso/lib/provider-normalization.ts new file mode 100644 index 000000000000..377e5e0cba25 --- /dev/null +++ b/apps/web/modules/ee/sso/lib/provider-normalization.ts @@ -0,0 +1,39 @@ +import type { IdentityProvider } from "@prisma/client"; + +const SSO_PROVIDER_MAP = { + google: "google", + github: "github", + "azure-ad": "azuread", + azuread: "azuread", + openid: "openid", + saml: "saml", +} as const satisfies Record; + +const LEGACY_SSO_PROVIDER_ALIASES: Partial> = { + azuread: ["azure-ad"], +}; + +const isSupportedSsoProvider = (provider: string): provider is keyof typeof SSO_PROVIDER_MAP => + provider in SSO_PROVIDER_MAP; + +export const normalizeSsoProvider = (provider: string): IdentityProvider | null => { + const normalizedProviderKey = provider.toLowerCase(); + if (!isSupportedSsoProvider(normalizedProviderKey)) { + return null; + } + + return SSO_PROVIDER_MAP[normalizedProviderKey]; +}; + +export const getLegacySsoProviderAliases = (provider: IdentityProvider): string[] => + LEGACY_SSO_PROVIDER_ALIASES[provider] ?? []; + +export const getSsoProviderLookupCandidates = (provider: string): string[] => { + const normalizedProvider = normalizeSsoProvider(provider); + + if (!normalizedProvider) { + return []; + } + + return [normalizedProvider, ...getLegacySsoProviderAliases(normalizedProvider)]; +}; diff --git a/apps/web/modules/ee/sso/lib/providers.test.ts b/apps/web/modules/ee/sso/lib/providers.test.ts index b2e4c9096c3f..d0ded414bb05 100644 --- a/apps/web/modules/ee/sso/lib/providers.test.ts +++ b/apps/web/modules/ee/sso/lib/providers.test.ts @@ -1,22 +1,43 @@ import { describe, expect, test, vi } from "vitest"; import { getSSOProviders } from "./providers"; +type TSsoProvider = ReturnType[number]; +type TOidcProvider = Extract; +type TSamlProvider = Extract; +type TAzureProvider = Extract; + +const getProviderById = (id: TId): Extract => { + const provider = getSSOProviders().find( + (candidate): candidate is Extract => candidate.id === id + ); + + if (!provider) { + throw new Error(`Provider with id ${id} not found`); + } + + return provider; +}; + // Mock environment variables -vi.mock("@/lib/constants", () => ({ - GITHUB_ID: "test-github-id", - GITHUB_SECRET: "test-github-secret", - GOOGLE_CLIENT_ID: "test-google-client-id", - GOOGLE_CLIENT_SECRET: "test-google-client-secret", - AZUREAD_CLIENT_ID: "test-azure-client-id", - AZUREAD_CLIENT_SECRET: "test-azure-client-secret", - AZUREAD_TENANT_ID: "test-azure-tenant-id", - OIDC_CLIENT_ID: "test-oidc-client-id", - OIDC_CLIENT_SECRET: "test-oidc-client-secret", - OIDC_DISPLAY_NAME: "Test OIDC", - OIDC_ISSUER: "https://test-issuer.com", - OIDC_SIGNING_ALGORITHM: "RS256", - WEBAPP_URL: "https://test-app.com", -})); +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + GITHUB_ID: "test-github-id", + GITHUB_SECRET: "test-github-secret", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azure-client-id", + AZUREAD_CLIENT_SECRET: "test-azure-client-secret", + AZUREAD_TENANT_ID: "test-azure-tenant-id", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_DISPLAY_NAME: "Test OIDC", + OIDC_ISSUER: "https://test-issuer.com", + OIDC_SIGNING_ALGORITHM: "RS256", + WEBAPP_URL: "https://test-app.com", + }; +}); describe("SSO Providers", () => { test("should return all configured providers", () => { @@ -25,33 +46,91 @@ describe("SSO Providers", () => { }); test("should configure OIDC provider correctly", () => { - const providers = getSSOProviders(); - const oidcProvider = providers[3]; + const oidcProvider = getProviderById("openid") as TOidcProvider; expect(oidcProvider.id).toBe("openid"); expect(oidcProvider.name).toBe("Test OIDC"); - expect((oidcProvider as any).clientId).toBe("test-oidc-client-id"); - expect((oidcProvider as any).clientSecret).toBe("test-oidc-client-secret"); - expect((oidcProvider as any).wellKnown).toBe("https://test-issuer.com/.well-known/openid-configuration"); - expect((oidcProvider as any).client?.id_token_signed_response_alg).toBe("RS256"); + expect(oidcProvider.clientId).toBe("test-oidc-client-id"); + expect(oidcProvider.clientSecret).toBe("test-oidc-client-secret"); + expect(oidcProvider.wellKnown).toBe("https://test-issuer.com/.well-known/openid-configuration"); + expect(oidcProvider.client?.id_token_signed_response_alg).toBe("RS256"); expect(oidcProvider.checks).toContain("pkce"); expect(oidcProvider.checks).toContain("state"); }); + test("should map the OIDC profile into the Formbricks user shape", () => { + const oidcProvider = getProviderById("openid") as TOidcProvider; + const oidcProfile: Parameters>[0] = { + sub: "oidc-user-1", + name: "OIDC User", + email: "oidc@example.com", + }; + + expect(oidcProvider.profile?.(oidcProfile)).toEqual({ + id: "oidc-user-1", + name: "OIDC User", + email: "oidc@example.com", + }); + }); + test("should configure SAML provider correctly", () => { - const providers = getSSOProviders(); - const samlProvider = providers[4]; - const googleProvider = providers[1]; + const samlProvider = getProviderById("saml") as TSamlProvider; + const googleProvider = getProviderById("google"); + const azureProvider = getProviderById("azure-ad") as TAzureProvider; expect(samlProvider.id).toBe("saml"); + expect(azureProvider.id).toBe("azure-ad"); expect(samlProvider.name).toBe("BoxyHQ SAML"); - expect((samlProvider as any).version).toBe("2.0"); + expect(samlProvider.version).toBe("2.0"); expect(samlProvider.checks).toContain("pkce"); expect(samlProvider.checks).toContain("state"); - expect((samlProvider as any).authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize"); + expect(samlProvider.authorization?.url).toBe("https://test-app.com/api/auth/saml/authorize"); expect(samlProvider.token).toBe("https://test-app.com/api/auth/saml/token"); expect(samlProvider.userinfo).toBe("https://test-app.com/api/auth/saml/userinfo"); expect(googleProvider.allowDangerousEmailAccountLinking).toBeUndefined(); expect(samlProvider.allowDangerousEmailAccountLinking).toBeUndefined(); }); + + test("should map the SAML profile and trim empty name parts", () => { + const samlProvider = getProviderById("saml") as TSamlProvider; + const samlProfile: Parameters>[0] = { + id: "saml-user-1", + email: "saml@example.com", + firstName: "Saml", + lastName: "", + }; + + expect(samlProvider.profile?.(samlProfile)).toEqual({ + id: "saml-user-1", + email: "saml@example.com", + name: "Saml", + }); + }); + + test("falls back to empty Azure credentials when legacy env vars are unset", async () => { + vi.resetModules(); + vi.doMock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + AZUREAD_CLIENT_ID: undefined, + AZUREAD_CLIENT_SECRET: undefined, + AZUREAD_TENANT_ID: undefined, + }; + }); + + const { getSSOProviders: getProvidersWithMissingAzureEnv } = await import("./providers"); + const azureProvider = getProvidersWithMissingAzureEnv().find( + (provider): provider is TAzureProvider => provider.id === "azure-ad" + ); + + if (!azureProvider) { + throw new Error("Azure provider not found"); + } + + expect(azureProvider.id).toBe("azure-ad"); + expect(azureProvider.options.clientId).toBe(""); + expect(azureProvider.options.clientSecret).toBe(""); + expect(azureProvider.options.tenantId).toBe(""); + }); }); diff --git a/apps/web/modules/ee/sso/lib/sso-handlers.ts b/apps/web/modules/ee/sso/lib/sso-handlers.ts index acd3990e68b6..a273ac693ce6 100644 --- a/apps/web/modules/ee/sso/lib/sso-handlers.ts +++ b/apps/web/modules/ee/sso/lib/sso-handlers.ts @@ -1,9 +1,8 @@ -import type { IdentityProvider, Organization, Prisma } from "@prisma/client"; +import type { IdentityProvider, Organization } from "@prisma/client"; import type { Account } from "next-auth"; import { prisma } from "@formbricks/database"; import { logger } from "@formbricks/logger"; import type { TUser, TUserNotificationSettings } from "@formbricks/types/user"; -import { upsertAccount } from "@/lib/account/service"; import { DEFAULT_TEAM_ID, SKIP_INVITE_FOR_SSO } from "@/lib/constants"; import { getIsFreshInstance } from "@/lib/instance/service"; import { verifyInviteToken } from "@/lib/jwt"; @@ -23,49 +22,26 @@ import { } from "@/modules/ee/license-check/lib/utils"; import { getFirstOrganization } from "@/modules/ee/sso/lib/organization"; import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team"; - -const LINKED_SSO_LOOKUP_SELECT = { - id: true, - email: true, - locale: true, - emailVerified: true, - isActive: true, - identityProvider: true, - identityProviderAccountId: true, -} as const; - -const OAUTH_ACCOUNT_NOT_LINKED_ERROR = "OAuthAccountNotLinked"; - -const syncSsoAccount = async (userId: string, account: Account, tx?: Prisma.TransactionClient) => { - await upsertAccount( - { - userId, - type: account.type, - provider: account.provider, - providerAccountId: account.providerAccountId, - ...(account.access_token !== undefined ? { access_token: account.access_token } : {}), - ...(account.refresh_token !== undefined ? { refresh_token: account.refresh_token } : {}), - ...(account.expires_at !== undefined ? { expires_at: account.expires_at } : {}), - ...(account.scope !== undefined ? { scope: account.scope } : {}), - ...(account.token_type !== undefined ? { token_type: account.token_type } : {}), - ...(account.id_token !== undefined ? { id_token: account.id_token } : {}), - }, - tx - ); -}; +import { LINKED_SSO_LOOKUP_SELECT, TSsoLookupUser, syncSsoIdentityForUser } from "./account-linking"; +import { getSsoProviderLookupCandidates, normalizeSsoProvider } from "./provider-normalization"; +import { startSsoRecovery } from "./sso-recovery"; const syncLinkedSsoUser = async ({ linkedUser, user, account, + provider, contextLogger, logSource, + legacyAccountIdToNormalize, }: { linkedUser: Pick; user: TUser; account: Account; + provider: IdentityProvider; contextLogger: ReturnType; - logSource: "account_row" | "legacy_identity_provider"; + logSource: "account_row" | "legacy_account_alias" | "legacy_identity_provider"; + legacyAccountIdToNormalize?: string; }) => { contextLogger.debug( { @@ -77,7 +53,23 @@ const syncLinkedSsoUser = async ({ ); if (linkedUser.email === user.email) { - await syncSsoAccount(linkedUser.id, account); + await syncSsoIdentityForUser({ + userId: linkedUser.id, + provider, + account: { + type: account.type, + provider, + providerAccountId: account.providerAccountId, + access_token: account.access_token, + refresh_token: account.refresh_token, + expires_at: account.expires_at, + scope: account.scope, + token_type: account.token_type, + id_token: account.id_token, + }, + legacyAccountIdToNormalize, + }); + contextLogger.debug( { linkedUserId: linkedUser.id, logSource }, "SSO callback successful: linked user, email matches" @@ -98,8 +90,35 @@ const syncLinkedSsoUser = async ({ "No other user with this email found, updating linked user email after SSO provider change" ); - await updateUser(linkedUser.id, { email: user.email }); - await syncSsoAccount(linkedUser.id, account); + await prisma.$transaction(async (tx) => { + await tx.user.update({ + where: { + id: linkedUser.id, + }, + data: { + email: user.email, + }, + }); + + await syncSsoIdentityForUser({ + userId: linkedUser.id, + provider, + account: { + type: account.type, + provider, + providerAccountId: account.providerAccountId, + access_token: account.access_token, + refresh_token: account.refresh_token, + expires_at: account.expires_at, + scope: account.scope, + token_type: account.token_type, + id_token: account.id_token, + }, + tx, + legacyAccountIdToNormalize, + }); + }); + return true; } @@ -113,379 +132,464 @@ const syncLinkedSsoUser = async ({ ); }; -export const handleSsoCallback = async ({ - user, - account, - callbackUrl, +const findLinkedSsoUser = async ({ + provider, + providerAccountId, }: { - user: TUser; - account: Account; - callbackUrl: string; -}) => { - const contextLogger = logger.withContext({ - correlationId: crypto.randomUUID(), - name: "formbricks", - }); - - contextLogger.debug( - { - ...redactPII({ user, account, callbackUrl }), - hasEmail: !!user.email, - hasName: !!user.name, - }, - "SSO callback initiated" - ); - - const isSsoEnabled = await getIsSsoEnabled(); - if (!isSsoEnabled) { - contextLogger.debug({ isSsoEnabled }, "SSO not enabled"); - return false; - } - - if (!user.email || account.type !== "oauth") { - contextLogger.debug( - { - hasEmail: !!user.email, - accountType: account.type, - reason: !user.email ? "missing_email" : "invalid_account_type", - }, - "SSO callback rejected: missing email or invalid account type" - ); - - return false; - } - - let provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider; - - if (provider === "saml") { - const isSamlSsoEnabled = await getIsSamlSsoEnabled(); - if (!isSamlSsoEnabled) { - contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license"); - return false; - } - } - - if (account.provider) { - contextLogger.debug( - { lookupType: "account_provider_account_id" }, - "Checking for existing linked user by provider account" - ); - + provider: IdentityProvider; + providerAccountId: string; +}): Promise<{ + linkedUser: TSsoLookupUser; + logSource: "account_row" | "legacy_account_alias"; + legacyAccountIdToNormalize?: string; +} | null> => { + const lookupCandidates = getSsoProviderLookupCandidates(provider); + + for (const lookupProvider of lookupCandidates) { const existingLinkedAccount = await prisma.account.findUnique({ where: { provider_providerAccountId: { - provider: account.provider, - providerAccountId: account.providerAccountId, + provider: lookupProvider, + providerAccountId, }, }, select: { + id: true, + provider: true, user: { select: LINKED_SSO_LOOKUP_SELECT, }, }, }); - if (existingLinkedAccount?.user) { - return syncLinkedSsoUser({ + if (!existingLinkedAccount?.user) { + continue; + } + + if (existingLinkedAccount.provider === provider) { + return { linkedUser: existingLinkedAccount.user, - user, - account, - contextLogger, logSource: "account_row", - }); + }; + } + + return { + linkedUser: existingLinkedAccount.user, + logSource: "legacy_account_alias", + legacyAccountIdToNormalize: existingLinkedAccount.id, + }; + } + + return null; +}; + +const findLegacyExactMatch = async ({ + provider, + providerAccountId, +}: { + provider: IdentityProvider; + providerAccountId: string; +}) => + prisma.user.findFirst({ + where: { + identityProvider: provider, + identityProviderAccountId: providerAccountId, + }, + select: LINKED_SSO_LOOKUP_SELECT, + }); + +const provisionNewSsoUser = async ({ + user, + account, + provider, + callbackUrl, + contextLogger, +}: { + user: TUser; + account: Account; + provider: IdentityProvider; + callbackUrl: string; + contextLogger: ReturnType; +}) => { + let userName = user.name; + + if (provider === "openid") { + const oidcUser = user as TUser & TOidcNameFields; + if (oidcUser.name) { + userName = oidcUser.name; + } else if (oidcUser.given_name || oidcUser.family_name) { + userName = `${oidcUser.given_name} ${oidcUser.family_name}`; + } else if (oidcUser.preferred_username) { + userName = oidcUser.preferred_username; } contextLogger.debug( - { lookupType: "legacy_identity_provider_account_id" }, - "No account row found, checking for legacy linked SSO user" + { + hasName: !!oidcUser.name, + hasGivenName: !!oidcUser.given_name, + hasFamilyName: !!oidcUser.family_name, + hasPreferredUsername: !!oidcUser.preferred_username, + }, + "Extracted OIDC user name" ); + } - const existingLegacyLinkedUser = await prisma.user.findFirst({ - where: { - identityProvider: provider, - identityProviderAccountId: account.providerAccountId, + if (provider === "saml") { + const samlUser = user as TUser & TSamlNameFields; + if (samlUser.name) { + userName = samlUser.name; + } else if (samlUser.firstName || samlUser.lastName) { + userName = `${samlUser.firstName} ${samlUser.lastName}`; + } + contextLogger.debug( + { + hasName: !!samlUser.name, + hasFirstName: !!samlUser.firstName, + hasLastName: !!samlUser.lastName, }, - select: LINKED_SSO_LOOKUP_SELECT, - }); + "Extracted SAML user name" + ); + } - if (existingLegacyLinkedUser) { - return syncLinkedSsoUser({ - linkedUser: existingLegacyLinkedUser, - user, - account, - contextLogger, - logSource: "legacy_identity_provider", - }); + const isMultiOrgEnabled = await getIsMultiOrgEnabled(); + const isFirstUser = await getIsFreshInstance(); + + contextLogger.debug( + { + isMultiOrgEnabled, + isFirstUser, + skipInviteForSso: SKIP_INVITE_FOR_SSO, + hasDefaultTeamId: !!DEFAULT_TEAM_ID, + }, + "License and instance configuration checked" + ); + + if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) { + if (!callbackUrl) { + contextLogger.debug( + { reason: "missing_callback_url" }, + "SSO callback rejected: missing callback URL for invite validation" + ); + return false; } - // There is no existing linked account for this identity provider / account id - // check if a user account with this email already exists and fail closed if so - contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email"); + try { + const isValidCallbackUrl = new URL(callbackUrl); + const inviteToken = isValidCallbackUrl.searchParams.get("token") || ""; + const source = isValidCallbackUrl.searchParams.get("source") || ""; - const existingUserWithEmail = await getUserByEmail(user.email); + if (source === "signin" && !inviteToken) { + contextLogger.debug( + { reason: "signin_without_invite_token" }, + "SSO callback rejected: signin without invite token" + ); + return false; + } - if (existingUserWithEmail) { + const { email, inviteId } = verifyInviteToken(inviteToken); + if (email !== user.email) { + contextLogger.debug( + { reason: "invite_email_mismatch", inviteId }, + "SSO callback rejected: invite token email mismatch" + ); + return false; + } + + const isValidInviteToken = await getIsValidInviteToken(inviteId); + if (!isValidInviteToken) { + contextLogger.debug( + { reason: "invalid_invite_token", inviteId }, + "SSO callback rejected: invalid or expired invite token" + ); + return false; + } + contextLogger.debug({ inviteId }, "Invite token validation successful"); + } catch (err) { contextLogger.debug( { - existingUserId: existingUserWithEmail.id, - existingIdentityProvider: existingUserWithEmail.identityProvider, + reason: "invite_token_validation_error", + error: err instanceof Error ? err.message : "unknown_error", }, - "SSO callback blocked: existing user found by email without linked provider account" + "SSO callback rejected: invite token validation failed" ); - throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + contextLogger.error(err, "Invalid callbackUrl"); + return false; } + } + let organization: Organization | null = null; + + if (!isFirstUser && !isMultiOrgEnabled) { contextLogger.debug( - { action: "new_user_creation" }, - "No existing user found, proceeding with new user creation" + { + assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization", + }, + "Determining organization assignment" ); + if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) { + organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID); + } else { + organization = await getFirstOrganization(); + } - let userName = user.name; - - if (provider === "openid") { - const oidcUser = user as TUser & TOidcNameFields; - if (oidcUser.name) { - userName = oidcUser.name; - } else if (oidcUser.given_name || oidcUser.family_name) { - userName = `${oidcUser.given_name} ${oidcUser.family_name}`; - } else if (oidcUser.preferred_username) { - userName = oidcUser.preferred_username; - } - + if (!organization) { contextLogger.debug( - { - hasName: !!oidcUser.name, - hasGivenName: !!oidcUser.given_name, - hasFamilyName: !!oidcUser.family_name, - hasPreferredUsername: !!oidcUser.preferred_username, - }, - "Extracted OIDC user name" + { reason: "no_organization_found" }, + "SSO callback rejected: no organization found for assignment" ); + return false; } - if (provider === "saml") { - const samlUser = user as TUser & TSamlNameFields; - if (samlUser.name) { - userName = samlUser.name; - } else if (samlUser.firstName || samlUser.lastName) { - userName = `${samlUser.firstName} ${samlUser.lastName}`; - } + const isAccessControlAllowed = await getAccessControlPermission(organization.id); + if (!isAccessControlAllowed && !callbackUrl) { contextLogger.debug( { - hasName: !!samlUser.name, - hasFirstName: !!samlUser.firstName, - hasLastName: !!samlUser.lastName, + reason: "insufficient_role_permissions", + organizationId: organization.id, + isAccessControlAllowed, }, - "Extracted SAML user name" + "SSO callback rejected: insufficient role management permissions" ); + return false; } + } - // Get multi-org license status - const isMultiOrgEnabled = await getIsMultiOrgEnabled(); - - const isFirstUser = await getIsFreshInstance(); + contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user"); + const matchedLocale = await findMatchingLocale(); - contextLogger.debug( + const userProfile = await prisma.$transaction(async (tx) => { + const createdUser = await createUser( { - isMultiOrgEnabled, - isFirstUser, - skipInviteForSso: SKIP_INVITE_FOR_SSO, - hasDefaultTeamId: !!DEFAULT_TEAM_ID, + name: + userName || + user.email + .split("@")[0] + .replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ") + .trim(), + email: user.email, + emailVerified: new Date(Date.now()), + identityProvider: provider, + identityProviderAccountId: account.providerAccountId, + locale: matchedLocale, }, - "License and instance configuration checked" + tx ); - // Additional security checks for self-hosted instances without auto-provisioning and no multi-org enabled - if (!isFirstUser && !SKIP_INVITE_FOR_SSO && !isMultiOrgEnabled) { - if (!callbackUrl) { - contextLogger.debug( - { reason: "missing_callback_url" }, - "SSO callback rejected: missing callback URL for invite validation" - ); - return false; - } - - try { - // Parse and validate the callback URL - const isValidCallbackUrl = new URL(callbackUrl); - // Extract invite token and source from URL parameters - const inviteToken = isValidCallbackUrl.searchParams.get("token") || ""; - const source = isValidCallbackUrl.searchParams.get("source") || ""; - - // Allow sign-in if multi-org is enabled, otherwise check for invite token - if (source === "signin" && !inviteToken) { - contextLogger.debug( - { reason: "signin_without_invite_token" }, - "SSO callback rejected: signin without invite token" - ); - return false; - } - - // If multi-org is enabled, skip invite token validation - // Verify invite token and check email match - const { email, inviteId } = verifyInviteToken(inviteToken); - if (email !== user.email) { - contextLogger.debug( - { reason: "invite_email_mismatch", inviteId }, - "SSO callback rejected: invite token email mismatch" - ); - return false; - } - // Check if invite token is still valid - const isValidInviteToken = await getIsValidInviteToken(inviteId); - if (!isValidInviteToken) { - contextLogger.debug( - { reason: "invalid_invite_token", inviteId }, - "SSO callback rejected: invalid or expired invite token" - ); - return false; - } - contextLogger.debug({ inviteId }, "Invite token validation successful"); - } catch (err) { - contextLogger.debug( - { - reason: "invite_token_validation_error", - error: err instanceof Error ? err.message : "unknown_error", - }, - "SSO callback rejected: invite token validation failed" - ); - // Log and reject on any validation errors - contextLogger.error(err, "Invalid callbackUrl"); - return false; - } - } - - let organization: Organization | null = null; + await syncSsoIdentityForUser({ + userId: createdUser.id, + provider, + account: { + type: account.type, + provider, + providerAccountId: account.providerAccountId, + access_token: account.access_token, + refresh_token: account.refresh_token, + expires_at: account.expires_at, + scope: account.scope, + token_type: account.token_type, + id_token: account.id_token, + }, + tx, + }); - if (!isFirstUser && !isMultiOrgEnabled) { + if (organization) { contextLogger.debug( - { - assignmentStrategy: SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID ? "default_team" : "first_organization", - }, - "Determining organization assignment" + { newUserId: createdUser.id, organizationId: organization.id, role: "member" }, + "Assigning user to organization" ); - if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) { - organization = await getOrganizationByTeamId(DEFAULT_TEAM_ID); - } else { - organization = await getFirstOrganization(); - } - - if (!organization) { - contextLogger.debug( - { reason: "no_organization_found" }, - "SSO callback rejected: no organization found for assignment" - ); - return false; - } + await createMembership(organization.id, createdUser.id, { role: "member", accepted: true }, tx); - const isAccessControlAllowed = await getAccessControlPermission(organization.id); - if (!isAccessControlAllowed && !callbackUrl) { + if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) { contextLogger.debug( - { - reason: "insufficient_role_permissions", - organizationId: organization.id, - isAccessControlAllowed, - }, - "SSO callback rejected: insufficient role management permissions" + { newUserId: createdUser.id, defaultTeamId: DEFAULT_TEAM_ID }, + "Creating default team membership" ); - return false; + await createDefaultTeamMembership(createdUser.id, tx); } - } - contextLogger.debug({ hasUserName: !!userName, identityProvider: provider }, "Creating new SSO user"); - const matchedLocale = await findMatchingLocale(); + const updatedNotificationSettings: TUserNotificationSettings = { + ...createdUser.notificationSettings, + alert: { + ...createdUser.notificationSettings?.alert, + }, + unsubscribedOrganizationIds: Array.from( + new Set([...(createdUser.notificationSettings?.unsubscribedOrganizationIds || []), organization.id]) + ), + }; - const userProfile = await prisma.$transaction(async (tx) => { - const createdUser = await createUser( + await updateUser( + createdUser.id, { - name: - userName || - user.email - .split("@")[0] - .replace(/[^'\p{L}\p{M}\s\d-]+/gu, " ") - .trim(), - email: user.email, - emailVerified: new Date(Date.now()), - identityProvider: provider, - identityProviderAccountId: account.providerAccountId, - locale: matchedLocale, + notificationSettings: updatedNotificationSettings, }, tx ); + } - await syncSsoAccount(createdUser.id, account, tx); + return createdUser; + }); - if (organization) { - contextLogger.debug( - { newUserId: createdUser.id, organizationId: organization.id, role: "member" }, - "Assigning user to organization" - ); - await createMembership(organization.id, createdUser.id, { role: "member", accepted: true }, tx); - - if (SKIP_INVITE_FOR_SSO && DEFAULT_TEAM_ID) { - contextLogger.debug( - { newUserId: createdUser.id, defaultTeamId: DEFAULT_TEAM_ID }, - "Creating default team membership" - ); - await createDefaultTeamMembership(createdUser.id, tx); - } - - const updatedNotificationSettings: TUserNotificationSettings = { - ...createdUser.notificationSettings, - alert: { - ...createdUser.notificationSettings?.alert, - }, - unsubscribedOrganizationIds: Array.from( - new Set([ - ...(createdUser.notificationSettings?.unsubscribedOrganizationIds || []), - organization.id, - ]) - ), - }; - - await updateUser( - createdUser.id, - { - notificationSettings: updatedNotificationSettings, - }, - tx - ); - } + contextLogger.debug( + { newUserId: userProfile.id, identityProvider: provider }, + "New SSO user created successfully" + ); - return createdUser; - }); + createBrevoCustomer({ id: userProfile.id, email: userProfile.email }); + capturePostHogEvent(userProfile.id, "user_signed_up", { + auth_provider: provider, + email_domain: userProfile.email.split("@")[1], + signup_source: callbackUrl?.includes("token=") ? "invite" : "direct", + invite_organization_id: organization?.id ?? null, + }); + + if (isMultiOrgEnabled) { contextLogger.debug( - { newUserId: userProfile.id, identityProvider: provider }, - "New SSO user created successfully" + { isMultiOrgEnabled, newUserId: userProfile.id }, + "Multi-org enabled, skipping organization assignment" ); + return true; + } - // send new user to brevo - createBrevoCustomer({ id: userProfile.id, email: userProfile.email }); + if (organization) { + return true; + } - capturePostHogEvent(userProfile.id, "user_signed_up", { - auth_provider: provider, - email_domain: userProfile.email.split("@")[1], - signup_source: callbackUrl?.includes("token=") ? "invite" : "direct", - invite_organization_id: organization?.id ?? null, - }); + return true; +}; - if (isMultiOrgEnabled) { - contextLogger.debug( - { isMultiOrgEnabled, newUserId: userProfile.id }, - "Multi-org enabled, skipping organization assignment" - ); - return true; - } +export const handleSsoCallback = async ({ + user, + account, + callbackUrl, +}: { + user: TUser; + account: Account; + callbackUrl: string; +}): Promise => { + const contextLogger = logger.withContext({ + correlationId: crypto.randomUUID(), + name: "formbricks", + }); - // Default organization assignment if env variable is set - if (organization) { - return true; + contextLogger.debug( + { + ...redactPII({ user, account, callbackUrl }), + hasEmail: !!user.email, + hasName: !!user.name, + }, + "SSO callback initiated" + ); + + const isSsoEnabled = await getIsSsoEnabled(); + if (!isSsoEnabled) { + contextLogger.debug({ isSsoEnabled }, "SSO not enabled"); + return false; + } + + if (!user.email || account.type !== "oauth") { + contextLogger.debug( + { + hasEmail: !!user.email, + accountType: account.type, + reason: !user.email ? "missing_email" : "invalid_account_type", + }, + "SSO callback rejected: missing email or invalid account type" + ); + + return false; + } + + const provider = normalizeSsoProvider(account.provider); + if (!provider) { + contextLogger.debug({ provider: account.provider }, "SSO callback rejected: unsupported provider"); + return false; + } + + if (provider === "saml") { + const isSamlSsoEnabled = await getIsSamlSsoEnabled(); + if (!isSamlSsoEnabled) { + contextLogger.debug({ provider: "saml" }, "SSO callback rejected: SAML not enabled in license"); + return false; } - // Without default organization assignment - return true; } - contextLogger.debug("SSO callback successful: default return"); - return true; + contextLogger.debug( + { lookupType: "account_provider_account_id" }, + "Checking for existing linked user by provider account" + ); + const existingLinkedUser = await findLinkedSsoUser({ + provider, + providerAccountId: account.providerAccountId, + }); + + if (existingLinkedUser) { + return syncLinkedSsoUser({ + linkedUser: existingLinkedUser.linkedUser, + user, + account, + provider, + contextLogger, + logSource: existingLinkedUser.logSource, + legacyAccountIdToNormalize: existingLinkedUser.legacyAccountIdToNormalize, + }); + } + + contextLogger.debug( + { lookupType: "legacy_identity_provider_account_id" }, + "No account row found, checking for legacy linked SSO user" + ); + const legacyExactMatch = await findLegacyExactMatch({ + provider, + providerAccountId: account.providerAccountId, + }); + + if (legacyExactMatch) { + return syncLinkedSsoUser({ + linkedUser: legacyExactMatch, + user, + account, + provider, + contextLogger, + logSource: "legacy_identity_provider", + }); + } + + contextLogger.debug({ lookupType: "email" }, "No linked SSO account found, checking for user by email"); + const existingUserWithEmail = await prisma.user.findUnique({ + where: { + email: user.email, + }, + select: LINKED_SSO_LOOKUP_SELECT, + }); + + if (existingUserWithEmail) { + contextLogger.debug( + { + existingUserId: existingUserWithEmail.id, + existingIdentityProvider: existingUserWithEmail.identityProvider, + }, + "SSO callback requires inbox verification before linking" + ); + + return startSsoRecovery({ + existingUser: existingUserWithEmail, + provider, + account, + callbackUrl, + }); + } + + contextLogger.debug( + { action: "new_user_creation" }, + "No existing user found, proceeding with new user creation" + ); + + return provisionNewSsoUser({ + user, + account, + provider, + callbackUrl, + contextLogger, + }); }; diff --git a/apps/web/modules/ee/sso/lib/sso-recovery.test.ts b/apps/web/modules/ee/sso/lib/sso-recovery.test.ts new file mode 100644 index 000000000000..d587052cd2c8 --- /dev/null +++ b/apps/web/modules/ee/sso/lib/sso-recovery.test.ts @@ -0,0 +1,425 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { prisma } from "@formbricks/database"; +import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking"; +import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links"; +import { sendVerificationEmail } from "@/modules/email"; +import { syncSsoIdentityForUser } from "./account-linking"; +import { completeSsoRecovery, getSsoRecoveryFailureRedirectUrl, startSsoRecovery } from "./sso-recovery"; + +const mocks = vi.hoisted(() => ({ + createEmailToken: vi.fn(), + createSsoRelinkIntent: vi.fn(), + verifySsoRelinkIntent: vi.fn(), + queueAuditEventBackground: vi.fn(), +})); + +vi.mock("@formbricks/database", () => ({ + prisma: { + $transaction: vi.fn(), + user: { + findUnique: vi.fn(), + }, + }, +})); + +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + WEBAPP_URL: "http://localhost:3000", + }; +}); + +vi.mock("@/lib/jwt", () => ({ + createEmailToken: mocks.createEmailToken, + createSsoRelinkIntent: mocks.createSsoRelinkIntent, + verifySsoRelinkIntent: mocks.verifySsoRelinkIntent, +})); + +vi.mock("@/modules/auth/lib/sign-in-tracking", () => ({ + finalizeSuccessfulSignIn: vi.fn(), +})); + +vi.mock("@/modules/auth/lib/verification-links", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildVerificationRequestedPath: vi.fn(), + }; +}); + +vi.mock("@/modules/email", () => ({ + sendVerificationEmail: vi.fn(), +})); + +vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({ + queueAuditEventBackground: mocks.queueAuditEventBackground, +})); + +vi.mock("@formbricks/logger", () => ({ + logger: { + error: vi.fn(), + withContext: vi.fn(() => ({ + error: vi.fn(), + info: vi.fn(), + })), + }, +})); + +vi.mock("./account-linking", () => ({ + LINKED_SSO_LOOKUP_SELECT: { + id: true, + email: true, + locale: true, + emailVerified: true, + isActive: true, + identityProvider: true, + identityProviderAccountId: true, + }, + syncSsoIdentityForUser: vi.fn(), +})); + +describe("sso-recovery", () => { + const txUserUpdate = vi.fn(); + const tx = { + user: { + update: txUserUpdate, + }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(prisma.$transaction).mockImplementation( + async (callback: (tx: typeof tx) => Promise) => await callback(tx) + ); + vi.mocked(buildVerificationRequestedPath).mockReturnValue( + "/auth/verification-requested?token=email-token&purpose=sso_recovery" + ); + mocks.createEmailToken.mockReturnValue("email-token"); + mocks.createSsoRelinkIntent.mockReturnValue("intent-token"); + mocks.verifySsoRelinkIntent.mockReturnValue({ + userId: "user_1", + email: "john.doe@example.com", + provider: "google", + providerAccountId: "provider-account-1", + callbackUrl: "http://localhost:3000/environments/env_1", + }); + }); + + test("preserves the recovery purpose when building the verification requested path", async () => { + vi.mocked(sendVerificationEmail).mockResolvedValue(true); + + const result = await startSsoRecovery({ + existingUser: { + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + emailVerified: null, + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + }, + provider: "google", + account: { + type: "oauth", + provider: "google", + providerAccountId: "provider-account-1", + } as any, + callbackUrl: "http://localhost:3000/environments/env_1", + }); + + expect(sendVerificationEmail).toHaveBeenCalledWith({ + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=intent-token", + purpose: "sso_recovery", + }); + expect(buildVerificationRequestedPath).toHaveBeenCalledWith({ + token: "email-token", + callbackUrl: "http://localhost:3000/api/auth/sso/recovery/complete?intent=intent-token", + purpose: "sso_recovery", + }); + expect(result).toBe("/auth/verification-requested?token=email-token&purpose=sso_recovery"); + }); + + test("records a failed recovery start when the verification email cannot be sent", async () => { + vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("smtp unavailable")); + + await expect( + startSsoRecovery({ + existingUser: { + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + emailVerified: null, + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + }, + provider: "google", + account: { + type: "oauth", + provider: "google", + providerAccountId: "provider-account-1", + } as any, + callbackUrl: "https://evil.example/phish", + }) + ).rejects.toThrow("smtp unavailable"); + + expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sso_recovery_failed", + status: "failure", + userId: "user_1", + newObject: expect.objectContaining({ + callbackUrl: "http://localhost:3000", + failureReason: "smtp unavailable", + }), + }) + ); + }); + + test("reclaims unverified local auth factors before linking SSO", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + emailVerified: null, + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + password: "hashed-password", + twoFactorEnabled: true, + twoFactorSecret: "encrypted-secret", + backupCodes: "encrypted-codes", + } as any); + + const callbackUrl = await completeSsoRecovery({ + intentToken: "test-intent", + sessionUserId: "user_1", + }); + + expect(txUserUpdate).toHaveBeenCalledWith({ + where: { + id: "user_1", + }, + data: { + backupCodes: null, + emailVerified: expect.any(Date), + password: null, + twoFactorEnabled: false, + twoFactorSecret: null, + }, + }); + expect(syncSsoIdentityForUser).toHaveBeenCalledWith({ + userId: "user_1", + provider: "google", + account: { + type: "oauth", + provider: "google", + providerAccountId: "provider-account-1", + }, + tx, + }); + expect(finalizeSuccessfulSignIn).toHaveBeenCalledWith({ + userId: "user_1", + email: "john.doe@example.com", + provider: "google", + }); + expect(callbackUrl).toBe("http://localhost:3000/environments/env_1"); + }); + + test("does not clear local auth material for already verified users", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + emailVerified: new Date("2024-01-01T00:00:00.000Z"), + isActive: true, + identityProvider: "email", + identityProviderAccountId: null, + password: "hashed-password", + twoFactorEnabled: true, + twoFactorSecret: "encrypted-secret", + backupCodes: "encrypted-codes", + } as any); + + await completeSsoRecovery({ + intentToken: "test-intent", + sessionUserId: "user_1", + }); + + expect(txUserUpdate).not.toHaveBeenCalled(); + expect(syncSsoIdentityForUser).toHaveBeenCalledOnce(); + }); + + test("rejects recovery when the signed-in user does not match the intent owner", async () => { + await expect( + completeSsoRecovery({ + intentToken: "test-intent", + sessionUserId: "user_2", + }) + ).rejects.toThrow("OAuthAccountNotLinked"); + + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(syncSsoIdentityForUser).not.toHaveBeenCalled(); + expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sso_recovery_failed", + status: "failure", + userId: "user_1", + newObject: expect.objectContaining({ + failureReason: "session_user_mismatch", + }), + }) + ); + }); + + test("rejects recovery when there is no signed-in session", async () => { + await expect( + completeSsoRecovery({ + intentToken: "test-intent", + }) + ).rejects.toThrow("OAuthAccountNotLinked"); + + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(syncSsoIdentityForUser).not.toHaveBeenCalled(); + expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sso_recovery_failed", + status: "failure", + userId: "user_1", + newObject: expect.objectContaining({ + failureReason: "missing_session", + }), + }) + ); + }); + + test("rejects recovery when the intent provider is invalid", async () => { + mocks.verifySsoRelinkIntent.mockReturnValue({ + userId: "user_1", + email: "john.doe@example.com", + provider: "unknown-provider", + providerAccountId: "provider-account-1", + callbackUrl: "http://localhost:3000/environments/env_1", + }); + + await expect( + completeSsoRecovery({ + intentToken: "test-intent", + sessionUserId: "user_1", + }) + ).rejects.toThrow("OAuthAccountNotLinked"); + + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(syncSsoIdentityForUser).not.toHaveBeenCalled(); + expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sso_recovery_failed", + status: "failure", + userId: "user_1", + newObject: expect.objectContaining({ + failureReason: "invalid_provider", + }), + }) + ); + }); + + test("rejects invalid or expired recovery intents before looking up any user", async () => { + mocks.verifySsoRelinkIntent.mockImplementation(() => { + throw new Error("expired"); + }); + + await expect( + completeSsoRecovery({ + intentToken: "expired-intent", + sessionUserId: "user_1", + }) + ).rejects.toThrow("OAuthAccountNotLinked"); + + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(syncSsoIdentityForUser).not.toHaveBeenCalled(); + expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sso_recovery_failed", + status: "failure", + userId: "unknown", + newObject: expect.objectContaining({ + failureReason: "invalid_or_expired_intent", + }), + }) + ); + }); + + test("rejects recovery when the verified user no longer matches the intended email", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "different@example.com", + locale: "en-US", + emailVerified: new Date("2024-01-01T00:00:00.000Z"), + isActive: true, + identityProvider: "google", + identityProviderAccountId: "provider-account-1", + password: null, + twoFactorEnabled: false, + twoFactorSecret: null, + backupCodes: null, + } as any); + + await expect( + completeSsoRecovery({ + intentToken: "test-intent", + sessionUserId: "user_1", + }) + ).rejects.toThrow("OAuthAccountNotLinked"); + + expect(syncSsoIdentityForUser).not.toHaveBeenCalled(); + expect(mocks.queueAuditEventBackground).toHaveBeenCalledWith( + expect.objectContaining({ + action: "sso_recovery_failed", + status: "failure", + userId: "user_1", + newObject: expect.objectContaining({ + failureReason: "user_mismatch", + }), + }) + ); + }); + + test("still completes recovery when sign-in finalization fails", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user_1", + email: "john.doe@example.com", + locale: "en-US", + emailVerified: new Date("2024-01-01T00:00:00.000Z"), + isActive: true, + identityProvider: "google", + identityProviderAccountId: "provider-account-1", + password: null, + twoFactorEnabled: false, + twoFactorSecret: null, + backupCodes: null, + } as any); + vi.mocked(finalizeSuccessfulSignIn).mockRejectedValue(new Error("tracking unavailable")); + + await expect( + completeSsoRecovery({ + intentToken: "test-intent", + sessionUserId: "user_1", + }) + ).resolves.toBe("http://localhost:3000/environments/env_1"); + + expect(syncSsoIdentityForUser).toHaveBeenCalledOnce(); + }); + + test("preserves only safe callback URLs in the failure redirect", () => { + expect(getSsoRecoveryFailureRedirectUrl("http://localhost:3000/invite?token=invite-token")).toBe( + "http://localhost:3000/auth/login?error=OAuthAccountNotLinked&callbackUrl=http%3A%2F%2Flocalhost%3A3000%2Finvite%3Ftoken%3Dinvite-token" + ); + expect(getSsoRecoveryFailureRedirectUrl("https://evil.example/phish")).toBe( + "http://localhost:3000/auth/login?error=OAuthAccountNotLinked" + ); + }); +}); diff --git a/apps/web/modules/ee/sso/lib/sso-recovery.ts b/apps/web/modules/ee/sso/lib/sso-recovery.ts new file mode 100644 index 000000000000..779fca161ae6 --- /dev/null +++ b/apps/web/modules/ee/sso/lib/sso-recovery.ts @@ -0,0 +1,359 @@ +import type { IdentityProvider, Prisma } from "@prisma/client"; +import type { Account } from "next-auth"; +import { prisma } from "@formbricks/database"; +import { logger } from "@formbricks/logger"; +import { WEBAPP_URL } from "@/lib/constants"; +import { createEmailToken, createSsoRelinkIntent, verifySsoRelinkIntent } from "@/lib/jwt"; +import { getValidatedCallbackUrl } from "@/lib/utils/url"; +import { finalizeSuccessfulSignIn } from "@/modules/auth/lib/sign-in-tracking"; +import { buildVerificationRequestedPath } from "@/modules/auth/lib/verification-links"; +import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler"; +import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; +import { sendVerificationEmail } from "@/modules/email"; +import { + LINKED_SSO_LOOKUP_SELECT, + TSsoAccountLinkInput, + TSsoLookupUser, + syncSsoIdentityForUser, +} from "./account-linking"; +import { OAUTH_ACCOUNT_NOT_LINKED_ERROR, SSO_RECOVERY_COMPLETION_PATH } from "./constants"; +import { normalizeSsoProvider } from "./provider-normalization"; + +const getSsoRecoveryLogger = ( + event: "sso_recovery_started" | "sso_recovery_completed" | "sso_recovery_failed" +) => + logger.withContext({ + event, + name: "formbricks", + }); + +const queueSsoRecoveryAuditEvent = ({ + action, + status, + userId, + email, + provider, + callbackUrl, + failureReason, +}: { + action: "sso_recovery_started" | "sso_recovery_completed" | "sso_recovery_failed"; + status: "success" | "failure"; + userId: string; + email: string; + provider: string; + callbackUrl?: string; + failureReason?: string; +}) => { + queueAuditEventBackground({ + action, + targetType: "user", + userId, + targetId: userId, + organizationId: UNKNOWN_DATA, + status, + userType: "user", + newObject: { + email, + provider, + ...(callbackUrl ? { callbackUrl } : {}), + ...(failureReason ? { failureReason } : {}), + }, + }); +}; + +const SSO_RECOVERY_USER_SELECT = { + ...LINKED_SSO_LOOKUP_SELECT, + backupCodes: true, + password: true, + twoFactorEnabled: true, + twoFactorSecret: true, +} as const; + +type TSsoRecoveryUser = Prisma.UserGetPayload<{ + select: typeof SSO_RECOVERY_USER_SELECT; +}>; + +const reclaimUnverifiedLocalAuthIfNeeded = async ({ + tx, + user, +}: { + tx: Prisma.TransactionClient; + user: TSsoRecoveryUser; +}) => { + if (user.identityProvider !== "email" || user.emailVerified) { + return; + } + + // Inbox ownership is now proven, so strip any untrusted local auth factors before the SSO + // account becomes the canonical way back in. + await tx.user.update({ + where: { + id: user.id, + }, + data: { + backupCodes: null, + emailVerified: new Date(), + password: null, + twoFactorEnabled: false, + twoFactorSecret: null, + }, + }); +}; + +const createSsoRecoveryCompletionUrl = (intentToken: string): string => { + const completionUrl = new URL(SSO_RECOVERY_COMPLETION_PATH, WEBAPP_URL); + completionUrl.searchParams.set("intent", intentToken); + + return completionUrl.toString(); +}; + +export const getSsoRecoveryFailureRedirectUrl = (callbackUrl?: string): string => { + const loginUrl = new URL("/auth/login", WEBAPP_URL); + loginUrl.searchParams.set("error", OAUTH_ACCOUNT_NOT_LINKED_ERROR); + + const validatedCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL); + if (validatedCallbackUrl) { + loginUrl.searchParams.set("callbackUrl", validatedCallbackUrl); + } + + return loginUrl.toString(); +}; + +export const startSsoRecovery = async ({ + existingUser, + provider, + account, + callbackUrl, +}: { + existingUser: TSsoLookupUser; + provider: IdentityProvider; + account: Account; + callbackUrl: string; +}): Promise => { + const originalCallbackUrl = getValidatedCallbackUrl(callbackUrl, WEBAPP_URL) ?? WEBAPP_URL; + + try { + const recoveryIntent = createSsoRelinkIntent({ + userId: existingUser.id, + email: existingUser.email, + provider, + providerAccountId: account.providerAccountId, + callbackUrl: originalCallbackUrl, + }); + const completionUrl = createSsoRecoveryCompletionUrl(recoveryIntent); + + await sendVerificationEmail({ + id: existingUser.id, + email: existingUser.email, + locale: existingUser.locale, + callbackUrl: completionUrl, + purpose: "sso_recovery", + }); + + getSsoRecoveryLogger("sso_recovery_started").info( + { + userId: existingUser.id, + provider, + callbackUrl: originalCallbackUrl, + }, + "SSO recovery started" + ); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_started", + status: "success", + userId: existingUser.id, + email: existingUser.email, + provider, + callbackUrl: originalCallbackUrl, + }); + + return buildVerificationRequestedPath({ + token: createEmailToken(existingUser.email), + callbackUrl: completionUrl, + purpose: "sso_recovery", + }); + } catch (error) { + getSsoRecoveryLogger("sso_recovery_failed").error( + { + error, + userId: existingUser.id, + provider, + callbackUrl: originalCallbackUrl, + }, + "Failed to start SSO recovery" + ); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_failed", + status: "failure", + userId: existingUser.id, + email: existingUser.email, + provider, + callbackUrl: originalCallbackUrl, + failureReason: error instanceof Error ? error.message : "unknown_error", + }); + throw error; + } +}; + +export const completeSsoRecovery = async ({ + intentToken, + sessionUserId, +}: { + intentToken: string; + sessionUserId?: string; +}): Promise => { + let intent: ReturnType; + + try { + intent = verifySsoRelinkIntent(intentToken); + } catch (error) { + getSsoRecoveryLogger("sso_recovery_failed").error({ error }, "Invalid or expired SSO recovery intent"); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_failed", + status: "failure", + userId: UNKNOWN_DATA, + email: UNKNOWN_DATA, + provider: "unknown", + failureReason: "invalid_or_expired_intent", + }); + throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + } + + const provider = normalizeSsoProvider(intent.provider); + + if (!provider) { + getSsoRecoveryLogger("sso_recovery_failed").error( + { + provider: intent.provider, + }, + "SSO recovery failed due to an invalid provider" + ); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_failed", + status: "failure", + userId: intent.userId, + email: intent.email, + provider: intent.provider, + callbackUrl: intent.callbackUrl, + failureReason: "invalid_provider", + }); + throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + } + + if (!sessionUserId) { + getSsoRecoveryLogger("sso_recovery_failed").error( + { + userId: intent.userId, + provider, + }, + "SSO recovery failed because there is no signed-in session" + ); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_failed", + status: "failure", + userId: intent.userId, + email: intent.email, + provider, + callbackUrl: intent.callbackUrl, + failureReason: "missing_session", + }); + throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + } + + if (sessionUserId !== intent.userId) { + getSsoRecoveryLogger("sso_recovery_failed").error( + { + userId: intent.userId, + provider, + sessionUserId, + }, + "SSO recovery failed because the signed-in user does not match the recovery intent" + ); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_failed", + status: "failure", + userId: intent.userId, + email: intent.email, + provider, + callbackUrl: intent.callbackUrl, + failureReason: "session_user_mismatch", + }); + throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + } + + const user = await prisma.user.findUnique({ + where: { + id: intent.userId, + }, + select: SSO_RECOVERY_USER_SELECT, + }); + + if (user?.email !== intent.email) { + getSsoRecoveryLogger("sso_recovery_failed").error( + { + userId: intent.userId, + provider: intent.provider, + }, + "SSO recovery failed due to user mismatch" + ); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_failed", + status: "failure", + userId: intent.userId, + email: intent.email, + provider: intent.provider, + callbackUrl: intent.callbackUrl, + failureReason: "user_mismatch", + }); + throw new Error(OAUTH_ACCOUNT_NOT_LINKED_ERROR); + } + + await prisma.$transaction(async (tx) => { + await reclaimUnverifiedLocalAuthIfNeeded({ + tx, + user, + }); + + const recoveryAccount: TSsoAccountLinkInput = { + type: "oauth", + provider, + providerAccountId: intent.providerAccountId, + }; + + await syncSsoIdentityForUser({ + userId: user.id, + provider, + account: recoveryAccount, + tx, + }); + }); + + try { + await finalizeSuccessfulSignIn({ + userId: user.id, + email: user.email, + provider, + }); + } catch (error) { + logger.error(error, "Failed to finalize sign-in after SSO recovery"); + } + + getSsoRecoveryLogger("sso_recovery_completed").info( + { + userId: user.id, + provider, + callbackUrl: intent.callbackUrl, + }, + "SSO recovery completed" + ); + queueSsoRecoveryAuditEvent({ + action: "sso_recovery_completed", + status: "success", + userId: user.id, + email: user.email, + provider, + callbackUrl: intent.callbackUrl, + }); + + return getValidatedCallbackUrl(intent.callbackUrl, WEBAPP_URL) ?? WEBAPP_URL; +}; diff --git a/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts b/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts index 8658f79901c7..b5501186d7f3 100644 --- a/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts +++ b/apps/web/modules/ee/sso/lib/tests/sso-handlers.test.ts @@ -1,35 +1,33 @@ import { Organization } from "@prisma/client"; import { beforeEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import type { TUser } from "@formbricks/types/user"; -import { upsertAccount } from "@/lib/account/service"; import { getIsFreshInstance } from "@/lib/instance/service"; +import { verifyInviteToken } from "@/lib/jwt"; import { createMembership } from "@/lib/membership/service"; -import { createOrganization, getOrganization } from "@/lib/organization/service"; import { capturePostHogEvent } from "@/lib/posthog"; import { findMatchingLocale } from "@/lib/utils/locale"; import { createBrevoCustomer } from "@/modules/auth/lib/brevo"; import { createUser, getUserByEmail, updateUser } from "@/modules/auth/lib/user"; -import type { TSamlNameFields } from "@/modules/auth/types/auth"; +import { getIsValidInviteToken } from "@/modules/auth/signup/lib/invite"; import { getAccessControlPermission, getIsMultiOrgEnabled, getIsSamlSsoEnabled, getIsSsoEnabled, } from "@/modules/ee/license-check/lib/utils"; +import { getFirstOrganization } from "@/modules/ee/sso/lib/organization"; +import { startSsoRecovery } from "@/modules/ee/sso/lib/sso-recovery"; import { createDefaultTeamMembership, getOrganizationByTeamId } from "@/modules/ee/sso/lib/team"; import { handleSsoCallback } from "../sso-handlers"; import { mockAccount, mockCreatedUser, - mockOpenIdAccount, mockOpenIdUser, mockOrganization, mockSamlAccount, mockUser, } from "./__mock__/sso-handlers.mock"; -// Mock all dependencies vi.mock("@/modules/auth/lib/brevo", () => ({ createBrevoCustomer: vi.fn(), })); @@ -53,13 +51,30 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({ vi.mock("@formbricks/database", () => ({ prisma: { - $transaction: vi.fn(async (callback: (tx: Record) => unknown) => await callback({})), + $transaction: vi.fn( + async (callback: (tx: any) => unknown) => + await callback({ + account: { + create: vi.fn(), + delete: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + user: { + update: vi.fn(), + }, + }) + ), account: { + create: vi.fn(), + delete: vi.fn(), findUnique: vi.fn(), + update: vi.fn(), }, user: { findFirst: vi.fn(), - count: vi.fn(), // Add count mock for user + findUnique: vi.fn(), + update: vi.fn(), }, }, })); @@ -68,688 +83,727 @@ vi.mock("@/lib/instance/service", () => ({ getIsFreshInstance: vi.fn(), })); +vi.mock("@/modules/ee/sso/lib/organization", () => ({ + getFirstOrganization: vi.fn(), +})); + vi.mock("@/modules/ee/sso/lib/team", () => ({ getOrganizationByTeamId: vi.fn(), createDefaultTeamMembership: vi.fn(), })); -vi.mock("@/lib/account/service", () => ({ - upsertAccount: vi.fn(), -})); - vi.mock("@/lib/membership/service", () => ({ createMembership: vi.fn(), })); -vi.mock("@/lib/organization/service", () => ({ - createOrganization: vi.fn(), - getOrganization: vi.fn(), -})); - vi.mock("@/lib/utils/locale", () => ({ findMatchingLocale: vi.fn(), })); -vi.mock("@formbricks/lib/jwt", () => ({ +vi.mock("@/lib/jwt", () => ({ verifyInviteToken: vi.fn(), })); +vi.mock("@/modules/ee/sso/lib/sso-recovery", () => ({ + startSsoRecovery: vi.fn(), +})); + vi.mock("@formbricks/logger", () => ({ logger: { error: vi.fn(), debug: vi.fn(), - withContext: (context: Record) => { - return { - ...context, - debug: vi.fn(), - }; - }, + withContext: vi.fn(() => ({ + debug: vi.fn(), + error: vi.fn(), + info: vi.fn(), + })), }, })); -// Mock environment variables -vi.mock("@/lib/constants", () => ({ - SKIP_INVITE_FOR_SSO: 0, - DEFAULT_TEAM_ID: "team-123", - DEFAULT_ORGANIZATION_ID: "org-123", - ENCRYPTION_KEY: "test-encryption-key-32-chars-long", - POSTHOG_KEY: undefined, -})); +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + SKIP_INVITE_FOR_SSO: 0, + DEFAULT_TEAM_ID: "team-123", + }; +}); vi.mock("@/lib/posthog", () => ({ capturePostHogEvent: vi.fn(), })); +const transactionAccount = { + create: vi.fn(), + delete: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), +}; + +const transactionUser = { + update: vi.fn(), +}; + describe("handleSsoCallback", () => { - beforeEach(async () => { + beforeEach(() => { vi.clearAllMocks(); - vi.resetModules(); - // Default mock implementations + vi.mocked(prisma.$transaction).mockImplementation( + async (callback: (tx: any) => unknown) => + await callback({ + account: transactionAccount, + user: transactionUser, + }) + ); + vi.mocked(getIsSsoEnabled).mockResolvedValue(true); vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true); vi.mocked(findMatchingLocale).mockResolvedValue("en-US"); vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(true); vi.mocked(getIsFreshInstance).mockResolvedValue(true); - - // Mock organization-related functions - vi.mocked(getOrganization).mockResolvedValue(mockOrganization); - vi.mocked(createOrganization).mockResolvedValue(mockOrganization); + vi.mocked(getUserByEmail).mockResolvedValue(null); + vi.mocked(updateUser).mockResolvedValue({ ...mockUser, id: "user-123" }); + vi.mocked(createDefaultTeamMembership).mockResolvedValue(undefined); vi.mocked(createMembership).mockResolvedValue({ role: "member", accepted: true, userId: mockUser.id, organizationId: mockOrganization.id, }); - vi.mocked(updateUser).mockResolvedValue({ ...mockUser, id: "user-123" }); - vi.mocked(createDefaultTeamMembership).mockResolvedValue(undefined); + vi.mocked(getFirstOrganization).mockResolvedValue(mockOrganization as unknown as Organization); + vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization as unknown as Organization); + vi.mocked(getAccessControlPermission).mockResolvedValue(true); + vi.mocked(startSsoRecovery).mockResolvedValue("/auth/verification-requested?token=email-token"); + vi.mocked(getIsValidInviteToken).mockResolvedValue(true); + vi.mocked(verifyInviteToken).mockReturnValue({ + email: mockUser.email, + inviteId: "invite-123", + } as any); + transactionAccount.findUnique.mockResolvedValue(null); + transactionAccount.create.mockResolvedValue(undefined); + transactionAccount.update.mockResolvedValue(undefined); + transactionAccount.delete.mockResolvedValue(undefined); + transactionUser.update.mockResolvedValue(undefined); }); - describe("Early return conditions", () => { - test("should return false if SSO is not enabled", async () => { - vi.mocked(getIsSsoEnabled).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - }); - - test("should return false if user email is missing", async () => { - const result = await handleSsoCallback({ - user: { ...mockUser, email: "" }, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - }); - - test("should return false if account type is not oauth", async () => { - const result = await handleSsoCallback({ - user: mockUser, - account: { ...mockAccount, type: "credentials" }, - callbackUrl: "http://localhost:3000", - }); + test("returns false when SSO is disabled", async () => { + vi.mocked(getIsSsoEnabled).mockResolvedValue(false); - expect(result).toBe(false); + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000", }); - test("should return false if provider is SAML and SAML SSO is not enabled", async () => { - vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockSamlAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(false); - }); + expect(result).toBe(false); }); - describe("Existing user handling", () => { - test("should return true if user with account already exists and email is the same", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue({ + test("syncs an existing canonical account link when the provider account already exists", async () => { + vi.mocked(prisma.account.findUnique) + .mockResolvedValueOnce({ + id: "account_1", + provider: "google", user: { ...mockUser, email: mockUser.email, }, - } as any); + } as any) + .mockResolvedValueOnce(null); + transactionAccount.findUnique.mockResolvedValue({ + id: "account_1", + userId: mockUser.id, + } as any); - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(prisma.account.findUnique).toHaveBeenCalledWith({ - where: { - provider_providerAccountId: { - provider: mockAccount.provider, - providerAccountId: mockAccount.providerAccountId, - }, - }, - select: { - user: { - select: { - id: true, - email: true, - locale: true, - emailVerified: true, - isActive: true, - identityProvider: true, - identityProviderAccountId: true, - }, - }, - }, - }); + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000", + }); + + expect(result).toBe(true); + expect(transactionAccount.update).toHaveBeenCalledWith({ + where: { + id: "account_1", + }, + data: {}, + }); + expect(transactionUser.update).toHaveBeenCalledWith({ + where: { + id: mockUser.id, + }, + data: { + identityProvider: "google", + identityProviderAccountId: mockAccount.providerAccountId, + }, }); + }); + + test("normalizes legacy Azure account aliases into the canonical provider id", async () => { + const azureAccount = { ...mockAccount, provider: "azuread" }; - test("should not overwrite stored tokens when the provider omits them", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue({ + vi.mocked(prisma.account.findUnique) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce({ + id: "legacy_account_1", + provider: "azure-ad", user: { ...mockUser, email: mockUser.email, }, - } as any); - - const result = await handleSsoCallback({ - user: mockUser, - account: { - ...mockAccount, - access_token: undefined, - refresh_token: undefined, - expires_at: undefined, - scope: undefined, - token_type: undefined, - id_token: undefined, - }, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(upsertAccount).toHaveBeenCalledWith( - { - userId: mockUser.id, - type: mockAccount.type, - provider: mockAccount.provider, - providerAccountId: mockAccount.providerAccountId, - }, - undefined - ); + } as any) + .mockResolvedValueOnce(null); + transactionAccount.findUnique.mockResolvedValue(null); + + const result = await handleSsoCallback({ + user: mockUser, + account: azureAccount, + callbackUrl: "http://localhost:3000", }); - test("should update user email if user with account exists but email changed", async () => { - const existingUser = { - ...mockUser, - id: "existing-user-id", - email: "old-email@example.com", - }; - - vi.mocked(prisma.account.findUnique).mockResolvedValue({ user: existingUser } as any); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(updateUser).mockResolvedValue({ ...existingUser, email: mockUser.email }); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(updateUser).toHaveBeenCalledWith(existingUser.id, { email: mockUser.email }); + expect(result).toBe(true); + expect(transactionAccount.update).toHaveBeenCalledWith({ + where: { + id: "legacy_account_1", + }, + data: { + userId: mockUser.id, + type: "oauth", + provider: "azuread", + providerAccountId: mockAccount.providerAccountId, + }, }); + }); - test("should throw error if user with account exists, email changed, and another user has the new email", async () => { - const existingUser = { + test("updates the linked SSO user email when the provider email changes without conflicts", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValueOnce({ + id: "account_1", + provider: "google", + user: { ...mockUser, - id: "existing-user-id", - email: "old-email@example.com", - }; - - vi.mocked(prisma.account.findUnique).mockResolvedValue({ user: existingUser } as any); - vi.mocked(getUserByEmail).mockResolvedValue({ - id: "another-user-id", - email: mockUser.email, - emailVerified: mockUser.emailVerified, - identityProvider: "google", - locale: mockUser.locale, - isActive: true, - }); - - await expect( - handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow( - "Looks like you updated your email somewhere else. A user with this new email exists already." - ); + id: "linked-user-1", + email: "old@example.com", + }, + } as any); + transactionAccount.findUnique.mockResolvedValue({ + id: "account_1", + userId: "linked-user-1", + } as any); + + const result = await handleSsoCallback({ + user: { + ...mockUser, + email: "new@example.com", + }, + account: mockAccount, + callbackUrl: "http://localhost:3000", }); - test("should backfill the account row for legacy linked SSO users", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - ...mockUser, + expect(result).toBe(true); + expect(transactionUser.update).toHaveBeenNthCalledWith(1, { + where: { + id: "linked-user-1", + }, + data: { + email: "new@example.com", + }, + }); + expect(transactionUser.update).toHaveBeenNthCalledWith(2, { + where: { + id: "linked-user-1", + }, + data: { identityProvider: "google", identityProviderAccountId: mockAccount.providerAccountId, - } as any); - - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(upsertAccount).toHaveBeenCalledWith( - expect.objectContaining({ - userId: mockUser.id, - provider: mockAccount.provider, - providerAccountId: mockAccount.providerAccountId, - }), - undefined - ); - }); - - test("should reject verified email users whose SSO provider is not already linked", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue({ - id: "existing-user-id", - email: mockUser.email, - emailVerified: new Date(), - identityProvider: "email", - locale: mockUser.locale, - isActive: true, - }); - - await expect( - handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow("OAuthAccountNotLinked"); - expect(upsertAccount).not.toHaveBeenCalled(); - expect(updateUser).not.toHaveBeenCalled(); - expect(createUser).not.toHaveBeenCalled(); - expect(createMembership).not.toHaveBeenCalled(); - expect(createBrevoCustomer).not.toHaveBeenCalled(); - expect(capturePostHogEvent).not.toHaveBeenCalled(); - }); - - test("should reject unverified email users whose SSO provider is not already linked", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue({ - id: "existing-user-id", - email: mockUser.email, - emailVerified: null, - identityProvider: "email", - locale: mockUser.locale, - isActive: true, - }); - - await expect( - handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow("OAuthAccountNotLinked"); - expect(upsertAccount).not.toHaveBeenCalled(); - expect(updateUser).not.toHaveBeenCalled(); - expect(createUser).not.toHaveBeenCalled(); - expect(createMembership).not.toHaveBeenCalled(); - expect(createBrevoCustomer).not.toHaveBeenCalled(); - expect(capturePostHogEvent).not.toHaveBeenCalled(); - }); - - test("should reject existing users from a different SSO provider when no link exists", async () => { - vi.mocked(prisma.account.findUnique).mockResolvedValue(null); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue({ - id: "existing-user-id", - email: mockUser.email, - emailVerified: new Date(), - identityProvider: "github", - locale: mockUser.locale, - isActive: true, - }); - - await expect( - handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow("OAuthAccountNotLinked"); - expect(upsertAccount).not.toHaveBeenCalled(); - expect(updateUser).not.toHaveBeenCalled(); - expect(createUser).not.toHaveBeenCalled(); + }, }); }); - describe("New user creation", () => { - test("should create a new user if no existing user found", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser()); - - const result = await handleSsoCallback({ - user: mockUser, + test("rejects sign-in when the provider email changes to an email that is already taken", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValueOnce({ + id: "account_1", + provider: "google", + user: { + ...mockUser, + id: "linked-user-1", + email: "old@example.com", + }, + } as any); + vi.mocked(getUserByEmail).mockResolvedValueOnce({ + ...mockUser, + id: "conflict-user-1", + email: "new@example.com", + } as any); + + await expect( + handleSsoCallback({ + user: { + ...mockUser, + email: "new@example.com", + }, account: mockAccount, callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - { - name: mockUser.name, - email: mockUser.email, - emailVerified: expect.any(Date), - identityProvider: mockAccount.provider.toLowerCase().replace("-", ""), - identityProviderAccountId: mockAccount.providerAccountId, - locale: "en-US", - }, - expect.anything() - ); - expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email }); - }); + }) + ).rejects.toThrow("Looks like you updated your email somewhere else."); - test("should capture user_signed_up PostHog event for new SSO user", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser()); + expect(transactionUser.update).not.toHaveBeenCalled(); + }); - await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); + test("backfills a canonical account row from a legacy exact user match", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + ...mockUser, + identityProvider: "google", + identityProviderAccountId: mockAccount.providerAccountId, + } as any); + transactionAccount.findUnique.mockResolvedValue(null); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000", + }); - expect(capturePostHogEvent).toHaveBeenCalledWith(mockUser.id, "user_signed_up", { - auth_provider: mockAccount.provider, - email_domain: "example.com", - signup_source: "direct", - invite_organization_id: null, - }); + expect(result).toBe(true); + expect(transactionAccount.create).toHaveBeenCalledWith({ + data: { + userId: mockUser.id, + type: "oauth", + provider: "google", + providerAccountId: mockAccount.providerAccountId, + }, + }); + expect(transactionUser.update).toHaveBeenCalledWith({ + where: { + id: mockUser.id, + }, + data: { + identityProvider: "google", + identityProviderAccountId: mockAccount.providerAccountId, + }, }); + }); - test("should capture user_signed_up with invite signup_source when callbackUrl has token", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser()); - vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization as unknown as Organization); - vi.mocked(getAccessControlPermission).mockResolvedValue(true); + test("starts inbox-based recovery for an existing same-email user without a linked account", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...mockUser, + identityProvider: "email", + identityProviderAccountId: null, + emailVerified: new Date(), + } as any); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", + }); - await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000?token=invite-token", - }); - - expect(capturePostHogEvent).toHaveBeenCalledWith( - mockUser.id, - "user_signed_up", - expect.objectContaining({ - signup_source: "invite", - }) - ); + expect(result).toBe("/auth/verification-requested?token=email-token"); + expect(startSsoRecovery).toHaveBeenCalledWith({ + existingUser: expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + }), + provider: "google", + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", }); + expect(createUser).not.toHaveBeenCalled(); + expect(createMembership).not.toHaveBeenCalled(); + expect(createBrevoCustomer).not.toHaveBeenCalled(); + expect(capturePostHogEvent).not.toHaveBeenCalled(); + }); - test("should return true when organization doesn't exist with DEFAULT_TEAM_ID", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser()); - vi.mocked(getOrganizationByTeamId).mockResolvedValue(null); + test("keeps unverified email-password users in the recovery flow instead of activating them during SSO", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...mockUser, + identityProvider: "email", + identityProviderAccountId: null, + emailVerified: null, + } as any); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", + }); - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); + expect(result).toBe("/auth/verification-requested?token=email-token"); + expect(startSsoRecovery).toHaveBeenCalledWith({ + existingUser: expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + emailVerified: null, + identityProvider: "email", + }), + provider: "google", + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", + }); + expect(createUser).not.toHaveBeenCalled(); + }); - expect(result).toBe(true); - expect(getAccessControlPermission).not.toHaveBeenCalled(); + test("starts recovery for a legacy SSO-only user when the stored provider account id is stale", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + ...mockUser, + identityProvider: "google", + identityProviderAccountId: "legacy-google-subject", + emailVerified: new Date(), + password: null, + } as any); + + const result = await handleSsoCallback({ + user: mockUser, + account: { + ...mockAccount, + provider: "google", + providerAccountId: "new-google-subject", + }, + callbackUrl: "http://localhost:3000", }); - test("should return true when organization exists but role management is not enabled", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser()); - vi.mocked(getOrganizationByTeamId).mockResolvedValue(mockOrganization as unknown as Organization); - vi.mocked(getAccessControlPermission).mockResolvedValue(false); + expect(result).toBe("/auth/verification-requested?token=email-token"); + expect(startSsoRecovery).toHaveBeenCalledWith({ + existingUser: expect.objectContaining({ + id: mockUser.id, + email: mockUser.email, + identityProvider: "google", + identityProviderAccountId: "legacy-google-subject", + }), + provider: "google", + account: expect.objectContaining({ + provider: "google", + providerAccountId: "new-google-subject", + }), + callbackUrl: "http://localhost:3000", + }); + expect(createUser).not.toHaveBeenCalled(); + }); - const result = await handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }); + test("creates a new SSO user with canonical provider state when no existing user is found", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(createUser).mockResolvedValue(mockCreatedUser()); + transactionAccount.findUnique.mockResolvedValue(null); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000", + }); - expect(result).toBe(true); - expect(createMembership).not.toHaveBeenCalled(); + expect(result).toBe(true); + expect(createUser).toHaveBeenCalledWith( + { + name: mockUser.name, + email: mockUser.email, + emailVerified: expect.any(Date), + identityProvider: "google", + identityProviderAccountId: mockAccount.providerAccountId, + locale: "en-US", + }, + expect.anything() + ); + expect(createBrevoCustomer).toHaveBeenCalledWith({ id: mockUser.id, email: mockUser.email }); + expect(capturePostHogEvent).toHaveBeenCalledWith(mockUser.id, "user_signed_up", { + auth_provider: "google", + email_domain: "example.com", + signup_source: "direct", + invite_organization_id: null, }); }); - describe("OpenID Connect name handling", () => { - test("should use oidcUser.name when available", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); + test("extracts fallback OpenID names when direct name is missing", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(createUser).mockResolvedValue(mockCreatedUser("John Doe")); + transactionAccount.findUnique.mockResolvedValue(null); - const openIdUser = mockOpenIdUser({ - name: "Direct Name", - given_name: "John", - family_name: "Doe", - }); + const openIdUser = mockOpenIdUser({ + given_name: "John", + family_name: "Doe", + }); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("Direct Name")); + await handleSsoCallback({ + user: openIdUser, + account: { ...mockAccount, provider: "openid" }, + callbackUrl: "http://localhost:3000", + }); - const result = await handleSsoCallback({ - user: openIdUser, - account: mockOpenIdAccount, - callbackUrl: "http://localhost:3000", - }); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ + name: "John Doe", + identityProvider: "openid", + }), + expect.anything() + ); + }); - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "Direct Name", - }), - expect.anything() - ); + test("extracts the preferred OpenID username when no other name fields are present", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(createUser).mockResolvedValue(mockCreatedUser("oidc-handle")); + transactionAccount.findUnique.mockResolvedValue(null); + + await handleSsoCallback({ + user: mockOpenIdUser({ + preferred_username: "oidc-handle", + }), + account: { ...mockAccount, provider: "openid" }, + callbackUrl: "http://localhost:3000", }); - test("should use given_name + family_name when name is not available", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ + name: "oidc-handle", + identityProvider: "openid", + }), + expect.anything() + ); + }); - const openIdUser = mockOpenIdUser({ - name: undefined, - given_name: "John", - family_name: "Doe", - }); + test("extracts fallback SAML names when the display name is missing", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(createUser).mockResolvedValue(mockCreatedUser("Saml User")); + transactionAccount.findUnique.mockResolvedValue(null); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("John Doe")); + await handleSsoCallback({ + user: { + ...mockUser, + name: "", + firstName: "Saml", + lastName: "User", + } as any, + account: mockSamlAccount, + callbackUrl: "http://localhost:3000", + }); - const result = await handleSsoCallback({ - user: openIdUser, - account: mockOpenIdAccount, - callbackUrl: "http://localhost:3000", - }); + expect(createUser).toHaveBeenCalledWith( + expect.objectContaining({ + name: "Saml User", + identityProvider: "saml", + }), + expect.anything() + ); + }); - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "John Doe", - }), - expect.anything() - ); + test("rejects new SSO sign-up when invite validation requires a callback URL and none is provided", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "", }); - test("should use preferred_username when name and given_name/family_name are not available", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - - const openIdUser = mockOpenIdUser({ - name: undefined, - given_name: undefined, - family_name: undefined, - preferred_username: "preferred.user", - }); + expect(result).toBe(false); + expect(createUser).not.toHaveBeenCalled(); + }); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("preferred.user")); + test("rejects sign-in callback URLs that claim a signin source without an invite token", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000/auth/login?source=signin", + }); - const result = await handleSsoCallback({ - user: openIdUser, - account: mockOpenIdAccount, - callbackUrl: "http://localhost:3000", - }); - - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "preferred.user", - }), - expect.anything() - ); - }); - - test("should fallback to email username when no OIDC name fields are available", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - - const openIdUser = mockOpenIdUser({ - name: undefined, - given_name: undefined, - family_name: undefined, - preferred_username: undefined, - email: "test.user@example.com", - }); - - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("test user")); - - const result = await handleSsoCallback({ - user: openIdUser, - account: mockOpenIdAccount, - callbackUrl: "http://localhost:3000", - }); + expect(result).toBe(false); + expect(createUser).not.toHaveBeenCalled(); + }); - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "test user", - }), - expect.anything() - ); + test("rejects invite tokens that belong to a different email address", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + vi.mocked(verifyInviteToken).mockReturnValue({ + email: "someone-else@example.com", + inviteId: "invite-123", + } as any); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", }); + + expect(result).toBe(false); + expect(getIsValidInviteToken).not.toHaveBeenCalled(); }); - describe("SAML name handling", () => { - test("should use samlUser.name when available", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); + test("rejects invalid or expired invite tokens during new SSO sign-up", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + vi.mocked(getIsValidInviteToken).mockResolvedValue(false); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", + }); - const samlUser = { - ...mockUser, - name: "Direct Name", - firstName: "John", - lastName: "Doe", - } as TUser & TSamlNameFields; + expect(result).toBe(false); + expect(createUser).not.toHaveBeenCalled(); + }); - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("Direct Name")); + test("rejects malformed callback URLs during invite validation", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "not-a-valid-url", + }); - const result = await handleSsoCallback({ - user: samlUser, - account: mockSamlAccount, - callbackUrl: "http://localhost:3000", - }); + expect(result).toBe(false); + expect(createUser).not.toHaveBeenCalled(); + }); - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "Direct Name", - }), - expect.anything() - ); + test("rejects new SSO sign-up when no organization can be assigned", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + vi.mocked(getFirstOrganization).mockResolvedValue(null); + + const result = await handleSsoCallback({ + user: mockUser, + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", }); - test("should use firstName + lastName when name is not available", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); + expect(result).toBe(false); + expect(createUser).not.toHaveBeenCalled(); + }); - const samlUser = { + test("assigns invited SSO users into the resolved organization and syncs notification settings", async () => { + vi.mocked(prisma.account.findUnique).mockResolvedValue(null); + vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + vi.mocked(getIsFreshInstance).mockResolvedValue(false); + vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + vi.mocked(verifyInviteToken).mockReturnValue({ + email: "invited@example.com", + inviteId: "invite-123", + } as any); + vi.mocked(createUser).mockResolvedValue( + mockCreatedUser("Org User") as typeof mockUser & { + notificationSettings: { alert: Record; unsubscribedOrganizationIds: string[] }; + } + ); + transactionAccount.findUnique.mockResolvedValue(null); + + const result = await handleSsoCallback({ + user: { ...mockUser, - name: "", - firstName: "John", - lastName: "Doe", - } as TUser & TSamlNameFields; - - vi.mocked(createUser).mockResolvedValue(mockCreatedUser("John Doe")); - - const result = await handleSsoCallback({ - user: samlUser, - account: mockSamlAccount, - callbackUrl: "http://localhost:3000", - }); + email: "invited@example.com", + }, + account: mockAccount, + callbackUrl: "http://localhost:3000/invite?token=invite-token", + }); - expect(result).toBe(true); - expect(createUser).toHaveBeenCalledWith( - expect.objectContaining({ - name: "John Doe", - }), - expect.anything() - ); + expect(result).toBe(true); + expect(createMembership).toHaveBeenCalledWith( + mockOrganization.id, + mockUser.id, + { role: "member", accepted: true }, + expect.anything() + ); + expect(updateUser).toHaveBeenCalledWith( + mockUser.id, + { + notificationSettings: { + alert: {}, + unsubscribedOrganizationIds: [mockOrganization.id], + }, + }, + expect.anything() + ); + expect(capturePostHogEvent).toHaveBeenCalledWith(mockUser.id, "user_signed_up", { + auth_provider: "google", + email_domain: "example.com", + signup_source: "invite", + invite_organization_id: mockOrganization.id, }); }); - describe("Auto-provisioning and invite handling", () => { - test("should return false when auto-provisioning is disabled and no callback URL or multi-org", async () => { - vi.resetModules(); + test("rejects unsupported providers before any database writes happen", async () => { + const result = await handleSsoCallback({ + user: mockUser, + account: { + ...mockAccount, + provider: "twitter", + } as any, + callbackUrl: "http://localhost:3000", + }); - vi.mocked(getIsFreshInstance).mockResolvedValue(false); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(getIsMultiOrgEnabled).mockResolvedValue(false); + expect(result).toBe(false); + expect(prisma.account.findUnique).not.toHaveBeenCalled(); + expect(createUser).not.toHaveBeenCalled(); + }); - const result = await handleSsoCallback({ - user: mockUser, + test("rejects non-oauth accounts and users without an email address", async () => { + await expect( + handleSsoCallback({ + user: { + ...mockUser, + email: "", + }, account: mockAccount, - callbackUrl: "", - }); + callbackUrl: "http://localhost:3000", + }) + ).resolves.toBe(false); - expect(result).toBe(false); - }); + await expect( + handleSsoCallback({ + user: mockUser, + account: { + ...mockAccount, + type: "email", + } as any, + callbackUrl: "http://localhost:3000", + }) + ).resolves.toBe(false); }); - describe("Error handling", () => { - test("should handle database errors", async () => { - vi.mocked(prisma.user.findFirst).mockRejectedValue(new Error("Database error")); + test("rejects SAML sign-in when the license is disabled", async () => { + vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false); - await expect( - handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow("Database error"); + const result = await handleSsoCallback({ + user: mockUser, + account: mockSamlAccount, + callbackUrl: "http://localhost:3000", }); - test("should handle locale finding errors", async () => { - vi.mocked(findMatchingLocale).mockRejectedValue(new Error("Locale error")); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - - await expect( - handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow("Locale error"); - }); - - test("should not trigger signup side effects when transactional provisioning fails", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - vi.mocked(getUserByEmail).mockResolvedValue(null); - vi.mocked(createUser).mockRejectedValue(new Error("Create user failed")); - - await expect( - handleSsoCallback({ - user: mockUser, - account: mockAccount, - callbackUrl: "http://localhost:3000", - }) - ).rejects.toThrow("Create user failed"); - - expect(createBrevoCustomer).not.toHaveBeenCalled(); - expect(capturePostHogEvent).not.toHaveBeenCalled(); - }); + expect(result).toBe(false); }); }); diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx index c52e0e76bd49..91a34f5cde0d 100644 --- a/apps/web/modules/email/index.tsx +++ b/apps/web/modules/email/index.tsx @@ -38,11 +38,17 @@ import { WEBAPP_URL, } from "@/lib/constants"; import { getPublicDomain } from "@/lib/getPublicUrl"; -import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt"; +import { + createEmailChangeToken, + createEmailToken, + createInviteToken, + createToken, + createTokenForLinkSurvey, +} from "@/lib/jwt"; import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getElementResponseMapping } from "@/lib/responses"; import { getTranslate } from "@/lingodotdev/server"; -import { buildVerificationLinks } from "@/modules/auth/lib/verification-links"; +import { TVerificationRequestPurpose, buildVerificationLinks } from "@/modules/auth/lib/verification-links"; import { resolveStorageUrl } from "@/modules/storage/utils"; export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT); @@ -128,21 +134,26 @@ export const sendVerificationEmail = async ({ email, locale, callbackUrl, + purpose = "email_verification", }: { id: string; email: TUserEmail; locale: TUserLocale; callbackUrl?: string; + purpose?: TVerificationRequestPurpose; }): Promise => { try { const t = await getTranslate(locale); const token = createToken(id, { expiresIn: "1d", + purpose, }); const { verifyLink, verificationRequestLink } = buildVerificationLinks({ token, webAppUrl: WEBAPP_URL, callbackUrl, + purpose, + verificationRequestToken: createEmailToken(email), }); const html = await renderVerificationEmail({ diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index b13405be7358..84e0e58113d3 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -7,10 +7,40 @@ const jiti = createJiti(fileURLToPath(import.meta.url)); const require = createRequire(import.meta.url); jiti("./lib/env"); +const LOOPBACK_HOSTS = ["localhost", "127.0.0.1"]; +const LOOPBACK_WILDCARD_ORIGINS = LOOPBACK_HOSTS.map((host) => `http://${host}:*`); + +const getLoopbackOriginVariants = (value) => { + if (!value) { + return []; + } + + try { + const url = new URL(value); + + if (!["http:", "https:"].includes(url.protocol) || !LOOPBACK_HOSTS.includes(url.hostname)) { + return []; + } + + const portSuffix = url.port ? `:${url.port}` : ""; + const alternateHost = url.hostname === "localhost" ? "127.0.0.1" : "localhost"; + + return [ + `${url.protocol}//${url.hostname}${portSuffix}`, + `${url.protocol}//${alternateHost}${portSuffix}`, + ]; + } catch { + return []; + } +}; + +const getUniqueValues = (values) => [...new Set(values.filter(Boolean))]; + /** @type {import('next').NextConfig} */ const nextConfig = { assetPrefix: process.env.ASSET_PREFIX_URL || undefined, + allowedDevOrigins: process.env.NODE_ENV === "production" ? undefined : LOOPBACK_HOSTS, basePath: process.env.BASE_PATH || undefined, output: "standalone", poweredByHeader: false, @@ -75,6 +105,10 @@ const nextConfig = { protocol: "http", hostname: "localhost", }, + { + protocol: "http", + hostname: "127.0.0.1", + }, { protocol: "https", hostname: "app.formbricks.com", @@ -157,8 +191,18 @@ const nextConfig = { async headers() { const isProduction = process.env.NODE_ENV === "production"; const scriptSrcUnsafeEval = isProduction ? "" : " 'unsafe-eval'"; + const allowLoopbackSources = !isProduction || process.env.E2E_TESTING === "1"; + const devLoopbackSources = allowLoopbackSources + ? getUniqueValues([ + ...LOOPBACK_WILDCARD_ORIGINS, + ...getLoopbackOriginVariants(process.env.WEBAPP_URL), + ...getLoopbackOriginVariants(process.env.NEXTAUTH_URL), + ...getLoopbackOriginVariants(process.env.S3_ENDPOINT_URL), + ]) + : []; + const devLoopbackSourceList = devLoopbackSources.length > 0 ? ` ${devLoopbackSources.join(" ")}` : ""; - const cspBase = `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data: http://localhost:9000 https:; font-src 'self' data: https:; connect-src 'self' http://localhost:9000 https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`; + const cspBase = `default-src 'self'; script-src 'self' 'unsafe-inline'${scriptSrcUnsafeEval} https:; style-src 'self' 'unsafe-inline' https:; img-src 'self' blob: data:${devLoopbackSourceList} https:; font-src 'self' data: https:; connect-src 'self'${devLoopbackSourceList} https: wss:; frame-src 'self' https://app.cal.com https:; media-src 'self' https:; object-src 'self' data: https:; base-uri 'self'; form-action 'self'`; return [ { @@ -497,5 +541,4 @@ const sentryOptions = { // Runtime Sentry reporting still depends on DSN being set via environment variables const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig; - export default exportConfig; diff --git a/apps/web/playwright/lib/utils.ts b/apps/web/playwright/lib/utils.ts index ddefedf04544..50d16d8c0fda 100644 --- a/apps/web/playwright/lib/utils.ts +++ b/apps/web/playwright/lib/utils.ts @@ -1,17 +1,73 @@ import { Page } from "@playwright/test"; import { UsersFixture } from "../fixtures/users"; +type EnvironmentSurveyLocation = { + environmentId: string; + isSurveyList: boolean; +}; + +const getEnvironmentSurveyLocation = (url: URL): EnvironmentSurveyLocation | null => { + const segments = url.pathname.split("/").filter(Boolean); + + if (segments.length !== 2 && segments.length !== 3) { + return null; + } + + if (segments[0] !== "environments") { + return null; + } + + const environmentId = segments[1]; + + if (!environmentId) { + return null; + } + + if (segments.length === 2) { + return { + environmentId, + isSurveyList: false, + }; + } + + if (segments[2] !== "surveys") { + return null; + } + + return { + environmentId, + isSurveyList: true, + }; +}; + +export async function gotoSurveyList(page: Page): Promise { + await page.waitForURL((url) => getEnvironmentSurveyLocation(url) !== null, { timeout: 30000 }); + + const currentUrl = new URL(page.url()); + const location = getEnvironmentSurveyLocation(currentUrl); + + if (!location) { + throw new Error(`Unable to determine environmentId from URL: ${page.url()}`); + } + + const { environmentId, isSurveyList } = location; + + if (!isSurveyList) { + await page.goto(`/environments/${environmentId}/surveys`, { waitUntil: "domcontentloaded" }); + } + + await page.waitForURL((url) => getEnvironmentSurveyLocation(url)?.isSurveyList === true, { + timeout: 30000, + }); + + return environmentId; +} + export async function loginAndGetApiKey(page: Page, users: UsersFixture) { const user = await users.create(); await user.login(); - await page.waitForURL(/\/environments\/[^/]+\/surveys/, { timeout: 30000 }); - - const environmentId = - /\/environments\/([^/]+)\/surveys/.exec(page.url())?.[1] ?? - (() => { - throw new Error("Unable to parse environmentId from URL"); - })(); + const environmentId = await gotoSurveyList(page); await page.goto(`/environments/${environmentId}/settings/api-keys`, { waitUntil: "domcontentloaded" }); diff --git a/apps/web/playwright/organization.spec.ts b/apps/web/playwright/organization.spec.ts index 7b784757e4f1..eaece2e14663 100644 --- a/apps/web/playwright/organization.spec.ts +++ b/apps/web/playwright/organization.spec.ts @@ -1,5 +1,6 @@ import { expect } from "playwright/test"; import { test } from "./lib/fixtures"; +import { gotoSurveyList } from "./lib/utils"; import { invites } from "./utils/mock"; test.describe("Invite, accept and remove organization member", async () => { @@ -7,7 +8,7 @@ test.describe("Invite, accept and remove organization member", async () => { const user = await users.create(); await user.login(); - await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await gotoSurveyList(page); }); test("Invite organization member", async ({ page }) => { diff --git a/apps/web/playwright/storage-smoke.spec.ts b/apps/web/playwright/storage-smoke.spec.ts new file mode 100644 index 000000000000..19fb3bb3f9fd --- /dev/null +++ b/apps/web/playwright/storage-smoke.spec.ts @@ -0,0 +1,39 @@ +import { expect } from "@playwright/test"; +import { test } from "./lib/fixtures"; +import { gotoSurveyList } from "./lib/utils"; +import { fillRichTextEditor, uploadImageChoicesForPictureSelection } from "./utils/helper"; + +const firstPictureChoiceAltPrefix = "playwright-choice-1--fid--"; +const secondPictureChoiceAltPrefix = "playwright-choice-2--fid--"; + +test.describe("Storage Smoke @storage-smoke", () => { + test.setTimeout(1000 * 60 * 3); + + test("uploads picture selection images against real storage", async ({ page, users }) => { + const user = await users.create(); + await user.login(); + + await gotoSurveyList(page); + await page.getByText("Start from scratch").click(); + await page.getByRole("button", { name: "Create survey", exact: true }).click(); + await page.waitForURL(/\/environments\/[^/]+\/surveys\/[^/]+\/edit$/); + + await fillRichTextEditor(page, "Question*", "Storage smoke question"); + + const addBlock = "Add BlockChoose the first question on your Block"; + await page + .locator("div") + .filter({ hasText: new RegExp(`^${addBlock}$`) }) + .nth(1) + .click(); + await page.getByRole("button", { name: "Picture Selection" }).click(); + await fillRichTextEditor(page, "Question*", "Storage smoke picture choice"); + await page.getByRole("button", { name: "Add description" }).click(); + await fillRichTextEditor(page, "Description", "Storage smoke description"); + + await uploadImageChoicesForPictureSelection(page); + + await expect(page.locator(`img[alt^="${firstPictureChoiceAltPrefix}"]`)).toHaveCount(1); + await expect(page.locator(`img[alt^="${secondPictureChoiceAltPrefix}"]`)).toHaveCount(1); + }); +}); diff --git a/apps/web/playwright/survey-follow-up.spec.ts b/apps/web/playwright/survey-follow-up.spec.ts index 3b4abfb33bf3..1bfed54ec98c 100644 --- a/apps/web/playwright/survey-follow-up.spec.ts +++ b/apps/web/playwright/survey-follow-up.spec.ts @@ -1,5 +1,6 @@ import { expect } from "@playwright/test"; import { test } from "./lib/fixtures"; +import { gotoSurveyList } from "./lib/utils"; test.describe("Survey Follow-Up Create & Edit", async () => { // 3 minutes @@ -9,7 +10,7 @@ test.describe("Survey Follow-Up Create & Edit", async () => { const user = await users.create(); await user.login(); - await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await gotoSurveyList(page); await test.step("Create a new survey", async () => { await page.getByText("Start from scratch").click(); diff --git a/apps/web/playwright/survey.spec.ts b/apps/web/playwright/survey.spec.ts index 98c9caf88b95..752efac661ce 100644 --- a/apps/web/playwright/survey.spec.ts +++ b/apps/web/playwright/survey.spec.ts @@ -1,8 +1,9 @@ -import { expect } from "@playwright/test"; +import { type Locator, expect } from "@playwright/test"; import { surveys } from "@/playwright/utils/mock"; import { test } from "./lib/fixtures"; +import { gotoSurveyList } from "./lib/utils"; import * as helper from "./utils/helper"; -import { createSurvey, createSurveyWithLogic, uploadFileForFileUploadQuestion } from "./utils/helper"; +import { createSurvey, createSurveyWithLogic, uploadImageChoicesForPictureSelection } from "./utils/helper"; test.use({ launchOptions: { @@ -10,6 +11,19 @@ test.use({ }, }); +test.beforeEach(async ({ page }) => { + await helper.mockStorageUploads(page); +}); + +const firstPictureChoiceAlt = "logo-transparent.png"; +const secondPictureChoiceAlt = "android-chrome-192x192.png"; + +const selectPictureChoice = async (pictureSelectQuestion: Locator, choiceAlt: string) => { + const choiceImage = pictureSelectQuestion.getByRole("img", { name: choiceAlt }); + await expect(choiceImage).toBeVisible(); + await choiceImage.click(); +}; + test.describe("Survey Create & Submit Response without logic", async () => { // 5 minutes test.setTimeout(1000 * 60 * 5); @@ -20,7 +34,7 @@ test.describe("Survey Create & Submit Response without logic", async () => { const user = await users.create(); await user.login(); - await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await gotoSurveyList(page); await test.step("Create Survey", async () => { await createSurvey(page, surveys.createAndSubmit); @@ -156,12 +170,13 @@ test.describe("Survey Create & Submit Response without logic", async () => { // Picture Select Question await expect(page.getByText(surveys.createAndSubmit.pictureSelectQuestion.question)).toBeVisible(); await expect(page.getByText(surveys.createAndSubmit.pictureSelectQuestion.description)).toBeVisible(); - await expect(page.locator("#questionCard-7").getByRole("button", { name: "Next" })).toBeVisible(); - await expect(page.locator("#questionCard-7").getByRole("button", { name: "Back" })).toBeVisible(); - await expect(page.getByRole("img", { name: "puppy-1-small.jpg" })).toBeVisible(); - await expect(page.getByRole("img", { name: "puppy-2-small.jpg" })).toBeVisible(); - await page.getByRole("img", { name: "puppy-1-small.jpg" }).click(); - await page.locator("#questionCard-7").getByRole("button", { name: "Next" }).click(); + const pictureSelectQuestion = page.locator("#questionCard-7"); + await expect(pictureSelectQuestion.getByRole("button", { name: "Next" })).toBeVisible(); + await expect(pictureSelectQuestion.getByRole("button", { name: "Back" })).toBeVisible(); + await expect(pictureSelectQuestion.getByRole("img", { name: firstPictureChoiceAlt })).toBeVisible(); + await expect(pictureSelectQuestion.getByRole("img", { name: secondPictureChoiceAlt })).toBeVisible(); + await selectPictureChoice(pictureSelectQuestion, firstPictureChoiceAlt); + await pictureSelectQuestion.getByRole("button", { name: "Next" }).click(); // File Upload Question await expect(page.getByText(surveys.createAndSubmit.fileUploadQuestion.question)).toBeVisible(); @@ -245,7 +260,7 @@ test.describe("Multi Language Survey Create", async () => { const user = await users.create(); await user.login(); - await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await gotoSurveyList(page); // Add workspace languages (English + German) await page.getByRole("link", { name: "Configuration" }).click(); @@ -304,8 +319,7 @@ test.describe("Multi Language Survey Create", async () => { surveys.createAndSubmit.pictureSelectQuestion.question ); - // Handle file uploads - await uploadFileForFileUploadQuestion(page); + await uploadImageChoicesForPictureSelection(page); await page .locator("div") @@ -710,7 +724,7 @@ test.describe("Testing Survey with advanced logic", async () => { const user = await users.create(); await user.login(); - await page.waitForURL(/\/environments\/[^/]+\/surveys/); + await gotoSurveyList(page); await test.step("Create Survey", async () => { await createSurveyWithLogic(page, surveys.createWithLogicAndSubmit); @@ -810,12 +824,13 @@ test.describe("Testing Survey with advanced logic", async () => { await expect( page.getByText(surveys.createWithLogicAndSubmit.pictureSelectQuestion.description) ).toBeVisible(); - await expect(page.locator("#questionCard-3").getByRole("button", { name: "Next" })).toBeVisible(); - await expect(page.locator("#questionCard-3").getByRole("button", { name: "Back" })).toBeVisible(); - await expect(page.getByRole("img", { name: "puppy-1-small.jpg" })).toBeVisible(); - await expect(page.getByRole("img", { name: "puppy-2-small.jpg" })).toBeVisible(); - await page.getByRole("img", { name: "puppy-1-small.jpg" }).click(); - await page.locator("#questionCard-3").getByRole("button", { name: "Next" }).click(); + const pictureSelectQuestion = page.locator("#questionCard-3"); + await expect(pictureSelectQuestion.getByRole("button", { name: "Next" })).toBeVisible(); + await expect(pictureSelectQuestion.getByRole("button", { name: "Back" })).toBeVisible(); + await expect(pictureSelectQuestion.getByRole("img", { name: firstPictureChoiceAlt })).toBeVisible(); + await expect(pictureSelectQuestion.getByRole("img", { name: secondPictureChoiceAlt })).toBeVisible(); + await selectPictureChoice(pictureSelectQuestion, firstPictureChoiceAlt); + await pictureSelectQuestion.getByRole("button", { name: "Next" }).click(); // Rating Question await expect(page.getByText(surveys.createWithLogicAndSubmit.ratingQuestion.question)).toBeVisible(); diff --git a/apps/web/playwright/utils/helper.ts b/apps/web/playwright/utils/helper.ts index 9bdef0fdef79..af8b1c1d90b9 100644 --- a/apps/web/playwright/utils/helper.ts +++ b/apps/web/playwright/utils/helper.ts @@ -1,10 +1,170 @@ import { expect } from "@playwright/test"; import { readFileSync, writeFileSync } from "fs"; +import { resolve } from "node:path"; import { Page } from "playwright"; import { logger } from "@formbricks/logger"; import { TProjectConfigChannel } from "@formbricks/types/project"; import { CreateSurveyParams, CreateSurveyWithLogicParams } from "@/playwright/utils/mock"; +const MOCK_STORAGE_UPLOAD_PATH = "/__playwright__/mock-storage-upload"; +const MOCK_STORAGE_FILE_PATH = "/storage/playwright-mock"; + +type MockStorageFileFixture = { + name: string; + mimeType: string; + buffer: Buffer; + publicAssetPath?: string; +}; + +export const PLAYWRIGHT_PICTURE_SELECTION_FILES: MockStorageFileFixture[] = [ + { + name: "playwright-choice-1.png", + mimeType: "image/png", + buffer: readFileSync(resolve(process.cwd(), "apps/web/public/logo-transparent.png")), + publicAssetPath: "/logo-transparent.png", + }, + { + name: "playwright-choice-2.png", + mimeType: "image/png", + buffer: readFileSync(resolve(process.cwd(), "apps/web/public/favicon/android-chrome-192x192.png")), + publicAssetPath: "/favicon/android-chrome-192x192.png", + }, +]; + +const PLAYWRIGHT_STORAGE_FILE_FIXTURES = new Map( + PLAYWRIGHT_PICTURE_SELECTION_FILES.map((file) => [file.name, file] as const) +); + +const DEFAULT_MOCK_STORAGE_FILE_FIXTURE: MockStorageFileFixture = { + name: "mock-file.svg", + mimeType: "image/svg+xml", + buffer: Buffer.from( + ``, + "utf8" + ), +}; + +const getMockStorageFileUrl = ( + appOrigin: string, + fileName: string, + accessType: "public" | "private" +): string => { + if (accessType === "public") { + const fixture = PLAYWRIGHT_STORAGE_FILE_FIXTURES.get(fileName); + + if (fixture?.publicAssetPath) { + return new URL(fixture.publicAssetPath, appOrigin).toString(); + } + } + + return `${MOCK_STORAGE_FILE_PATH}/${accessType}/${encodeURIComponent(fileName)}`; +}; + +/** + * Survey builder E2E tests exercise survey authoring and response flows. + * They are not the right place to depend on browser reachability to a real object-storage sidecar, + * especially when some CI browsers run remotely. Mock the storage boundary so these tests stay scoped + * to survey behavior, while real storage compatibility is covered by dedicated smoke/integration checks. + */ +export const mockStorageUploads = async (page: Page): Promise => { + await page.route("**/api/v1/management/storage", async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + + const payload = route.request().postDataJSON() as { fileName?: string } | undefined; + const fileName = payload?.fileName ?? "uploaded-file.bin"; + const appOrigin = new URL(route.request().url()).origin; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + signedUrl: `${appOrigin}${MOCK_STORAGE_UPLOAD_PATH}/${encodeURIComponent(fileName)}`, + presignedFields: { + key: fileName, + }, + fileUrl: getMockStorageFileUrl(appOrigin, fileName, "public"), + signingData: null, + updatedFileName: fileName, + }, + }), + }); + }); + + await page.route( + (url) => { + const pathname = url.pathname; + const segments = pathname.split("/").filter(Boolean); + + return ( + segments.length === 5 && + segments[0] === "api" && + segments[1] === "v1" && + segments[2] === "client" && + segments[4] === "storage" + ); + }, + async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + + const payload = route.request().postDataJSON() as { fileName?: string } | undefined; + const fileName = payload?.fileName ?? "uploaded-file.bin"; + const appOrigin = new URL(route.request().url()).origin; + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + data: { + signedUrl: `${appOrigin}${MOCK_STORAGE_UPLOAD_PATH}/${encodeURIComponent(fileName)}`, + presignedFields: { + key: fileName, + }, + fileUrl: getMockStorageFileUrl(appOrigin, fileName, "private"), + signingData: null, + updatedFileName: fileName, + }, + }), + }); + } + ); + + await page.route(`**${MOCK_STORAGE_UPLOAD_PATH}/**`, async (route) => { + if (route.request().method() !== "POST") { + await route.fallback(); + return; + } + + await route.fulfill({ + status: 201, + contentType: "application/xml", + body: `${MOCK_STORAGE_UPLOAD_PATH}`, + }); + }); + + await page.route(`**${MOCK_STORAGE_FILE_PATH}/**`, async (route) => { + if (!["GET", "HEAD"].includes(route.request().method())) { + await route.fallback(); + return; + } + + const fileName = decodeURIComponent(route.request().url().split("/").pop() ?? ""); + const fixture = PLAYWRIGHT_STORAGE_FILE_FIXTURES.get(fileName) ?? DEFAULT_MOCK_STORAGE_FILE_FIXTURE; + + await route.fulfill({ + status: 200, + contentType: fixture.mimeType, + body: route.request().method() === "HEAD" ? "" : fixture.buffer, + }); + }); +}; + export const signUpAndLogin = async ( page: Page, name: string, @@ -76,28 +236,22 @@ export const apiLogin = async (page: Page, email: string, password: string) => { }); }; -export const uploadFileForFileUploadQuestion = async (page: Page) => { +export const waitForPendingFileUploads = async (page: Page): Promise => { + await expect(page.locator("svg.animate-spin.text-slate-700")).toHaveCount(0, { timeout: 60000 }); + await expect(page.getByText("Some files failed to upload")).toHaveCount(0); + await expect(page.getByText("No files were uploaded")).toHaveCount(0); + await expect(page.getByText("Invalid file name, please rename your file and try again")).toHaveCount(0); +}; + +export const uploadImageChoicesForPictureSelection = async (page: Page) => { + const fileInput = page.locator('input[type="file"]'); + await fileInput.setInputFiles(PLAYWRIGHT_PICTURE_SELECTION_FILES); + try { - const fileInput = page.locator('input[type="file"]'); - const response1 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg"); - const response2 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg"); - const buffer1 = Buffer.from(await response1.arrayBuffer()); - const buffer2 = Buffer.from(await response2.arrayBuffer()); - - await fileInput.setInputFiles([ - { - name: "puppy-1-small.jpg", - mimeType: "image/jpeg", - buffer: buffer1, - }, - { - name: "puppy-2-small.jpg", - mimeType: "image/jpeg", - buffer: buffer2, - }, - ]); + await waitForPendingFileUploads(page); } catch (error) { - logger.error(error, "Error uploading files"); + logger.error(error, "Error waiting for file uploads to finish"); + throw error; } }; @@ -319,8 +473,7 @@ export const createSurvey = async (page: Page, params: CreateSurveyParams) => { await page.getByRole("button", { name: "Add description" }).click(); await fillRichTextEditor(page, "Description", params.pictureSelectQuestion.description); - // Handle file uploads - await uploadFileForFileUploadQuestion(page); + await uploadImageChoicesForPictureSelection(page); // File Upload Question await page @@ -490,24 +643,7 @@ export const createSurveyWithLogic = async (page: Page, params: CreateSurveyWith await fillRichTextEditor(page, "Question*", params.pictureSelectQuestion.question); await page.getByRole("button", { name: "Add description" }).click(); await fillRichTextEditor(page, "Description", params.pictureSelectQuestion.description); - const fileInput = page.locator('input[type="file"]'); - const response1 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-1-small.jpg"); - const response2 = await fetch("https://formbricks-cdn.s3.eu-central-1.amazonaws.com/puppy-2-small.jpg"); - const buffer1 = Buffer.from(await response1.arrayBuffer()); - const buffer2 = Buffer.from(await response2.arrayBuffer()); - - await fileInput.setInputFiles([ - { - name: "puppy-1-small.jpg", - mimeType: "image/jpeg", - buffer: buffer1, - }, - { - name: "puppy-2-small.jpg", - mimeType: "image/jpeg", - buffer: buffer2, - }, - ]); + await uploadImageChoicesForPictureSelection(page); // Rating Question await page diff --git a/apps/web/vitestSetup.ts b/apps/web/vitestSetup.ts index 98412cdc8422..33692d54e7a8 100644 --- a/apps/web/vitestSetup.ts +++ b/apps/web/vitestSetup.ts @@ -184,64 +184,69 @@ export const testInputValidation = async (service: Function, ...args: any[]): Pr }, 15000); }; -vi.mock("@/lib/constants", () => ({ - IS_FORMBRICKS_CLOUD: false, - ENCRYPTION_KEY: "mock-encryption-key", - ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", - GITHUB_ID: "mock-github-id", - GITHUB_SECRET: "test-githubID", - GOOGLE_CLIENT_ID: "test-google-client-id", - GOOGLE_CLIENT_SECRET: "test-google-client-secret", - AZUREAD_CLIENT_ID: "test-azuread-client-id", - AZUREAD_CLIENT_SECRET: "test-azure", - AZUREAD_TENANT_ID: "test-azuread-tenant-id", - OIDC_DISPLAY_NAME: "test-oidc-display-name", - OIDC_CLIENT_ID: "test-oidc-client-id", - OIDC_ISSUER: "test-oidc-issuer", - OIDC_CLIENT_SECRET: "test-oidc-client-secret", - OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", - WEBAPP_URL: "https://test-webapp-url.com", - STRIPE_API_VERSION: "2026-01-28.clover", - IS_PRODUCTION: false, - SENTRY_DSN: "mock-sentry-dsn", - SENTRY_RELEASE: "mock-sentry-release", - SENTRY_ENVIRONMENT: "mock-sentry-environment", - SESSION_MAX_AGE: 1000, - MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT: 100, - MAX_OTHER_OPTION_LENGTH: 250, - AVAILABLE_LOCALES: [ - "de-DE", - "en-US", - "es-ES", - "fr-FR", - "hu-HU", - "ja-JP", - "nl-NL", - "pt-BR", - "pt-PT", - "ro-RO", - "ru-RU", - "sv-SE", - "tr-TR", - "zh-Hans-CN", - "zh-Hant-TW", - ], - DEFAULT_LOCALE: "en-US", - BREVO_API_KEY: "mock-brevo-api-key", - ITEMS_PER_PAGE: 30, - FB_LOGO_URL: "mock-fb-logo-url", - NOTION_RICH_TEXT_LIMIT: 1000, - SMTP_HOST: "mock-smtp-host", - SMTP_PORT: "587", - SMTP_SECURE_ENABLED: false, - SMTP_USER: "mock-smtp-user", - SMTP_PASSWORD: "mock-smtp-password", //NOSONAR ignore rule for test setup - SMTP_AUTHENTICATED: true, - SMTP_REJECT_UNAUTHORIZED_TLS: true, - MAIL_FROM: "mock@mail.com", - MAIL_FROM_NAME: "Mock Mail", - RATE_LIMITING_DISABLED: false, - TELEMETRY_DISABLED: false, - PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30, - CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", -})); +vi.mock("@/lib/constants", async (importOriginal) => { + const actual = await importOriginal(); + + return { + ...actual, + IS_FORMBRICKS_CLOUD: false, + ENCRYPTION_KEY: "mock-encryption-key", + ENTERPRISE_LICENSE_KEY: "mock-enterprise-license-key", + GITHUB_ID: "mock-github-id", + GITHUB_SECRET: "test-githubID", + GOOGLE_CLIENT_ID: "test-google-client-id", + GOOGLE_CLIENT_SECRET: "test-google-client-secret", + AZUREAD_CLIENT_ID: "test-azuread-client-id", + AZUREAD_CLIENT_SECRET: "test-azure", + AZUREAD_TENANT_ID: "test-azuread-tenant-id", + OIDC_DISPLAY_NAME: "test-oidc-display-name", + OIDC_CLIENT_ID: "test-oidc-client-id", + OIDC_ISSUER: "test-oidc-issuer", + OIDC_CLIENT_SECRET: "test-oidc-client-secret", + OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm", + WEBAPP_URL: "https://test-webapp-url.com", + STRIPE_API_VERSION: "2026-01-28.clover", + IS_PRODUCTION: false, + SENTRY_DSN: "mock-sentry-dsn", + SENTRY_RELEASE: "mock-sentry-release", + SENTRY_ENVIRONMENT: "mock-sentry-environment", + SESSION_MAX_AGE: 1000, + MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT: 100, + MAX_OTHER_OPTION_LENGTH: 250, + AVAILABLE_LOCALES: [ + "de-DE", + "en-US", + "es-ES", + "fr-FR", + "hu-HU", + "ja-JP", + "nl-NL", + "pt-BR", + "pt-PT", + "ro-RO", + "ru-RU", + "sv-SE", + "tr-TR", + "zh-Hans-CN", + "zh-Hant-TW", + ], + DEFAULT_LOCALE: "en-US", + BREVO_API_KEY: "mock-brevo-api-key", + ITEMS_PER_PAGE: 30, + FB_LOGO_URL: "mock-fb-logo-url", + NOTION_RICH_TEXT_LIMIT: 1000, + SMTP_HOST: "mock-smtp-host", + SMTP_PORT: "587", + SMTP_SECURE_ENABLED: false, + SMTP_USER: "mock-smtp-user", + SMTP_PASSWORD: "mock-smtp-password", //NOSONAR ignore rule for test setup + SMTP_AUTHENTICATED: true, + SMTP_REJECT_UNAUTHORIZED_TLS: true, + MAIL_FROM: "mock@mail.com", + MAIL_FROM_NAME: "Mock Mail", + RATE_LIMITING_DISABLED: false, + TELEMETRY_DISABLED: false, + PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30, + CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q", + }; +}); diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cb454d512e2c..344250dbd64e 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -24,22 +24,53 @@ services: volumes: - valkey-data:/data - minio: - image: minio/minio:RELEASE.2025-09-07T16-13-09Z - command: server /data --console-address ":9001" + rustfs-perms: + image: busybox:1.36.1 + user: "0:0" + command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"] + volumes: + - rustfs-data:/data + + rustfs: + image: rustfs/rustfs:1.0.0-alpha.93 + depends_on: + rustfs-perms: + condition: service_completed_successfully + command: /data environment: - - MINIO_ROOT_USER=devminio - - MINIO_ROOT_PASSWORD=devminio123 + - RUSTFS_ACCESS_KEY=devrustfs + - RUSTFS_SECRET_KEY=devrustfs123 + - RUSTFS_ADDRESS=:9000 + - RUSTFS_CONSOLE_ENABLE=true + - RUSTFS_CONSOLE_ADDRESS=:9001 ports: - "9000:9000" # S3 API direct access - "9001:9001" # Web console volumes: - - minio-data:/data + - rustfs-data:/data + + rustfs-init: + image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 + depends_on: + - rustfs + environment: + - RUSTFS_ADMIN_USER=devrustfs + - RUSTFS_ADMIN_PASSWORD=devrustfs123 + - RUSTFS_BUCKET_NAME=formbricks + - RUSTFS_POLICY_NAME=formbricks-policy + - RUSTFS_SERVICE_USER=devrustfs-service + - RUSTFS_SERVICE_PASSWORD=devrustfs-service123 + - RUSTFS_CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:3002,http://127.0.0.1:3002 + volumes: + - ./docker/rustfs-init.sh:/usr/local/bin/rustfs-init.sh:ro + entrypoint: + - /bin/sh + - /usr/local/bin/rustfs-init.sh volumes: postgres: driver: local valkey-data: driver: local - minio-data: + rustfs-data: driver: local diff --git a/docker/formbricks.sh b/docker/formbricks.sh index 8347d11ca6e5..a2ad674a4002 100755 --- a/docker/formbricks.sh +++ b/docker/formbricks.sh @@ -3,6 +3,166 @@ set -e ubuntu_version=$(lsb_release -a 2>/dev/null | grep -v "No LSB modules are available." | grep "Description:" | awk -F "Description:\t" '{print $2}') +write_rustfs_init_script() { + local target_path="${1:-rustfs-init.sh}" + local script_dir + local template_path + + script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" + template_path="${script_dir}/rustfs-init.sh" + + if [ -f "$template_path" ]; then + cp "$template_path" "$target_path" + chmod +x "$target_path" + return + fi + + cat >"$target_path" << 'RUSTFS_SCRIPT_EOF' +#!/bin/sh +# Shared RustFS bootstrap script. +# Used directly by docker-compose.dev.yml for local development and used as the +# source template for the generated rustfs-init.sh in docker/formbricks.sh for +# one-click/self-hosted installs. packages/storage/src/rustfs-init-bootstrap.test.ts +# also validates that the generated script stays in sync with this file. +set -e +rustfs_endpoint_url="${RUSTFS_ENDPOINT_URL:-http://rustfs:9000}" +echo '⏳ Waiting for RustFS to be ready...' +attempts=0 +max_attempts=30 +until mc alias set rustfs "$rustfs_endpoint_url" "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1 \ + && mc ls rustfs >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ $attempts -ge $max_attempts ]; then + printf '❌ Failed to connect to RustFS after %s attempts\n' $max_attempts + exit 1 + fi + printf '...still waiting attempt %s/%s\n' $attempts $max_attempts + sleep 2 +done +echo '🔗 RustFS reachable; alias configured.' + +echo '🪣 Creating bucket (idempotent)...' +mc mb rustfs/$RUSTFS_BUCKET_NAME --ignore-existing + +if [ -n "${RUSTFS_CORS_ALLOWED_ORIGINS:-}" ]; then + echo '🌐 Applying bucket CORS configuration...' + cors_file="/tmp/formbricks-cors.xml" + + cat > "$cors_file" << EOF + + +EOF + + old_ifs=$IFS + IFS=',' + for origin in $RUSTFS_CORS_ALLOWED_ORIGINS; do + trimmed_origin=$(printf '%s' "$origin" | tr -d '[:space:]') + if [ -n "$trimmed_origin" ]; then + printf ' %s\n' "$trimmed_origin" >> "$cors_file" + fi + done + IFS=$old_ifs + + cat >> "$cors_file" << EOF + GET + HEAD + POST + PUT + DELETE + * + ETag + 3000 + + +EOF + + mc cors set rustfs/$RUSTFS_BUCKET_NAME "$cors_file" + echo 'CORS configuration applied successfully.' +fi + +echo '📄 Creating JSON policy file...' +cat > /tmp/formbricks-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], + "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME/*"] + }, + { + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME"] + } + ] +} +EOF + +echo '🔒 Creating policy (idempotent)...' +if ! mc admin policy info rustfs "$RUSTFS_POLICY_NAME" >/dev/null 2>&1; then + mc admin policy create rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json || \ + mc admin policy add rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json + echo 'Policy created successfully.' +else + echo 'Policy already exists, skipping creation.' +fi + +echo '👤 Creating service user (idempotent)...' +if ! mc admin user info rustfs "$RUSTFS_SERVICE_USER" >/dev/null 2>&1; then + mc admin user add rustfs "$RUSTFS_SERVICE_USER" "$RUSTFS_SERVICE_PASSWORD" + echo 'User created successfully.' +else + echo 'User already exists, skipping creation.' +fi + +echo '🔗 Attaching policy to user (idempotent)...' +mc admin policy attach rustfs "$RUSTFS_POLICY_NAME" --user "$RUSTFS_SERVICE_USER" + +echo '✅ RustFS setup complete!' +RUSTFS_SCRIPT_EOF + + chmod +x "$target_path" +} + +upsert_dotenv_var() { + local key="$1" + local value="$2" + local env_file="${3:-.env}" + local tmp_file + + touch "$env_file" + chmod 600 "$env_file" + tmp_file=$(mktemp) + + awk -v insert_key="$key" -v insert_val="$value" ' + BEGIN { updated=0 } + $0 ~ "^" insert_key "=" { + print insert_key "=" insert_val + updated=1 + next + } + { print } + END { + if (!updated) { + print insert_key "=" insert_val + } + } + ' "$env_file" >"$tmp_file" && mv "$tmp_file" "$env_file" +} + +write_rustfs_env_file() { + local env_file="${1:-.env}" + + upsert_dotenv_var "FORMBRICKS_RUSTFS_ADMIN_USER" "$rustfs_admin_user" "$env_file" + upsert_dotenv_var "FORMBRICKS_RUSTFS_ADMIN_PASSWORD" "$rustfs_admin_password" "$env_file" + upsert_dotenv_var "FORMBRICKS_RUSTFS_SERVICE_USER" "$rustfs_service_user" "$env_file" + upsert_dotenv_var "FORMBRICKS_RUSTFS_SERVICE_PASSWORD" "$rustfs_service_password" "$env_file" + upsert_dotenv_var "FORMBRICKS_RUSTFS_BUCKET_NAME" "$rustfs_bucket_name" "$env_file" + upsert_dotenv_var "FORMBRICKS_RUSTFS_POLICY_NAME" "$rustfs_policy_name" "$env_file" + upsert_dotenv_var "FORMBRICKS_RUSTFS_REGION" "us-east-1" "$env_file" +} + install_formbricks() { # Friendly welcome echo "🧱 Welcome to the Formbricks Setup Script" @@ -273,7 +433,7 @@ EOT if [[ -z $configure_uploads ]]; then configure_uploads="y"; fi if [[ $configure_uploads == "y" ]]; then - # Storage choice: External S3 vs bundled MinIO + # Storage choice: External S3 vs bundled RustFS read -p "🗄️ Do you want to use an external S3-compatible storage (AWS S3/DO Spaces/etc.)? [y/N] " use_external_s3 use_external_s3=$(echo "$use_external_s3" | tr '[:upper:]' '[:lower:]') if [[ -z $use_external_s3 ]]; then use_external_s3="n"; fi @@ -286,29 +446,29 @@ EOT read -p " S3 Bucket Name: " ext_s3_bucket read -p " S3 Endpoint URL (leave empty if you are using AWS S3, otherwise please enter the endpoint URL of the third party S3 compatible storage service): " ext_s3_endpoint - minio_storage="n" + rustfs_storage="n" else - minio_storage="y" + rustfs_storage="y" default_files_domain="files.$domain_name" read -p "🔗 Enter the files subdomain for object storage (e.g., $default_files_domain): " files_domain if [[ -z $files_domain ]]; then files_domain="$default_files_domain"; fi - echo "🔑 Generating MinIO credentials..." - minio_root_user="formbricks-$(openssl rand -hex 4)" - minio_root_password=$(openssl rand -base64 20) - minio_service_user="formbricks-service-$(openssl rand -hex 4)" - minio_service_password=$(openssl rand -base64 20) - minio_bucket_name="formbricks-uploads" - minio_policy_name="formbricks-policy" + echo "🔑 Generating RustFS credentials..." + rustfs_admin_user="formbricks-$(openssl rand -hex 4)" + rustfs_admin_password=$(openssl rand -base64 20) + rustfs_service_user="formbricks-service-$(openssl rand -hex 4)" + rustfs_service_password=$(openssl rand -base64 20) + rustfs_bucket_name="formbricks-uploads" + rustfs_policy_name="formbricks-policy" - echo "✅ MinIO will be configured with:" - echo " S3 Access Key (least privilege): $minio_service_user" - echo " Bucket: $minio_bucket_name" + echo "✅ RustFS will be configured with:" + echo " S3 Access Key (least privilege): $rustfs_service_user" + echo " Bucket: $rustfs_bucket_name" fi else - minio_storage="n" + rustfs_storage="n" use_external_s3="n" - echo "⚠️ File uploads are disabled. Proceeding without S3/MinIO configuration." + echo "⚠️ File uploads are disabled. Proceeding without S3-compatible storage configuration." fi echo "📥 Downloading docker-compose.yml from Formbricks GitHub repository..." @@ -353,20 +513,21 @@ EOT sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1# S3_FORCE_PATH_STYLE:|' docker-compose.yml fi echo "🚗 External S3 configuration updated successfully!" - elif [[ $minio_storage == "y" ]]; then - echo "🚗 Configuring bundled MinIO..." - sed -i "s|# S3_ACCESS_KEY:|S3_ACCESS_KEY: \"$minio_service_user\"|" docker-compose.yml - sed -i "s|# S3_SECRET_KEY:|S3_SECRET_KEY: \"$minio_service_password\"|" docker-compose.yml - sed -i "s|# S3_REGION:|S3_REGION: \"us-east-1\"|" docker-compose.yml - sed -i "s|# S3_BUCKET_NAME:|S3_BUCKET_NAME: \"$minio_bucket_name\"|" docker-compose.yml + elif [[ $rustfs_storage == "y" ]]; then + echo "🚗 Configuring bundled RustFS..." + write_rustfs_env_file ".env" + sed -i 's|# S3_ACCESS_KEY:|S3_ACCESS_KEY: "${FORMBRICKS_RUSTFS_SERVICE_USER}"|' docker-compose.yml + sed -i 's|# S3_SECRET_KEY:|S3_SECRET_KEY: "${FORMBRICKS_RUSTFS_SERVICE_PASSWORD}"|' docker-compose.yml + sed -i 's|# S3_REGION:|S3_REGION: "${FORMBRICKS_RUSTFS_REGION}"|' docker-compose.yml + sed -i 's|# S3_BUCKET_NAME:|S3_BUCKET_NAME: "${FORMBRICKS_RUSTFS_BUCKET_NAME}"|' docker-compose.yml if [[ $https_setup == "y" ]]; then sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"https://$files_domain\"|" docker-compose.yml else sed -i "s|# S3_ENDPOINT_URL:|S3_ENDPOINT_URL: \"http://$files_domain\"|" docker-compose.yml fi - # Ensure S3_FORCE_PATH_STYLE is enabled for MinIO + # Ensure S3_FORCE_PATH_STYLE is enabled for RustFS sed -E -i 's|^([[:space:]]*)#?[[:space:]]*S3_FORCE_PATH_STYLE:[[:space:]]*.*$|\1S3_FORCE_PATH_STYLE: 1|' docker-compose.yml - echo "🚗 MinIO S3 configuration updated successfully!" + echo "🚗 RustFS S3 configuration updated successfully!" fi # SUPER SIMPLE: Use multiple simple operations instead of complex AWK @@ -400,8 +561,8 @@ EOT { print } ' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml - # Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on) - if [[ $minio_storage == "y" ]]; then + # Step 2: Ensure formbricks waits for rustfs-init to complete successfully (mapping depends_on) + if [[ $rustfs_storage == "y" ]]; then # Remove any existing simple depends_on list and replace with mapping awk ' BEGIN{in_fb=0; removing=0} @@ -422,7 +583,9 @@ EOT print " depends_on:" print " postgres:" print " condition: service_started" - print " minio-init:" + print " redis:" + print " condition: service_started" + print " rustfs-init:" print " condition: service_completed_successfully" inserted=1 } @@ -437,59 +600,82 @@ EOT insert_traefik="y" if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi - if [[ $minio_storage == "y" ]]; then - insert_minio="y"; insert_minio_init="y" - if grep -q "^ minio:" docker-compose.yml; then insert_minio="n"; fi - if grep -q "^ minio-init:" docker-compose.yml; then insert_minio_init="n"; fi + if [[ $rustfs_storage == "y" ]]; then + rustfs_cors_origin="https://$domain_name" + if [[ $https_setup != "y" ]]; then + rustfs_cors_origin="http://$domain_name" + fi - if [[ $insert_minio == "y" ]]; then + insert_rustfs_perms="y"; insert_rustfs="y"; insert_rustfs_init="y" + if grep -q "^ rustfs-perms:" docker-compose.yml; then insert_rustfs_perms="n"; fi + if grep -q "^ rustfs:" docker-compose.yml; then insert_rustfs="n"; fi + if grep -q "^ rustfs-init:" docker-compose.yml; then insert_rustfs_init="n"; fi + + if [[ $insert_rustfs_perms == "y" ]]; then cat >> "$services_snippet_file" << EOF - minio: + rustfs-perms: + image: busybox:1.36.1 + user: "0:0" + command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"] + volumes: + - rustfs-data:/data +EOF + fi + + if [[ $insert_rustfs == "y" ]]; then + cat >> "$services_snippet_file" << EOF + rustfs: restart: always - image: minio/minio@sha256:13582eff79c6605a2d315bdd0e70164142ea7e98fc8411e9e10d089502a6d883 - command: server /data + image: rustfs/rustfs:1.0.0-alpha.93 + depends_on: + rustfs-perms: + condition: service_completed_successfully + command: /data environment: - MINIO_ROOT_USER: "$minio_root_user" - MINIO_ROOT_PASSWORD: "$minio_root_password" + RUSTFS_ACCESS_KEY: "\${FORMBRICKS_RUSTFS_ADMIN_USER}" + RUSTFS_SECRET_KEY: "\${FORMBRICKS_RUSTFS_ADMIN_PASSWORD}" + RUSTFS_ADDRESS: ":9000" volumes: - - minio-data:/data + - rustfs-data:/data labels: - "traefik.enable=true" # S3 API on files subdomain - - "traefik.http.routers.minio-s3.rule=Host(\`$files_domain\`)" - - "traefik.http.routers.minio-s3.entrypoints=websecure" - - "traefik.http.routers.minio-s3.tls=true" - - "traefik.http.routers.minio-s3.tls.certresolver=default" - - "traefik.http.routers.minio-s3.service=minio-s3" - - "traefik.http.services.minio-s3.loadbalancer.server.port=9000" + - "traefik.http.routers.rustfs-s3.rule=Host(\`$files_domain\`)" + - "traefik.http.routers.rustfs-s3.entrypoints=websecure" + - "traefik.http.routers.rustfs-s3.tls=true" + - "traefik.http.routers.rustfs-s3.tls.certresolver=default" + - "traefik.http.routers.rustfs-s3.service=rustfs-s3" + - "traefik.http.services.rustfs-s3.loadbalancer.server.port=9000" # CORS and rate limit (adjust origins if needed) - - "traefik.http.routers.minio-s3.middlewares=minio-cors,minio-ratelimit" - - "traefik.http.middlewares.minio-cors.headers.accesscontrolallowmethods=GET,PUT,POST,DELETE,HEAD,OPTIONS" - - "traefik.http.middlewares.minio-cors.headers.accesscontrolallowheaders=*" - - "traefik.http.middlewares.minio-cors.headers.accesscontrolalloworiginlist=https://$domain_name" - - "traefik.http.middlewares.minio-cors.headers.accesscontrolmaxage=100" - - "traefik.http.middlewares.minio-cors.headers.addvaryheader=true" - - "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100" - - "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200" + - "traefik.http.routers.rustfs-s3.middlewares=rustfs-cors,rustfs-ratelimit" + - "traefik.http.middlewares.rustfs-cors.headers.accesscontrolallowmethods=GET,PUT,POST,DELETE,HEAD,OPTIONS" + - "traefik.http.middlewares.rustfs-cors.headers.accesscontrolallowheaders=*" + - "traefik.http.middlewares.rustfs-cors.headers.accesscontrolalloworiginlist=https://$domain_name" + - "traefik.http.middlewares.rustfs-cors.headers.accesscontrolmaxage=100" + - "traefik.http.middlewares.rustfs-cors.headers.addvaryheader=true" + - "traefik.http.middlewares.rustfs-ratelimit.ratelimit.average=100" + - "traefik.http.middlewares.rustfs-ratelimit.ratelimit.burst=200" EOF fi - if [[ $insert_minio_init == "y" ]]; then + if [[ $insert_rustfs_init == "y" ]]; then cat >> "$services_snippet_file" << EOF - minio-init: + rustfs-init: image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 depends_on: - - minio + - rustfs environment: - MINIO_ROOT_USER: "$minio_root_user" - MINIO_ROOT_PASSWORD: "$minio_root_password" - MINIO_SERVICE_USER: "$minio_service_user" - MINIO_SERVICE_PASSWORD: "$minio_service_password" - MINIO_BUCKET_NAME: "$minio_bucket_name" - entrypoint: ["/bin/sh", "/tmp/minio-init.sh"] + RUSTFS_ADMIN_USER: "\${FORMBRICKS_RUSTFS_ADMIN_USER}" + RUSTFS_ADMIN_PASSWORD: "\${FORMBRICKS_RUSTFS_ADMIN_PASSWORD}" + RUSTFS_SERVICE_USER: "\${FORMBRICKS_RUSTFS_SERVICE_USER}" + RUSTFS_SERVICE_PASSWORD: "\${FORMBRICKS_RUSTFS_SERVICE_PASSWORD}" + RUSTFS_BUCKET_NAME: "\${FORMBRICKS_RUSTFS_BUCKET_NAME}" + RUSTFS_POLICY_NAME: "\${FORMBRICKS_RUSTFS_POLICY_NAME}" + RUSTFS_CORS_ALLOWED_ORIGINS: "$rustfs_cors_origin" + entrypoint: ["/bin/sh", "/tmp/rustfs-init.sh"] volumes: - - ./minio-init.sh:/tmp/minio-init.sh:ro + - ./rustfs-init.sh:/tmp/rustfs-init.sh:ro EOF fi @@ -501,7 +687,7 @@ EOF container_name: "traefik" depends_on: - formbricks - - minio + - rustfs ports: - "80:80" - "443:443" @@ -513,11 +699,11 @@ EOF EOF fi - # Downgrade MinIO router to plain HTTP when HTTPS is not configured + # Downgrade RustFS router to plain HTTP when HTTPS is not configured if [[ $https_setup != "y" ]]; then - sed -i 's/traefik.http.routers.minio-s3.entrypoints=websecure/traefik.http.routers.minio-s3.entrypoints=web/' "$services_snippet_file" - sed -i '/traefik.http.routers.minio-s3.tls=true/d' "$services_snippet_file" - sed -i '/traefik.http.routers.minio-s3.tls.certresolver=default/d' "$services_snippet_file" + sed -i 's/traefik.http.routers.rustfs-s3.entrypoints=websecure/traefik.http.routers.rustfs-s3.entrypoints=web/' "$services_snippet_file" + sed -i '/traefik.http.routers.rustfs-s3.tls=true/d' "$services_snippet_file" + sed -i '/traefik.http.routers.rustfs-s3.tls.certresolver=default/d' "$services_snippet_file" sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file" fi else @@ -577,14 +763,14 @@ EOF END { if (invol && !added) { print " redis:"; print " driver: local" } } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml fi - # Ensure minio-data if needed - if [[ $minio_storage == "y" ]]; then - if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="minio-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then + # Ensure rustfs-data if needed + if [[ $rustfs_storage == "y" ]]; then + if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="rustfs-data:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then awk ' /^volumes:/ { print; invol=1; next } - invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 } + invol && /^[^[:space:]]/ { if(!added){ print " rustfs-data:"; print " driver: local"; added=1 } ; invol=0 } { print } - END { if (invol && !added) { print " minio-data:"; print " driver: local" } } + END { if (invol && !added) { print " rustfs-data:"; print " driver: local" } } ' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml fi fi @@ -596,77 +782,16 @@ EOF echo " driver: local" echo " redis:" echo " driver: local" - if [[ $minio_storage == "y" ]]; then - echo " minio-data:" + if [[ $rustfs_storage == "y" ]]; then + echo " rustfs-data:" echo " driver: local" fi } >> docker-compose.yml fi - # Create minio-init script outside heredoc to avoid variable expansion issues - if [[ $minio_storage == "y" ]]; then - cat > minio-init.sh << 'MINIO_SCRIPT_EOF' -#!/bin/sh -echo '⏳ Waiting for MinIO to be ready...' -attempts=0 -max_attempts=30 -until mc alias set minio http://minio:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null 2>&1 \ - && mc ls minio >/dev/null 2>&1; do - attempts=$((attempts + 1)) - if [ $attempts -ge $max_attempts ]; then - printf '❌ Failed to connect to MinIO after %s attempts\n' $max_attempts - exit 1 - fi - printf '...still waiting attempt %s/%s\n' $attempts $max_attempts - sleep 2 -done -echo '🔗 MinIO reachable; alias configured.' - -echo '🪣 Creating bucket (idempotent)...'; -mc mb minio/$MINIO_BUCKET_NAME --ignore-existing; - -echo '📄 Creating JSON policy file...'; -cat > /tmp/formbricks-policy.json << EOF -{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], - "Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME/*"] - }, - { - "Effect": "Allow", - "Action": ["s3:ListBucket"], - "Resource": ["arn:aws:s3:::$MINIO_BUCKET_NAME"] - } - ] -} -EOF - -echo '🔒 Creating policy (idempotent)...'; -if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then - mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json || mc admin policy add minio formbricks-policy /tmp/formbricks-policy.json; - echo 'Policy created successfully.'; -else - echo 'Policy already exists, skipping creation.'; -fi - -echo '👤 Creating service user (idempotent)...'; -if ! mc admin user info minio "$MINIO_SERVICE_USER" >/dev/null 2>&1; then - mc admin user add minio "$MINIO_SERVICE_USER" "$MINIO_SERVICE_PASSWORD"; - echo 'User created successfully.'; -else - echo 'User already exists, skipping creation.'; -fi - -echo '🔗 Attaching policy to user (idempotent)...'; -mc admin policy attach minio formbricks-policy --user "$MINIO_SERVICE_USER" || echo 'Policy already attached or attachment failed (non-fatal).'; - -echo '✅ MinIO setup complete!'; -exit 0; -MINIO_SCRIPT_EOF - chmod +x minio-init.sh + # Create rustfs-init script outside heredoc to avoid variable expansion issues + if [[ $rustfs_storage == "y" ]]; then + write_rustfs_init_script "rustfs-init.sh" fi newgrp docker < tmp.yml && mv tmp.yml docker-compose.yml - # Remove list-style "- minio-init" lines under depends_on (if any) + # Remove list-style init dependencies under depends_on (if any) if sed --version >/dev/null 2>&1; then + sed -E -i '/^[[:space:]]*-[[:space:]]*rustfs-init[[:space:]]*$/d' docker-compose.yml sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml else + sed -E -i '' '/^[[:space:]]*-[[:space:]]*rustfs-init[[:space:]]*$/d' docker-compose.yml sed -E -i '' '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml fi - # Remove the minio-init mapping and its condition line (mapping style depends_on) + # Remove the mapping style depends_on entries for init jobs if sed --version >/dev/null 2>&1; then + sed -i '/^[[:space:]]*rustfs-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml else + sed -i '' '/^[[:space:]]*rustfs-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml sed -i '' '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml fi - # Remove any stopped minio-init container and restart without orphans + # Remove any stopped init containers and restart without orphans + docker compose rm -f -s rustfs-init >/dev/null 2>&1 || true docker compose rm -f -s minio-init >/dev/null 2>&1 || true docker compose up -d --remove-orphans - echo "✅ MinIO init cleanup complete." + echo "✅ RustFS init cleanup complete." } -case "$1" in -install) - install_formbricks - ;; -update) - update_formbricks - ;; -stop) - stop_formbricks - ;; -restart) - restart_formbricks - ;; -logs) - get_logs - ;; +cleanup_minio_init() { + cleanup_rustfs_init +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + case "$1" in + install) + install_formbricks + ;; + update) + update_formbricks + ;; + stop) + stop_formbricks + ;; + restart) + restart_formbricks + ;; + logs) + get_logs + ;; + cleanup-rustfs-init) + cleanup_rustfs_init + ;; cleanup-minio-init) cleanup_minio_init ;; -uninstall) - uninstall_formbricks - ;; -*) - echo "🚀 Executing default step of installing Formbricks" - install_formbricks - ;; -esac \ No newline at end of file + uninstall) + uninstall_formbricks + ;; + *) + echo "🚀 Executing default step of installing Formbricks" + install_formbricks + ;; + esac +fi diff --git a/docker/rustfs-init.sh b/docker/rustfs-init.sh new file mode 100755 index 000000000000..0622d462bb07 --- /dev/null +++ b/docker/rustfs-init.sh @@ -0,0 +1,104 @@ +#!/bin/sh +# Shared RustFS bootstrap script. +# Used directly by docker-compose.dev.yml for local development and used as the +# source template for the generated rustfs-init.sh in docker/formbricks.sh for +# one-click/self-hosted installs. packages/storage/src/rustfs-init-bootstrap.test.ts +# also validates that the generated script stays in sync with this file. +set -e + +rustfs_endpoint_url="${RUSTFS_ENDPOINT_URL:-http://rustfs:9000}" + +echo '⏳ Waiting for RustFS to be ready...' +attempts=0 +max_attempts=30 +until mc alias set rustfs "$rustfs_endpoint_url" "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1 \ + && mc ls rustfs >/dev/null 2>&1; do + attempts=$((attempts + 1)) + if [ $attempts -ge $max_attempts ]; then + printf '❌ Failed to connect to RustFS after %s attempts\n' $max_attempts + exit 1 + fi + printf '...still waiting attempt %s/%s\n' $attempts $max_attempts + sleep 2 +done +echo '🔗 RustFS reachable; alias configured.' + +echo '🪣 Creating bucket (idempotent)...' +mc mb rustfs/$RUSTFS_BUCKET_NAME --ignore-existing + +if [ -n "${RUSTFS_CORS_ALLOWED_ORIGINS:-}" ]; then + echo '🌐 Applying bucket CORS configuration...' + cors_file="/tmp/formbricks-cors.xml" + + cat > "$cors_file" << EOF + + +EOF + + old_ifs=$IFS + IFS=',' + for origin in $RUSTFS_CORS_ALLOWED_ORIGINS; do + trimmed_origin=$(printf '%s' "$origin" | tr -d '[:space:]') + if [ -n "$trimmed_origin" ]; then + printf ' %s\n' "$trimmed_origin" >> "$cors_file" + fi + done + IFS=$old_ifs + + cat >> "$cors_file" << EOF + GET + HEAD + POST + PUT + DELETE + * + ETag + 3000 + + +EOF + + mc cors set rustfs/$RUSTFS_BUCKET_NAME "$cors_file" + echo 'CORS configuration applied successfully.' +fi + +echo '📄 Creating JSON policy file...' +cat > /tmp/formbricks-policy.json << EOF +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], + "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME/*"] + }, + { + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME"] + } + ] +} +EOF + +echo '🔒 Creating policy (idempotent)...' +if ! mc admin policy info rustfs "$RUSTFS_POLICY_NAME" >/dev/null 2>&1; then + mc admin policy create rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json || \ + mc admin policy add rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json + echo 'Policy created successfully.' +else + echo 'Policy already exists, skipping creation.' +fi + +echo '👤 Creating service user (idempotent)...' +if ! mc admin user info rustfs "$RUSTFS_SERVICE_USER" >/dev/null 2>&1; then + mc admin user add rustfs "$RUSTFS_SERVICE_USER" "$RUSTFS_SERVICE_PASSWORD" + echo 'User created successfully.' +else + echo 'User already exists, skipping creation.' +fi + +echo '🔗 Attaching policy to user (idempotent)...' +mc admin policy attach rustfs "$RUSTFS_POLICY_NAME" --user "$RUSTFS_SERVICE_USER" + +echo '✅ RustFS setup complete!' diff --git a/docs/self-hosting/advanced/migration.mdx b/docs/self-hosting/advanced/migration.mdx index 3600f0b9f916..0f8504f01663 100644 --- a/docs/self-hosting/advanced/migration.mdx +++ b/docs/self-hosting/advanced/migration.mdx @@ -39,6 +39,7 @@ When Formbricks v4.7 starts for the first time, the data migration will: If you run into "**No such container**", use `docker ps` to find your container name, e.g. `formbricks_postgres_1`. + If you are using the **in-cluster PostgreSQL** deployed by the Helm chart: @@ -52,6 +53,7 @@ When Formbricks v4.7 starts for the first time, the data migration will: If you are using a **managed PostgreSQL** service (e.g. AWS RDS, Cloud SQL), use your provider's backup/snapshot feature or run `pg_dump` directly against the external host. + @@ -69,6 +71,7 @@ When Formbricks v4.7 starts for the first time, the data migration will: # Start with Formbricks v4.7 docker compose up -d ``` + ```bash @@ -81,6 +84,7 @@ When Formbricks v4.7 starts for the first time, the data migration will: The Helm chart includes a migration Job that automatically runs Prisma schema migrations as a PreSync hook before the new pods start. No manual migration step is needed. + @@ -105,6 +109,7 @@ After Formbricks starts, check the logs to see whether the value backfill was co ```bash kubectl logs -n formbricks job/formbricks-migration ``` + @@ -121,6 +126,7 @@ If the migration skipped the value backfill, run the standalone backfill script ``` Replace `formbricks` with your actual container name if it differs. Use `docker ps` to find it. + ```bash @@ -130,6 +136,7 @@ If the migration skipped the value backfill, run the standalone backfill script If your Formbricks deployment has a different name, run `kubectl get deploy -n formbricks` to find it. + @@ -202,7 +209,7 @@ Formbricks 4.0 is a **major milestone** that sets up the technical foundation fo These improvements in Formbricks 4.0 also make some infrastructure requirements mandatory going forward: - **Redis** for caching -- **MinIO or S3-compatible storage** for file uploads +- **RustFS or S3-compatible storage** for file uploads These services are already included in the updated one-click setup for self-hosters, but existing users need to upgrade their setup. More information on this below. @@ -223,9 +230,9 @@ We believe this is the only path forward to build the comprehensive Survey and E Additional migration steps are needed if you are using a self-hosted Formbricks setup that uses either local file storage (not S3-compatible file storage) or doesn't already use a Redis cache. -### One-Click Setup +### Legacy one-click v4.0 upgrade -For users using our official one-click setup, we provide an automated migration using a migration script: +For historical v4.0 upgrades, our original one-click migration script is still available: ```bash # Download the latest script @@ -242,7 +249,7 @@ chmod +x migrate-to-v4.sh This script guides you through the steps for the infrastructure migration and does the following: - Adds a Redis service to your setup and configures it -- Adds a MinIO service (open source S3-alternative) to your setup, configures it and migrates local files to it +- Adds a bundled MinIO service to your setup, configures it, and migrates local files to it - Pulls the latest Formbricks image and updates your instance ### Manual Setup @@ -264,7 +271,7 @@ Formbricks supports multiple storage providers (among many other S3-compatible s - AWS S3 - Digital Ocean Spaces - Hetzner Object Storage -- Custom MinIO server +- Custom RustFS server Please make sure to set up a storage bucket with one of these solutions and then link it to Formbricks using the following environment variables: @@ -273,7 +280,8 @@ Please make sure to set up a storage bucket with one of these solutions and then S3_SECRET_KEY: your-secret-key S3_REGION: us-east-1 S3_BUCKET_NAME: formbricks-uploads - S3_ENDPOINT_URL: http://minio:9000 # not needed for AWS S3 + S3_ENDPOINT_URL: https://files.yourdomain.com # not needed for AWS S3 + S3_FORCE_PATH_STYLE: 1 # set this only for custom endpoints like RustFS, MinIO, or LocalStack; omit for AWS S3 ``` #### Upgrade Process @@ -319,7 +327,7 @@ No manual intervention is required for the database migration. **4. Verify Your Upgrade** - Access your Formbricks instance at the same URL as before -- Test file uploads to ensure S3/MinIO integration works correctly. Check the [File Upload Troubleshooting](/self-hosting/configuration/file-uploads#troubleshooting) section if you face any issues. +- Test file uploads to ensure S3 storage integration works correctly. Check the [File Upload Troubleshooting](/self-hosting/configuration/file-uploads#troubleshooting) section if you face any issues. - Verify that existing surveys and data are intact - Check that previously uploaded files are accessible diff --git a/docs/self-hosting/configuration/file-uploads.mdx b/docs/self-hosting/configuration/file-uploads.mdx index 0dfe9b4bca78..60c274287508 100644 --- a/docs/self-hosting/configuration/file-uploads.mdx +++ b/docs/self-hosting/configuration/file-uploads.mdx @@ -4,7 +4,7 @@ description: "Configure file storage for survey images, file uploads, and projec icon: "upload" --- -Formbricks requires S3-compatible storage for file uploads. You can use external cloud storage services or the bundled MinIO option for a self-hosted solution. +Formbricks requires S3-compatible storage for file uploads. You can use external cloud storage services or the bundled RustFS option for a self-hosted solution. ## Why Configure File Uploads? @@ -35,18 +35,25 @@ Use cloud storage services for production deployments: - **StorJ** - Any S3-compatible storage service -### 2. Bundled MinIO Storage (Self-Hosted) +### 2. Bundled RustFS Storage (Self-Hosted) - **Important**: MinIO requires a dedicated subdomain to function properly. You must configure a subdomain - like `files.yourdomain.com` that points to your server. MinIO will not work without this subdomain setup. + **Important**: Bundled RustFS requires a dedicated subdomain. You must configure a subdomain like + `files.yourdomain.com` that points to your server so browser uploads can reach the object storage endpoint. -MinIO provides a self-hosted S3-compatible storage solution that runs alongside Formbricks. This option: + + Bundled RustFS is a convenience-oriented single-server option. It fits small-scale or lower-complexity + self-hosted deployments, but it is not the ideal RustFS architecture for high-availability or larger-scale + production storage. For stricter production requirements, prefer external object storage or a dedicated + RustFS deployment. + + +RustFS provides a self-hosted S3-compatible storage solution that runs alongside Formbricks. This option: - Runs in a Docker container alongside Formbricks - Provides full S3 API compatibility -- Requires minimal additional configuration +- Uses the same `S3_*` environment variables as any other S3-compatible provider ## Configuration Methods @@ -78,14 +85,14 @@ Choose this option for AWS S3, DigitalOcean Spaces, or other cloud providers: S3 Endpoint URL (leave empty if you are using AWS S3): https://your-endpoint.com ``` -#### Bundled MinIO Storage +#### Bundled RustFS Storage Choose this option for a self-hosted S3-compatible storage that runs alongside Formbricks: **Critical Requirement**: Before proceeding, ensure you have configured a subdomain (e.g., - `files.yourdomain.com`) that points to your server's IP address. MinIO will not function without this - subdomain setup. + `files.yourdomain.com`) that points to your server's IP address. This is required so browser-direct uploads + can reach RustFS. ```bash @@ -95,10 +102,11 @@ Choose this option for a self-hosted S3-compatible storage that runs alongside F The script will automatically: -- Generate secure MinIO credentials +- Generate separate RustFS admin and Formbricks service credentials - Create the storage bucket - Configure SSL certificates for the files subdomain - Configure Traefik routing for the subdomain +- Store the generated RustFS credentials in `./formbricks/.env` with restricted permissions ### Option 2: Manual Environment Variables @@ -122,7 +130,7 @@ S3_FORCE_PATH_STYLE=1 AWS S3 vs. third‑party S3: When using AWS S3 directly, leave `S3_ENDPOINT_URL` unset and - set `S3_FORCE_PATH_STYLE=0` (or omit). For most third‑party S3‑compatible providers (e.g., MinIO, + set `S3_FORCE_PATH_STYLE=0` (or omit). For most third‑party S3‑compatible providers (e.g., RustFS, DigitalOcean Spaces, Wasabi, Storj), you typically must set `S3_ENDPOINT_URL` to the provider's endpoint and set `S3_FORCE_PATH_STYLE=1`. @@ -151,11 +159,11 @@ S3_ENDPOINT_URL=https://nyc3.digitaloceanspaces.com S3_FORCE_PATH_STYLE=1 ``` -### MinIO (Self-Hosted) +### RustFS (Self-Hosted) ```bash -S3_ACCESS_KEY=minio_access_key -S3_SECRET_KEY=minio_secret_key +S3_ACCESS_KEY=rustfs_service_access_key +S3_SECRET_KEY=rustfs_service_secret_key S3_REGION=us-east-1 S3_BUCKET_NAME=formbricks-uploads S3_ENDPOINT_URL=https://files.yourdomain.com @@ -170,14 +178,14 @@ that do not implement POST Object are not compatible with Formbricks uploads. Fo S3‑compatible API currently does not support POST Object and therefore will not work with Formbricks file uploads. -## Bundled MinIO Setup +## Bundled RustFS Setup -When using the bundled MinIO option through the setup script, you get: +When using the bundled RustFS option through the setup script, you get: ### Automatic Configuration -- **Storage Service**: MinIO running in a Docker container -- **Credentials**: Auto-generated secure access keys +- **Storage Service**: RustFS running in a Docker container +- **Credentials**: Auto-generated admin and least-privilege service credentials - **Bucket**: Automatically created `formbricks-uploads` bucket - **SSL**: Automatic certificate generation for the files subdomain @@ -186,23 +194,22 @@ When using the bundled MinIO option through the setup script, you get: After setup, you'll see: ```bash -🗄️ MinIO Storage Setup Complete: - • S3 API: https://files.yourdomain.com - • Access Key: formbricks-a1b2c3d4 +🗄️ RustFS Storage Setup Complete: + • Access Key: formbricks-service-a1b2c3d4 • Bucket: formbricks-uploads (✅ automatically created) ``` ### DNS Requirements - **Critical for MinIO**: The subdomain configuration is mandatory for MinIO to function. Without proper - subdomain DNS setup, MinIO will fail to work entirely. + **Critical for bundled RustFS**: The files subdomain is mandatory. Without proper DNS and reverse-proxy + routing, browser uploads will fail. -For the bundled MinIO setup, ensure: +For the bundled RustFS setup, ensure: 1. **Main domain**: `yourdomain.com` points to your server IP -2. **Files subdomain**: `files.yourdomain.com` points to your server IP (this is required for MinIO to work) +2. **Files subdomain**: `files.yourdomain.com` points to your server IP 3. **Firewall**: Ports 80 and 443 are open in your server's firewall 4. **DNS propagation**: Allow time for DNS changes to propagate globally @@ -234,23 +241,23 @@ services: When using AWS S3 or S3-compatible storage providers, ensure that the IAM user associated with your `S3_ACCESS_KEY` and `S3_SECRET_KEY` credentials has the necessary permissions to interact with your bucket. Without proper permissions, file uploads and retrievals will fail. -The following IAM policy grants the minimum required permissions for Formbricks to function correctly. This policy is also used in the bundled MinIO integration: +The following IAM policy grants the minimum required permissions for Formbricks to function correctly. This policy is also used in the bundled RustFS integration: ```json { - "Version": "2012-10-17", "Statement": [ { - "Effect": "Allow", "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], + "Effect": "Allow", "Resource": ["arn:aws:s3:::your-bucket-name/*"] }, { - "Effect": "Allow", "Action": ["s3:ListBucket"], + "Effect": "Allow", "Resource": ["arn:aws:s3:::your-bucket-name"] } - ] + ], + "Version": "2012-10-17" } ``` @@ -294,7 +301,9 @@ Example least-privileged S3 bucket policy: ``` - Replace `your-bucket-name` with your actual bucket name and `arn:aws:iam::123456789012:user/formbricks-service` with the ARN of your IAM user. This policy allows public read access only to specific paths while restricting write access to your Formbricks service user. + Replace `your-bucket-name` with your actual bucket name and + `arn:aws:iam::123456789012:user/formbricks-service` with the ARN of your IAM user. This policy allows public + read access only to specific paths while restricting write access to your Formbricks service user. ### S3 CORS Configuration @@ -306,30 +315,19 @@ Configure CORS on your S3 bucket with the following settings: ```json [ { - "AllowedHeaders": [ - "*" - ], - "AllowedMethods": [ - "POST", - "GET", - "HEAD", - "DELETE", - "PUT" - ], - "AllowedOrigins": [ - "*" - ], - "ExposeHeaders": [ - "ETag", - "x-amz-meta-custom-header" - ], + "AllowedHeaders": ["*"], + "AllowedMethods": ["POST", "GET", "HEAD", "DELETE", "PUT"], + "AllowedOrigins": ["*"], + "ExposeHeaders": ["ETag", "x-amz-meta-custom-header"], "MaxAgeSeconds": 3000 } ] ``` - For production environments, consider restricting `AllowedOrigins` to your specific Formbricks domain(s) instead of using `"*"` for better security. For example: `["https://app.yourdomain.com", "https://yourdomain.com"]`. + For production environments, consider restricting `AllowedOrigins` to your specific Formbricks domain(s) + instead of using `"*"` for better security. For example: `["https://app.yourdomain.com", + "https://yourdomain.com"]`. **How to configure CORS:** @@ -338,15 +336,21 @@ Configure CORS on your S3 bucket with the following settings: - **DigitalOcean Spaces**: Navigate to your Space → Settings → CORS Configurations → Add CORS configuration → Paste the JSON - **Other S3-compatible providers**: Refer to your provider's documentation for CORS configuration -### MinIO Security +### RustFS Security -When using bundled MinIO: +When using bundled RustFS: - Credentials are auto-generated and secure +- Generated RustFS credentials are written to a local `.env` file; keep it private and restrict it to `0600` - Access is restricted through Traefik proxy - CORS is automatically configured - Rate limiting is applied to prevent abuse - A bucket policy with the least privileges is applied to the bucket +- Prefer local SSD or NVMe-backed storage for `rustfs-data`, use XFS on dedicated host-managed disks when + possible, and avoid NFS or other network filesystems for RustFS data +- Back up the `rustfs-data` volume regularly, especially for single-server deployments +- Ship RustFS and Formbricks container logs into your normal logging and alerting stack; the bundled setup + does not provision centralized audit-log export or alerting for you ## Troubleshooting @@ -359,8 +363,8 @@ When using bundled MinIO: 3. Ensure bucket permissions allow uploads from your server 4. Check network connectivity to S3 endpoint 5. We use S3 presigned URLs for uploads. Make sure your CORS policy allows presigned URL uploads; otherwise, uploads will fail. -Some providers (e.g., Hetzner’s object storage) [require a specific CORS configuration](https://github.com/formbricks/formbricks/discussions/6641#discussioncomment-14574048). -If you’re using the bundled MinIO setup, this is already configured for you. + Some providers (e.g., Hetzner’s object storage) [require a specific CORS configuration](https://github.com/formbricks/formbricks/discussions/6641#discussioncomment-14574048). + If you’re using the bundled RustFS setup, this is already configured for you. **Images not displaying in surveys:** @@ -368,13 +372,13 @@ If you’re using the bundled MinIO setup, this is already configured for you. 2. Check CORS configuration allows requests from your domain 3. Ensure S3_ENDPOINT_URL is correctly set for third-party services -**MinIO not starting:** +**RustFS not starting:** 1. **Verify subdomain DNS**: Ensure `files.yourdomain.com` points to your server IP (this is the most common issue) 2. **Check DNS propagation**: Use tools like `nslookup` or `dig` to verify DNS resolution 3. **Verify ports**: Ensure ports 80 and 443 are open in your firewall 4. **SSL certificate**: Check that SSL certificate generation completed successfully -5. **Container logs**: Check Docker container logs: `docker compose logs minio` +5. **Container logs**: Check Docker container logs: `docker compose logs rustfs` ### Testing Your Configuration @@ -389,8 +393,8 @@ To test if file uploads are working: # Check Formbricks logs docker compose logs formbricks -# Check MinIO logs (if using bundled MinIO) -docker compose logs minio +# Check RustFS logs (if using bundled RustFS) +docker compose logs rustfs ``` For additional help, join the conversation on [GitHub Discussions](https://github.com/formbricks/formbricks/discussions). diff --git a/docs/self-hosting/setup/cluster-setup.mdx b/docs/self-hosting/setup/cluster-setup.mdx index 79af4dd8978f..5f554a45460f 100644 --- a/docs/self-hosting/setup/cluster-setup.mdx +++ b/docs/self-hosting/setup/cluster-setup.mdx @@ -87,28 +87,24 @@ graph TD ### Component Description 1. **Formbricks Cluster** - - Multiple Formbricks instances (1..n) running in parallel - Each instance is stateless and can handle any incoming request - Automatic failover if any instance becomes unavailable 2. **PostgreSQL Database** - - Primary database storing all survey, response, and contact data - Optional high-availability setup with primary-replica configuration - Handles all persistent data storage needs 3. **Redis Cluster** - - Acts as a distributed cache layer - Improves performance by caching frequently accessed data - Can be configured in HA mode with primary-replica setup - Handles session management and real-time features 4. **S3 Compatible Storage** - - Stores file uploads and attachments - - Can be any S3-compatible storage service (AWS S3, MinIO, etc.) + - Can be any S3-compatible storage service (AWS S3, RustFS, etc.) - Provides reliable and scalable file storage 5. **Load Balancer** @@ -139,13 +135,13 @@ S3_SECRET_KEY=your-secret-key S3_REGION=your-region S3_BUCKET_NAME=your-bucket-name -# For S3-compatible storage (e.g., StorJ, MinIO) +# For S3-compatible storage (e.g., StorJ, RustFS) # Leave empty for Amazon S3 S3_ENDPOINT_URL=https://your-s3-compatible-endpoint -# Enable for S3-compatible storage that requires path style -# 0 for disabled, 1 for enabled -S3_FORCE_PATH_STYLE=0 +# Enable for RustFS and most third-party S3-compatible storage +# Set to 0 (or omit) for Amazon S3 +S3_FORCE_PATH_STYLE=1 ``` When using S3 in a cluster setup, ensure that: diff --git a/docs/self-hosting/setup/docker.mdx b/docs/self-hosting/setup/docker.mdx index 8401db6cdf66..db835f18fc39 100644 --- a/docs/self-hosting/setup/docker.mdx +++ b/docs/self-hosting/setup/docker.mdx @@ -117,26 +117,37 @@ Please take a look at our [migration guide](/self-hosting/advanced/migration) fo docker compose up -d ``` -## Optional: Adding MinIO for File Storage +## Optional: Adding RustFS for File Storage -MinIO provides S3-compatible object storage for file uploads in Formbricks. If you want to enable features like image uploads, file uploads in surveys, or custom logos, you can add MinIO to your Docker setup. +RustFS provides S3-compatible object storage for file uploads in Formbricks. If you want to enable features +like image uploads, survey file uploads, or custom logos, you can run RustFS alongside Formbricks while +keeping the existing `S3_*` environment variables. - For detailed information about file storage options and configuration, see our [File Uploads + For a broader overview of file storage options and required environment variables, see our [File Uploads Configuration](/self-hosting/configuration/file-uploads) guide. **For production deployments with HTTPS**, use the [one-click setup script](/self-hosting/setup/one-click) - which automatically configures MinIO with Traefik, SSL certificates, and a subdomain (required for MinIO in - production). The setups below are suitable for local development or testing only. + which automatically configures RustFS with Traefik, SSL certificates, a dedicated `files.` subdomain, and + least-privilege service credentials. The examples below are best suited for development, testing, or custom + local setups. + + + + The bundled RustFS examples on this page are convenience-oriented single-server setups. They work well for + development, evaluation, and smaller self-hosted deployments, but they are not the ideal RustFS architecture + for high-availability or larger-scale production storage. For stricter production requirements, use external + object storage or run a dedicated RustFS deployment separately. ### Quick Start: Using docker-compose.dev.yml -The fastest way to test MinIO with Formbricks is to use the included `docker-compose.dev.yml` which already has MinIO pre-configured. +The fastest way to test file uploads locally is to use the included `docker-compose.dev.yml`, which already +starts RustFS and auto-creates the `formbricks` bucket. -1. **Start MinIO and Services** +1. **Start the local stack** From the repository root: @@ -144,163 +155,202 @@ The fastest way to test MinIO with Formbricks is to use the included `docker-com docker compose -f docker-compose.dev.yml up -d ``` - This starts PostgreSQL, Valkey (Redis), MinIO, and Mailhog. - -2. **Access MinIO Console** - - Open http://localhost:9001 in your browser. - - Login credentials: + This starts PostgreSQL, Valkey (Redis), Mailhog, RustFS, a permissions helper, and a one-time bucket + bootstrap job. - - Username: `devminio` - - Password: `devminio123` +2. **Access the RustFS console** -3. **Create Bucket** + Open http://localhost:9001 in your browser and sign in with: + - Username: `devrustfs` + - Password: `devrustfs123` - - Click "Buckets" in the left sidebar - - Click "Create Bucket" - - Name it: `formbricks` +3. **Configure Formbricks** -4. **Configure Formbricks** - - Update your `.env` file or environment variables with MinIO configuration: + Update your `.env` file or environment variables: ```bash - # MinIO S3 Storage - S3_ACCESS_KEY="devminio" - S3_SECRET_KEY="devminio123" + S3_ACCESS_KEY="devrustfs" + S3_SECRET_KEY="devrustfs123" S3_REGION="us-east-1" S3_BUCKET_NAME="formbricks" S3_ENDPOINT_URL="http://localhost:9000" S3_FORCE_PATH_STYLE="1" ``` -5. **Verify in MinIO Console** - - After uploading files in Formbricks, view them at http://localhost:9001: +4. **Verify uploads** - - Navigate to Buckets → formbricks → Browse - - Your uploaded files will appear here + After uploading a file in Formbricks, open http://localhost:9001 and navigate to **Buckets → formbricks** + to confirm the object was stored successfully. - The `docker-compose.dev.yml` file includes MinIO with console access on port 9001, making it easy to - visually verify file uploads. This is the recommended approach for development and testing. + The development compose file also runs a `rustfs-init` job so you do not need to create the bucket manually. -### Manual MinIO Setup (Custom Configuration) +### Manual RustFS Setup (Custom Configuration) - Recommended: If you can, use docker-compose.dev.yml for the fastest path. Use - this manual approach only when you need to integrate MinIO into an existing docker-compose.yml{" "} - or customize settings. + Recommended: Prefer docker-compose.dev.yml for local development unless you + need to fold RustFS into an existing custom Compose stack. -If you prefer to add MinIO to your own `docker-compose.yml`, follow these steps: - -1. **Add the MinIO service** - - Add this service alongside your existing `formbricks` and `postgres` services: - - ```yaml - services: - # ... your existing services (formbricks, postgres, redis/valkey, etc.) - - minio: - image: minio/minio:latest - restart: always - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: "formbricks-root" - MINIO_ROOT_PASSWORD: "change-this-secure-password" - ports: - - "9000:9000" # S3 API - - "9001:9001" # Web console - volumes: - - minio-data:/data - ``` - - - For production pinning, consider using a digest (e.g., minio/minio@sha256:...) and review - periodically with docker inspect minio/minio:latest. - - -2. **Declare the MinIO volume** - - Add (or extend) your `volumes` block: - - ```yaml - volumes: - postgres: - driver: local - redis: - driver: local - minio-data: - driver: local - ``` - -3. **Start services** - - ```bash - docker compose up -d - ``` - -4. **Open the MinIO Console & Create a Bucket** - - - Visit **http://localhost:9001** - - Log in with: - - **Username:** `formbricks-root` - - **Password:** `change-this-secure-password` - - Go to **Buckets → Create Bucket** - - Name it: **`formbricks`** +If you want to add RustFS to your own `docker-compose.yml`, use a pinned RustFS image plus two helper +services: + +```yaml +services: + rustfs-perms: + image: busybox:1.36.1 + user: "0:0" + command: ["sh", "-c", "mkdir -p /data && chown -R 10001:10001 /data"] + volumes: + - rustfs-data:/data + + rustfs: + image: rustfs/rustfs:1.0.0-alpha.93 + restart: always + depends_on: + rustfs-perms: + condition: service_completed_successfully + command: /data + environment: + RUSTFS_ACCESS_KEY: "${FORMBRICKS_RUSTFS_ADMIN_USER}" + RUSTFS_SECRET_KEY: "${FORMBRICKS_RUSTFS_ADMIN_PASSWORD}" + RUSTFS_ADDRESS: ":9000" + RUSTFS_CONSOLE_ENABLE: "true" + RUSTFS_CONSOLE_ADDRESS: ":9001" + ports: + - "9000:9000" + - "9001:9001" + volumes: + - rustfs-data:/data + + rustfs-init: + image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 + depends_on: + - rustfs + environment: + RUSTFS_ADMIN_USER: "${FORMBRICKS_RUSTFS_ADMIN_USER}" + RUSTFS_ADMIN_PASSWORD: "${FORMBRICKS_RUSTFS_ADMIN_PASSWORD}" + RUSTFS_SERVICE_USER: "${FORMBRICKS_RUSTFS_SERVICE_USER}" + RUSTFS_SERVICE_PASSWORD: "${FORMBRICKS_RUSTFS_SERVICE_PASSWORD}" + RUSTFS_BUCKET_NAME: "${FORMBRICKS_RUSTFS_BUCKET_NAME}" + RUSTFS_POLICY_NAME: "${FORMBRICKS_RUSTFS_POLICY_NAME}" + entrypoint: + - /bin/sh + - -c + - | + set -e + until mc alias set rustfs http://rustfs:9000 "$RUSTFS_ADMIN_USER" "$RUSTFS_ADMIN_PASSWORD" >/dev/null 2>&1 \ + && mc ls rustfs >/dev/null 2>&1; do + sleep 2 + done + mc mb rustfs/"$RUSTFS_BUCKET_NAME" --ignore-existing + cat > /tmp/formbricks-policy.json << EOF + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], + "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME/*"] + }, + { + "Effect": "Allow", + "Action": ["s3:ListBucket"], + "Resource": ["arn:aws:s3:::$RUSTFS_BUCKET_NAME"] + } + ] + } + EOF + if ! mc admin policy info rustfs "$RUSTFS_POLICY_NAME" >/dev/null 2>&1; then + mc admin policy create rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json || \ + mc admin policy add rustfs "$RUSTFS_POLICY_NAME" /tmp/formbricks-policy.json + fi + if ! mc admin user info rustfs "$RUSTFS_SERVICE_USER" >/dev/null 2>&1; then + mc admin user add rustfs "$RUSTFS_SERVICE_USER" "$RUSTFS_SERVICE_PASSWORD" + fi + mc admin policy attach rustfs "$RUSTFS_POLICY_NAME" --user "$RUSTFS_SERVICE_USER" +``` -5. **Configure Formbricks to use MinIO** +Declare the corresponding volume: - In your `.env` or `formbricks` service environment, set: +```yaml +volumes: + rustfs-data: + driver: local +``` - ```bash - # MinIO S3 Storage - S3_ACCESS_KEY="formbricks-root" - S3_SECRET_KEY="change-this-secure-password" - S3_REGION="us-east-1" - S3_BUCKET_NAME="formbricks" - S3_ENDPOINT_URL="http://minio:9000" - S3_FORCE_PATH_STYLE="1" - ``` +Store the generated RustFS credentials in a local `.env` file next to your `docker-compose.yml` instead of +hardcoding them in Compose: - - These credentials should match MINIO_ROOT_USER and MINIO_ROOT_PASSWORD above. - For local/dev this is fine. For production, create a dedicated MinIO user with restricted policies. - +```bash +FORMBRICKS_RUSTFS_ADMIN_USER=formbricks-root +FORMBRICKS_RUSTFS_ADMIN_PASSWORD=change-this-secure-password +FORMBRICKS_RUSTFS_SERVICE_USER=formbricks-service +FORMBRICKS_RUSTFS_SERVICE_PASSWORD=change-this-service-password +FORMBRICKS_RUSTFS_BUCKET_NAME=formbricks +FORMBRICKS_RUSTFS_POLICY_NAME=formbricks-policy +FORMBRICKS_RUSTFS_REGION=us-east-1 +``` -6. **Verify uploads** +Then configure Formbricks to use the RustFS service credentials: - After uploading a file in Formbricks, check **http://localhost:9001**: +```bash +S3_ACCESS_KEY="${FORMBRICKS_RUSTFS_SERVICE_USER}" +S3_SECRET_KEY="${FORMBRICKS_RUSTFS_SERVICE_PASSWORD}" +S3_REGION="${FORMBRICKS_RUSTFS_REGION}" +S3_BUCKET_NAME="${FORMBRICKS_RUSTFS_BUCKET_NAME}" +S3_ENDPOINT_URL="http://rustfs:9000" +S3_FORCE_PATH_STYLE="1" +``` - - **Buckets → formbricks → Browse** - You should see your uploaded files. + + Restrict the `.env` file to `0600` and do not commit it to source control. For production, prefer the + [one-click setup script](/self-hosting/setup/one-click), which creates a separate least-privilege service + account automatically. + #### Tips & Common Gotchas -- **Connection refused**: Ensure the `minio` container is running and port **9000** is reachable from the Formbricks container (use the internal URL `http://minio:9000`). -- **Bucket not found**: Create the `formbricks` bucket in the console before uploading. -- **Auth failed**: Confirm `S3_ACCESS_KEY`/`S3_SECRET_KEY` match MinIO credentials. +- **Permission denied on `/data`**: Ensure the mounted directory or volume is owned by UID `10001`. The + `rustfs-perms` helper handles this for Compose-managed volumes. +- **Storage medium matters**: Prefer local SSD or NVMe storage for `rustfs-data`, use XFS on dedicated + host-managed disks where possible, and avoid NFS or other network filesystems for RustFS data. +- **Connection refused**: Ensure the `rustfs` container is running and port `9000` is reachable from the + Formbricks container. +- **Bucket not found**: Confirm that `rustfs-init` completed successfully or create the bucket manually with + `mc`. +- **Auth failed**: Confirm that `S3_ACCESS_KEY` and `S3_SECRET_KEY` match the RustFS credentials configured on + the server. +- **Backups**: Back up the `rustfs-data` volume regularly, especially for single-server deployments. +- **Console exposure**: Do not expose the RustFS console port publicly in production. Keep it on a private + network or behind admin-only controls. - **Health check**: From the Formbricks container: + ```bash - docker compose exec formbricks sh -c 'wget -O- http://minio:9000/minio/health/ready' + docker compose exec formbricks sh -c 'wget -O- http://rustfs:9000/health' ``` ### Production Setup with Traefik -For production deployments, use the [one-click setup script](/self-hosting/setup/one-click) which automatically configures: +For production deployments, use the [one-click setup script](/self-hosting/setup/one-click), which +automatically configures: -- MinIO service with Traefik reverse proxy -- Dedicated subdomain (e.g., `files.yourdomain.com`) - **required for production** +- RustFS behind Traefik on a dedicated `files.yourdomain.com` subdomain - Automatic SSL certificate generation via Let's Encrypt -- CORS configuration for your domain +- CORS configuration scoped to your Formbricks domain - Rate limiting middleware -- Secure credential generation +- Separate RustFS admin and Formbricks service credentials +- A `rustfs-init` job that creates the bucket and access policy + +The production setup from [formbricks.sh](https://github.com/formbricks/formbricks/blob/main/docker/formbricks.sh) +adds the reverse proxy wiring and bootstrap automation needed for long-lived deployments. -The production setup from [formbricks.sh](https://github.com/formbricks/formbricks/blob/main/docker/formbricks.sh) includes advanced features not covered in this manual setup. For production use, we strongly recommend using the one-click installer. + + Even in the one-click flow, bundled RustFS remains a convenience-oriented single-server deployment. For + higher availability, stricter operational requirements, or larger storage footprints, prefer external object + storage or a dedicated RustFS deployment managed separately from Formbricks. + ## Debug diff --git a/docs/self-hosting/setup/one-click.mdx b/docs/self-hosting/setup/one-click.mdx index 30c65eb770b5..694471624240 100644 --- a/docs/self-hosting/setup/one-click.mdx +++ b/docs/self-hosting/setup/one-click.mdx @@ -14,6 +14,26 @@ If you’re looking to quickly set up a production instance of Formbricks on an automatic SSL management via Let’s Encrypt. + + The bundled RustFS option in this one-click setup is designed for convenience on a single server. It is a + good fit for small-scale or low-complexity self-hosted deployments, but it is not the ideal RustFS + architecture for high-availability or larger production environments. For stricter production requirements, + use external object storage or run a dedicated RustFS deployment separately from the Formbricks one-click + stack. + + + + When bundled RustFS is enabled, the installer stores the generated RustFS credentials in `./formbricks/.env` + and restricts the file to `0600`. Keep that file private, include it in your server backup plan, and avoid + checking it into source control or copying it to shared locations. + + + + For better RustFS performance and stability, prefer local SSD or NVMe-backed storage for the host volume + behind `rustfs-data`. Avoid NFS and other network filesystems for bundled RustFS data, and use XFS on + dedicated storage disks when you manage the host layout yourself. + + For other operating systems or a more customized installation, please refer to the advanced installation guide with [Docker](/self-hosting/setup/docker). ### Requirements @@ -312,17 +332,22 @@ To restart Formbricks, simply run the following command: The script will automatically restart all the Formbricks related containers and brings the entire stack up with the previous configuration. -## Cleanup MinIO init (optional) +## Cleanup RustFS init (optional) -During the one-click setup, a temporary `minio-init` service configures MinIO (bucket, policy, service user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once configuration exists. +During the one-click setup, a temporary `rustfs-init` service configures RustFS (bucket, policy, service +user). It is idempotent and safe to leave in place; it will do nothing on subsequent starts once the +configuration exists. -If you prefer to remove the `minio-init` service and its references after a successful setup, run: +If you prefer to remove the `rustfs-init` service and its references after a successful setup, run: ``` -./formbricks.sh cleanup-minio-init +./formbricks.sh cleanup-rustfs-init ``` -This only removes the init job and its Compose references; it does not delete any data or affect your MinIO configuration. +`./formbricks.sh cleanup-minio-init` is still available as a backward-compatible alias. + +This only removes the init job and its Compose references; it does not delete any data or affect your RustFS +configuration. ## Uninstall diff --git a/package.json b/package.json index 21504f121071..b5becf289481 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,7 @@ "effect": "3.20.0", "flatted": "3.4.2", "hono": "4.12.7", + "lodash": "4.18.1", "@microsoft/api-extractor>minimatch": "10.2.4", "node-forge": ">=1.3.2", "qs": "6.14.2", @@ -108,7 +109,7 @@ "diff": ">=8.0.3" }, "comments": { - "overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | protobufjs (ENG-716 / CVE-2026-41242) - awaiting @boxyhq/saml-jackson and OpenTelemetry transitive updates | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption" + "overrides": "Security fixes for transitive dependencies. Remove when upstream packages update: @hono/node-server/hono (Dependabot #313/#316/#317) - awaiting Prisma update | @tootallnate/once (Dependabot #305) - awaiting sqlite3/node-gyp chain update | schema-utils@3>ajv (Dependabot #287) - awaiting eslint/file-loader schema-utils update | axios (CVE-2025-58754, CVE-2026-25639) - awaiting @boxyhq/saml-jackson update | protobufjs (ENG-716 / CVE-2026-41242) - awaiting @boxyhq/saml-jackson and OpenTelemetry transitive updates | effect (Dependabot #339) - awaiting Prisma update | flatted (Dependabot #324/#338) - awaiting eslint/flat-cache update | lodash (Dependabot #360) - awaiting @boxyhq/saml-jackson, Prisma/Chevrotain, and @microsoft/api-extractor transitive updates | minimatch (Dependabot #288/#294/#297) - awaiting react-email/glob update | node-forge (Dependabot #230) - awaiting @boxyhq/saml-jackson update | qs (Dependabot #277) - awaiting googleapis/googleapis-common update | rollup (Dependabot #291) - awaiting Vite patch adoption | socket.io-parser (Dependabot #334) - awaiting react-email/socket.io update | tar (CVE-2026-23745/23950/24842/26960) - awaiting @boxyhq/saml-jackson/sqlite3 dependency updates | typeorm (Dependabot #223) - awaiting @boxyhq/saml-jackson update | undici (Dependabot #319/#322/#323) - awaiting jsdom/vitest/isomorphic-dompurify updates | fast-xml-parser (CVE-2026-25896/26278/33036/33349) - awaiting exact upstream pin updates | diff (Dependabot #269) - awaiting upstream patch range adoption" }, "patchedDependencies": { "next-auth@4.24.13": "patches/next-auth@4.24.13.patch" diff --git a/packages/database/migration/20260416110000_backfill_legacy_sso_accounts/migration.ts b/packages/database/migration/20260416110000_backfill_legacy_sso_accounts/migration.ts new file mode 100644 index 000000000000..8a2cdcc7e7c8 --- /dev/null +++ b/packages/database/migration/20260416110000_backfill_legacy_sso_accounts/migration.ts @@ -0,0 +1,248 @@ +import { createId } from "@paralleldrive/cuid2"; +import type { MigrationScript } from "../../src/scripts/migration-runner"; + +export interface TSsoBackfillStats { + scanned: number; + inserted: number; + normalizedLegacyAccounts: number; + skippedConflict: number; + skippedExisting: number; + skippedMissingId: number; +} + +interface TMigrationTx { + $executeRaw: (query: TemplateStringsArray, ...values: readonly unknown[]) => Promise; + $queryRaw: (query: TemplateStringsArray, ...values: readonly unknown[]) => Promise; +} + +interface TLegacySsoUserRow { + id: string; + identityProvider: string; + identityProviderAccountId: string | null; +} + +interface TAccountRow { + id: string; + provider: string; + providerAccountId: string; + userId: string; +} + +const LEGACY_SSO_USER_BATCH_SIZE = 1000; + +const LEGACY_SSO_PROVIDER_MAP: Record = { + google: "google", + github: "github", + "azure-ad": "azuread", + azuread: "azuread", + openid: "openid", + saml: "saml", +}; + +export const normalizeLegacySsoProvider = (provider: string): string | null => + LEGACY_SSO_PROVIDER_MAP[provider] ?? null; + +const getAccountKey = (provider: string, providerAccountId: string): string => + `${provider}:${providerAccountId}`; + +const getUserProviderKey = (userId: string, provider: string): string => `${userId}:${provider}`; + +export const backfillLegacySsoAccounts = async (tx: TMigrationTx): Promise => { + const stats: TSsoBackfillStats = { + scanned: 0, + inserted: 0, + normalizedLegacyAccounts: 0, + skippedConflict: 0, + skippedExisting: 0, + skippedMissingId: 0, + }; + + const legacyAzureAccounts = await tx.$queryRaw` + SELECT "id", "userId", "provider", "providerAccountId" + FROM "Account" + WHERE "provider" = 'azure-ad' + `; + const canonicalAccounts = await tx.$queryRaw` + SELECT "id", "userId", "provider", "providerAccountId" + FROM "Account" + WHERE "provider" IN ('google', 'github', 'azuread', 'openid', 'saml') + `; + const canonicalAccountByKey = new Map( + canonicalAccounts.map((account) => [getAccountKey(account.provider, account.providerAccountId), account]) + ); + const canonicalAccountByUserProvider = new Map( + canonicalAccounts.map((account) => [getUserProviderKey(account.userId, account.provider), account]) + ); + + for (const legacyAccount of legacyAzureAccounts) { + const accountKey = getAccountKey("azuread", legacyAccount.providerAccountId); + const canonicalAccount = canonicalAccountByKey.get(accountKey); + const userProviderKey = getUserProviderKey(legacyAccount.userId, "azuread"); + const existingUserProviderAccount = canonicalAccountByUserProvider.get(userProviderKey); + + if (canonicalAccount) { + if (canonicalAccount.userId !== legacyAccount.userId) { + stats.skippedConflict += 1; + console.warn( + [ + "Skipping Azure account normalization due to ownership conflict.", + `provider=azuread`, + `legacyAccountId=${legacyAccount.id}`, + `legacyUserId=${legacyAccount.userId}`, + `canonicalAccountId=${canonicalAccount.id}`, + `canonicalUserId=${canonicalAccount.userId}`, + ].join(" ") + ); + continue; + } + + await tx.$executeRaw` + DELETE FROM "Account" + WHERE "id" = ${legacyAccount.id} + `; + stats.normalizedLegacyAccounts += 1; + continue; + } + + if (existingUserProviderAccount) { + stats.skippedExisting += 1; + console.warn( + [ + "Skipping Azure account normalization because a canonical account already exists.", + `provider=azuread`, + `legacyAccountId=${legacyAccount.id}`, + `legacyUserId=${legacyAccount.userId}`, + `canonicalAccountId=${existingUserProviderAccount.id}`, + `canonicalUserId=${existingUserProviderAccount.userId}`, + ].join(" ") + ); + continue; + } + + await tx.$executeRaw` + UPDATE "Account" + SET "provider" = 'azuread', + "updated_at" = NOW() + WHERE "id" = ${legacyAccount.id} + `; + stats.normalizedLegacyAccounts += 1; + canonicalAccountByKey.set(accountKey, { + ...legacyAccount, + provider: "azuread", + }); + canonicalAccountByUserProvider.set(userProviderKey, { + ...legacyAccount, + provider: "azuread", + }); + } + + const fetchLegacySsoUserBatch = async (afterUserId: string | null): Promise => { + if (afterUserId) { + return tx.$queryRaw` + SELECT "id", "identityProvider", "identityProviderAccountId" + FROM "User" + WHERE "identityProvider" <> 'email' + AND "id" > ${afterUserId} + ORDER BY "id" ASC + LIMIT ${LEGACY_SSO_USER_BATCH_SIZE} + `; + } + + return tx.$queryRaw` + SELECT "id", "identityProvider", "identityProviderAccountId" + FROM "User" + WHERE "identityProvider" <> 'email' + ORDER BY "id" ASC + LIMIT ${LEGACY_SSO_USER_BATCH_SIZE} + `; + }; + + let lastProcessedUserId: string | null = null; + let hasMoreUsers = true; + + while (hasMoreUsers) { + const legacySsoUsers = await fetchLegacySsoUserBatch(lastProcessedUserId); + + if (legacySsoUsers.length === 0) { + hasMoreUsers = false; + continue; + } + + stats.scanned += legacySsoUsers.length; + + for (const user of legacySsoUsers) { + const provider = normalizeLegacySsoProvider(user.identityProvider); + + if (!provider || !user.identityProviderAccountId) { + stats.skippedMissingId += 1; + continue; + } + + const accountKey = getAccountKey(provider, user.identityProviderAccountId); + const existingAccount = canonicalAccountByKey.get(accountKey); + const userProviderKey = getUserProviderKey(user.id, provider); + const existingUserProviderAccount = canonicalAccountByUserProvider.get(userProviderKey); + + if (!existingAccount) { + if (existingUserProviderAccount) { + stats.skippedExisting += 1; + console.warn("Skipping legacy SSO backfill because a canonical account already exists."); + continue; + } + + const insertedAccountId = createId(); + await tx.$executeRaw` + INSERT INTO "Account" ("id", "created_at", "updated_at", "userId", "type", "provider", "providerAccountId") + VALUES (${insertedAccountId}, NOW(), NOW(), ${user.id}, 'oauth', ${provider}, ${user.identityProviderAccountId}) + `; + stats.inserted += 1; + canonicalAccountByKey.set(accountKey, { + id: insertedAccountId, + userId: user.id, + provider, + providerAccountId: user.identityProviderAccountId, + }); + canonicalAccountByUserProvider.set(userProviderKey, { + id: insertedAccountId, + userId: user.id, + provider, + providerAccountId: user.identityProviderAccountId, + }); + continue; + } + + if (existingAccount.userId === user.id) { + stats.skippedExisting += 1; + continue; + } + + stats.skippedConflict += 1; + console.warn(`Skipping legacy SSO backfill due to ownership conflict for provider ${provider}.`); + } + + lastProcessedUserId = legacySsoUsers[legacySsoUsers.length - 1].id; + } + + console.log( + [ + "Legacy SSO account backfill completed.", + `scanned=${String(stats.scanned)}`, + `normalizedLegacyAccounts=${String(stats.normalizedLegacyAccounts)}`, + `inserted=${String(stats.inserted)}`, + `skippedExisting=${String(stats.skippedExisting)}`, + `skippedConflict=${String(stats.skippedConflict)}`, + `skippedMissingId=${String(stats.skippedMissingId)}`, + ].join(" ") + ); + + return stats; +}; + +export const backfillLegacySsoAccountsMigration: MigrationScript = { + type: "data", + id: "yukzmjww0s5y9akghq8v2f8c", + name: "20260416110000_backfill_legacy_sso_accounts", + run: async ({ tx }) => { + await backfillLegacySsoAccounts(tx); + }, +}; diff --git a/packages/storage/.cursor/rules/storage-package.md b/packages/storage/.cursor/rules/storage-package.md index c5a7c7be0006..09b3145b7c67 100644 --- a/packages/storage/.cursor/rules/storage-package.md +++ b/packages/storage/.cursor/rules/storage-package.md @@ -2,7 +2,7 @@ ## Package Purpose & Design Philosophy -The `@formbricks/storage` package provides a **type-safe, environment-agnostic S3 storage abstraction** for Formbricks. It's designed as a standalone library that can work with any S3-compatible storage provider (AWS S3, MinIO, LocalStack, etc.). +The `@formbricks/storage` package provides a **type-safe, environment-agnostic S3 storage abstraction** for Formbricks. It's designed as a standalone library that can work with any S3-compatible storage provider (AWS S3, RustFS, LocalStack, etc.). ### Key Design Decisions @@ -122,8 +122,8 @@ S3_BUCKET_NAME=formbricks-storage ### Optional Variables (for non-AWS providers) ```bash -S3_ENDPOINT_URL=http://localhost:9000 # MinIO/LocalStack -S3_FORCE_PATH_STYLE=1 # Required for MinIO +S3_ENDPOINT_URL=http://localhost:9000 # RustFS/LocalStack +S3_FORCE_PATH_STYLE=1 # Common for RustFS and many third-party S3-compatible providers ``` ### Configuration Validation @@ -310,4 +310,4 @@ const s3Client = new S3Client({ **Returns**: `Result<{ deletedCount: number; partialFailures?: string[] }, StorageError>` **Use Case**: Cleanup entire folders when surveys/users are deleted -Remember: This package is designed to be **infrastructure-agnostic** and **error-resilient**. It should work seamlessly whether you're using AWS S3, MinIO for local development, or any other S3-compatible storage provider. +Remember: This package is designed to be **infrastructure-agnostic** and **error-resilient**. It should work seamlessly whether you're using AWS S3, RustFS for local or self-hosted storage, or any other S3-compatible storage provider. diff --git a/packages/storage/src/rustfs-init-bootstrap.test.ts b/packages/storage/src/rustfs-init-bootstrap.test.ts new file mode 100644 index 000000000000..9e77f9a4e3ad --- /dev/null +++ b/packages/storage/src/rustfs-init-bootstrap.test.ts @@ -0,0 +1,269 @@ +import { execFileSync } from "node:child_process"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, test } from "vitest"; + +const formbricksScriptPath = fileURLToPath(new URL("../../../docker/formbricks.sh", import.meta.url)); +const rustfsInitTemplatePath = fileURLToPath(new URL("../../../docker/rustfs-init.sh", import.meta.url)); + +const tempDirs: string[] = []; + +const createTempDir = (): string => { + const tempDir = mkdtempSync(join(tmpdir(), "formbricks-rustfs-init-")); + tempDirs.push(tempDir); + return tempDir; +}; + +const writeMockMc = (tempDir: string): void => { + const binDir = join(tempDir, "bin"); + mkdirSync(binDir, { recursive: true }); + + const mockMcPath = join(binDir, "mc"); + writeFileSync( + mockMcPath, + `#!/bin/sh +set -eu + +log_file="\${MC_LOG_FILE:?}" +capture_dir="\${MC_CAPTURE_DIR:?}" + +printf '%s\\n' "$*" >> "$log_file" + +if [ "$1" = "alias" ] && [ "$2" = "set" ] && [ "$3" = "rustfs" ]; then + if [ "\${MC_ALIAS_SET_ALWAYS_FAIL:-0}" = "1" ]; then + exit 1 + fi + exit 0 +fi + +if [ "$1" = "ls" ] && [ "$2" = "rustfs" ]; then + exit 0 +fi + +if [ "$1" = "mb" ] && [ "$2" = "rustfs/formbricks" ] && [ "$3" = "--ignore-existing" ]; then + exit 0 +fi + +if [ "$1" = "cors" ] && [ "$2" = "set" ] && [ "$3" = "rustfs/formbricks" ]; then + cp "$4" "$capture_dir/cors.xml" + exit 0 +fi + +if [ "$1" = "admin" ] && [ "$2" = "policy" ] && [ "$3" = "info" ]; then + exit 1 +fi + +if [ "$1" = "admin" ] && [ "$2" = "policy" ] && [ "$3" = "create" ]; then + if [ "\${MC_POLICY_CREATE_FAIL:-0}" = "1" ]; then + exit 1 + fi + cp "$6" "$capture_dir/policy.json" + exit 0 +fi + +if [ "$1" = "admin" ] && [ "$2" = "policy" ] && [ "$3" = "add" ]; then + cp "$6" "$capture_dir/policy.json" + exit 0 +fi + +if [ "$1" = "admin" ] && [ "$2" = "user" ] && [ "$3" = "info" ]; then + exit 1 +fi + +if [ "$1" = "admin" ] && [ "$2" = "user" ] && [ "$3" = "add" ]; then + exit 0 +fi + +if [ "$1" = "admin" ] && [ "$2" = "policy" ] && [ "$3" = "attach" ]; then + exit 0 +fi + +printf 'unexpected mc invocation: %s\\n' "$*" >&2 +exit 1 +` + ); + chmodSync(mockMcPath, 0o755); + + const mockSleepPath = join(binDir, "sleep"); + writeFileSync( + mockSleepPath, + `#!/bin/sh +exit 0 +` + ); + chmodSync(mockSleepPath, 0o755); +}; + +const writeRustfsInitScript = (targetPath: string): void => { + execFileSync( + "bash", + ["-lc", 'source "$1"; write_rustfs_init_script "$2"', "bash", formbricksScriptPath, targetPath], + { encoding: "utf8" } + ); +}; + +afterEach(() => { + for (const tempDir of tempDirs.splice(0)) { + rmSync(tempDir, { recursive: true, force: true }); + } + rmSync("/tmp/formbricks-cors.xml", { force: true }); + rmSync("/tmp/formbricks-policy.json", { force: true }); +}); + +describe("docker/formbricks.sh RustFS bootstrap", () => { + test("generated init script stays in sync with the checked-in dev bootstrap script", () => { + const tempDir = createTempDir(); + const generatedScriptPath = join(tempDir, "rustfs-init.sh"); + + writeRustfsInitScript(generatedScriptPath); + + expect(readFileSync(generatedScriptPath, "utf8")).toBe(readFileSync(rustfsInitTemplatePath, "utf8")); + }); + + test("generated init script provisions a bucket-scoped policy for the service user", () => { + const tempDir = createTempDir(); + const generatedScriptPath = join(tempDir, "rustfs-init.sh"); + const logFile = join(tempDir, "mc.log"); + const captureDir = join(tempDir, "capture"); + + mkdirSync(captureDir, { recursive: true }); + writeMockMc(tempDir); + writeRustfsInitScript(generatedScriptPath); + + execFileSync(generatedScriptPath, { + cwd: tempDir, + encoding: "utf8", + env: { + ...process.env, + PATH: `${join(tempDir, "bin")}:${process.env.PATH ?? ""}`, + MC_LOG_FILE: logFile, + MC_CAPTURE_DIR: captureDir, + RUSTFS_ADMIN_USER: "admin-user", + RUSTFS_ADMIN_PASSWORD: "admin-password", + RUSTFS_SERVICE_USER: "service-user", + RUSTFS_SERVICE_PASSWORD: "service-password", + RUSTFS_BUCKET_NAME: "formbricks", + RUSTFS_POLICY_NAME: "formbricks-app-policy", + RUSTFS_CORS_ALLOWED_ORIGINS: "http://localhost:3000,http://127.0.0.1:3000", + }, + }); + + const mcCalls = readFileSync(logFile, "utf8").trim().split("\n"); + + expect(mcCalls).toEqual([ + "alias set rustfs http://rustfs:9000 admin-user admin-password", + "ls rustfs", + "mb rustfs/formbricks --ignore-existing", + "cors set rustfs/formbricks /tmp/formbricks-cors.xml", + "admin policy info rustfs formbricks-app-policy", + "admin policy create rustfs formbricks-app-policy /tmp/formbricks-policy.json", + "admin user info rustfs service-user", + "admin user add rustfs service-user service-password", + "admin policy attach rustfs formbricks-app-policy --user service-user", + ]); + + const policy = JSON.parse(readFileSync(join(captureDir, "policy.json"), "utf8")) as { + Version: string; + Statement: { Action: string[]; Effect: string; Resource: string[] }[]; + }; + + expect(policy).toEqual({ + Version: "2012-10-17", + Statement: [ + { + Effect: "Allow", + Action: ["s3:DeleteObject", "s3:GetObject", "s3:PutObject"], + Resource: ["arn:aws:s3:::formbricks/*"], + }, + { + Effect: "Allow", + Action: ["s3:ListBucket"], + Resource: ["arn:aws:s3:::formbricks"], + }, + ], + }); + + expect(readFileSync(join(captureDir, "cors.xml"), "utf8")).toBe(` + + http://localhost:3000 + http://127.0.0.1:3000 + GET + HEAD + POST + PUT + DELETE + * + ETag + 3000 + + +`); + }); + + test("generated init script falls back to policy add when policy create is unavailable", () => { + const tempDir = createTempDir(); + const generatedScriptPath = join(tempDir, "rustfs-init.sh"); + const logFile = join(tempDir, "mc.log"); + const captureDir = join(tempDir, "capture"); + + mkdirSync(captureDir, { recursive: true }); + writeMockMc(tempDir); + writeRustfsInitScript(generatedScriptPath); + + execFileSync(generatedScriptPath, { + cwd: tempDir, + encoding: "utf8", + env: { + ...process.env, + PATH: `${join(tempDir, "bin")}:${process.env.PATH ?? ""}`, + MC_LOG_FILE: logFile, + MC_CAPTURE_DIR: captureDir, + MC_POLICY_CREATE_FAIL: "1", + RUSTFS_ADMIN_USER: "admin-user", + RUSTFS_ADMIN_PASSWORD: "admin-password", + RUSTFS_SERVICE_USER: "service-user", + RUSTFS_SERVICE_PASSWORD: "service-password", + RUSTFS_BUCKET_NAME: "formbricks", + RUSTFS_POLICY_NAME: "formbricks-app-policy", + }, + }); + + const mcCalls = readFileSync(logFile, "utf8").trim().split("\n"); + + expect(mcCalls).toContain("admin policy create rustfs formbricks-app-policy /tmp/formbricks-policy.json"); + expect(mcCalls).toContain("admin policy add rustfs formbricks-app-policy /tmp/formbricks-policy.json"); + }); + + test("generated init script exits non-zero when RustFS never becomes ready", () => { + const tempDir = createTempDir(); + const generatedScriptPath = join(tempDir, "rustfs-init.sh"); + const logFile = join(tempDir, "mc.log"); + const captureDir = join(tempDir, "capture"); + + mkdirSync(captureDir, { recursive: true }); + writeMockMc(tempDir); + writeRustfsInitScript(generatedScriptPath); + + expect(() => + execFileSync(generatedScriptPath, { + cwd: tempDir, + encoding: "utf8", + env: { + ...process.env, + PATH: `${join(tempDir, "bin")}:${process.env.PATH ?? ""}`, + MC_LOG_FILE: logFile, + MC_CAPTURE_DIR: captureDir, + MC_ALIAS_SET_ALWAYS_FAIL: "1", + RUSTFS_ADMIN_USER: "admin-user", + RUSTFS_ADMIN_PASSWORD: "admin-password", + RUSTFS_SERVICE_USER: "service-user", + RUSTFS_SERVICE_PASSWORD: "service-password", + RUSTFS_BUCKET_NAME: "formbricks", + RUSTFS_POLICY_NAME: "formbricks-app-policy", + }, + }) + ).toThrow(); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a923cded475b..57c04a301a8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,6 +18,7 @@ overrides: effect: 3.20.0 flatted: 3.4.2 hono: 4.12.7 + lodash: 4.18.1 '@microsoft/api-extractor>minimatch': 10.2.4 node-forge: '>=1.3.2' qs: 6.14.2 @@ -8954,12 +8955,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash@4.17.21: - resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - - lodash@4.17.23: - resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} - lodash@4.18.1: resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} @@ -13503,7 +13498,7 @@ snapshots: encoding: 0.1.13 ipaddr.js: 2.2.0 jose: 6.0.11 - lodash: 4.17.21 + lodash: 4.18.1 mixpanel: 0.18.1 mongodb: 6.16.0(@aws-sdk/credential-providers@3.817.0)(socks@2.8.7) mssql: 11.0.1 @@ -13563,13 +13558,13 @@ snapshots: dependencies: '@chevrotain/gast': 10.5.0 '@chevrotain/types': 10.5.0 - lodash: 4.17.21 + lodash: 4.18.1 optional: true '@chevrotain/gast@10.5.0': dependencies: '@chevrotain/types': 10.5.0 - lodash: 4.17.21 + lodash: 4.18.1 optional: true '@chevrotain/types@10.5.0': @@ -14362,7 +14357,7 @@ snapshots: '@rushstack/terminal': 0.22.3(@types/node@25.4.0) '@rushstack/ts-command-line': 5.3.3(@types/node@25.4.0) diff: 8.0.3 - lodash: 4.17.23 + lodash: 4.18.1 minimatch: 10.2.4 resolve: 1.22.11 semver: 7.5.4 @@ -19278,7 +19273,7 @@ snapshots: '@chevrotain/gast': 10.5.0 '@chevrotain/types': 10.5.0 '@chevrotain/utils': 10.5.0 - lodash: 4.17.21 + lodash: 4.18.1 regexp-to-ast: 0.5.0 optional: true @@ -21584,10 +21579,6 @@ snapshots: lodash.once@4.1.1: {} - lodash@4.17.21: {} - - lodash@4.17.23: {} - lodash@4.18.1: {} log-symbols@6.0.0: