mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-26 16:30:21 -06:00
Compare commits
13 Commits
dhru-test
...
fix/e2e-ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c93c35edfd | ||
|
|
b67177ba55 | ||
|
|
6cf1f49c8e | ||
|
|
4afb95b92a | ||
|
|
38089241b4 | ||
|
|
07487d4871 | ||
|
|
fa0879e3a0 | ||
|
|
3733c22a6f | ||
|
|
5e5baa76ab | ||
|
|
2153d2aa16 | ||
|
|
7fa4862fd9 | ||
|
|
411e9a26ee | ||
|
|
eb1349f205 |
11
.github/workflows/e2e.yml
vendored
11
.github/workflows/e2e.yml
vendored
@@ -181,6 +181,12 @@ jobs:
|
||||
fi
|
||||
echo "License key length: ${#LICENSE_KEY}"
|
||||
|
||||
- name: Disable rate limiting for E2E tests
|
||||
run: |
|
||||
echo "RATE_LIMITING_DISABLED=1" >> .env
|
||||
echo "Rate limiting disabled for E2E tests"
|
||||
shell: bash
|
||||
|
||||
- name: Run App
|
||||
run: |
|
||||
echo "Starting app with enterprise license..."
|
||||
@@ -222,11 +228,14 @@ jobs:
|
||||
if: env.AZURE_ENABLED == 'true'
|
||||
env:
|
||||
PLAYWRIGHT_SERVICE_URL: ${{ secrets.PLAYWRIGHT_SERVICE_URL }}
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test-e2e:azure
|
||||
|
||||
- name: Run E2E Tests (Local)
|
||||
if: env.AZURE_ENABLED == 'false'
|
||||
env:
|
||||
CI: true
|
||||
run: |
|
||||
pnpm test:e2e
|
||||
|
||||
@@ -245,4 +254,4 @@ jobs:
|
||||
|
||||
- name: Output App Logs
|
||||
if: failure()
|
||||
run: cat app.log
|
||||
run: cat app.log
|
||||
6
.github/workflows/move-stable-tag.yml
vendored
6
.github/workflows/move-stable-tag.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: "The release tag name (e.g., v1.2.3)"
|
||||
description: "The release tag name (e.g., 1.2.3)"
|
||||
required: true
|
||||
type: string
|
||||
commit_sha:
|
||||
@@ -53,8 +53,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
# Validate release tag format
|
||||
if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format. Expected format: v1.2.3, v1.2.3-alpha"
|
||||
if [[ ! "$RELEASE_TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "❌ Error: Invalid release tag format. Expected format: 1.2.3, 1.2.3-alpha"
|
||||
echo "Provided: $RELEASE_TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -120,7 +120,7 @@ describe("PasswordConfirmationModal", () => {
|
||||
const confirmButton = screen.getByText("common.confirm");
|
||||
await user.click(confirmButton);
|
||||
|
||||
expect(screen.getByText("String must contain at least 8 character(s)")).toBeInTheDocument();
|
||||
expect(screen.getByText("Password must be at least 8 characters long")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("handles cancel button click and resets form", async () => {
|
||||
|
||||
@@ -114,7 +114,7 @@ export const MAX_FILE_UPLOAD_SIZES = {
|
||||
standard: 1024 * 1024 * 10, // 10MB
|
||||
big: 1024 * 1024 * 1024, // 1GB
|
||||
} as const;
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_ACCESS_KEY && S3_SECRET_KEY && S3_REGION && S3_BUCKET_NAME);
|
||||
export const IS_STORAGE_CONFIGURED = Boolean(S3_BUCKET_NAME);
|
||||
|
||||
// Colors for Survey Bg
|
||||
export const SURVEY_BG_COLORS = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { env } from "@/lib/env";
|
||||
import jwt from "jsonwebtoken";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import {
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
@@ -14,12 +15,69 @@ import {
|
||||
verifyTokenForLinkSurvey,
|
||||
} from "./jwt";
|
||||
|
||||
const TEST_ENCRYPTION_KEY = "0".repeat(32); // 32-byte key for AES-256-GCM
|
||||
const TEST_NEXTAUTH_SECRET = "test-nextauth-secret";
|
||||
const DIFFERENT_SECRET = "different-secret";
|
||||
|
||||
// Error message constants
|
||||
const NEXTAUTH_SECRET_ERROR = "NEXTAUTH_SECRET is not set";
|
||||
const ENCRYPTION_KEY_ERROR = "ENCRYPTION_KEY is not set";
|
||||
|
||||
// Helper function to test error cases for missing secrets/keys
|
||||
const testMissingSecretsError = async (
|
||||
testFn: (...args: any[]) => any,
|
||||
args: any[],
|
||||
options: {
|
||||
testNextAuthSecret?: boolean;
|
||||
testEncryptionKey?: boolean;
|
||||
isAsync?: boolean;
|
||||
} = {}
|
||||
) => {
|
||||
const { testNextAuthSecret = true, testEncryptionKey = true, isAsync = false } = options;
|
||||
|
||||
if (testNextAuthSecret) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(NEXTAUTH_SECRET_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
|
||||
if (testEncryptionKey) {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
if (isAsync) {
|
||||
await expect(testFn(...args)).rejects.toThrow(ENCRYPTION_KEY_ERROR);
|
||||
} else {
|
||||
expect(() => testFn(...args)).toThrow(ENCRYPTION_KEY_ERROR);
|
||||
}
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
}
|
||||
};
|
||||
|
||||
// Mock environment variables
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
ENCRYPTION_KEY: "0".repeat(32), // 32-byte key for AES-256-GCM
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
} as typeof env,
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
NEXTAUTH_SECRET: "test-nextauth-secret",
|
||||
ENCRYPTION_KEY: "0".repeat(32),
|
||||
}));
|
||||
|
||||
// Mock prisma
|
||||
@@ -31,22 +89,65 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions", () => {
|
||||
// Mock logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
const mockUser = {
|
||||
id: "test-user-id",
|
||||
email: "test@example.com",
|
||||
};
|
||||
|
||||
let mockSymmetricEncrypt: any;
|
||||
let mockSymmetricDecrypt: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Setup default crypto mocks
|
||||
mockSymmetricEncrypt = vi
|
||||
.spyOn(crypto, "symmetricEncrypt")
|
||||
.mockImplementation((text: string) => `encrypted_${text}`);
|
||||
|
||||
mockSymmetricDecrypt = vi
|
||||
.spyOn(crypto, "symmetricDecrypt")
|
||||
.mockImplementation((encryptedText: string) => encryptedText.replace("encrypted_", ""));
|
||||
|
||||
(prisma.user.findUnique as any).mockResolvedValue(mockUser);
|
||||
});
|
||||
|
||||
describe("createToken", () => {
|
||||
test("should create a valid token", () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
test("should create a valid token with encrypted user ID", () => {
|
||||
const token = createToken(mockUser.id);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const customOptions = { expiresIn: "1h" };
|
||||
const token = createToken(mockUser.id, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
// Verify the token contains the expected expiration
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 hour (3600 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(3600);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(createToken, [mockUser.id], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,6 +157,18 @@ describe("JWT Functions", () => {
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should include surveyId in payload", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.surveyId).toBe(surveyId);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createTokenForLinkSurvey, ["survey-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,24 +177,30 @@ describe("JWT Functions", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", () => {
|
||||
const originalSecret = env.NEXTAUTH_SECRET;
|
||||
try {
|
||||
(env as any).NEXTAUTH_SECRET = undefined;
|
||||
expect(() => createEmailToken(mockUser.email)).toThrow("NEXTAUTH_SECRET is not set");
|
||||
} finally {
|
||||
(env as any).NEXTAUTH_SECRET = originalSecret;
|
||||
}
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailToken, [mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
describe("createEmailChangeToken", () => {
|
||||
test("should create a valid email change token with 1 day expiration", () => {
|
||||
const token = createEmailChangeToken(mockUser.id, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.id, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 1 day (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createEmailChangeToken, [mockUser.id, mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,6 +210,50 @@ describe("JWT Functions", () => {
|
||||
const token = createInviteToken(inviteId, mockUser.email);
|
||||
expect(token).toBeDefined();
|
||||
expect(typeof token).toBe("string");
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(inviteId, TEST_ENCRYPTION_KEY);
|
||||
expect(mockSymmetricEncrypt).toHaveBeenCalledWith(mockUser.email, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should accept custom options", () => {
|
||||
const inviteId = "test-invite-id";
|
||||
const customOptions = { expiresIn: "24h" };
|
||||
const token = createInviteToken(inviteId, mockUser.email, customOptions);
|
||||
expect(token).toBeDefined();
|
||||
|
||||
const decoded = jwt.decode(token) as any;
|
||||
expect(decoded.exp).toBeDefined();
|
||||
expect(decoded.iat).toBeDefined();
|
||||
// Should expire in approximately 24 hours (86400 seconds)
|
||||
expect(decoded.exp - decoded.iat).toBe(86400);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(createInviteToken, ["invite-id", mockUser.email]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getEmailFromEmailToken", () => {
|
||||
test("should extract email from valid token", () => {
|
||||
const token = createEmailToken(mockUser.email);
|
||||
const extractedEmail = getEmailFromEmailToken(token);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
expect(mockSymmetricDecrypt).toHaveBeenCalledWith(`encrypted_${mockUser.email}`, TEST_ENCRYPTION_KEY);
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create token manually with unencrypted email for legacy compatibility
|
||||
const legacyToken = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
const extractedEmail = getEmailFromEmailToken(legacyToken);
|
||||
expect(extractedEmail).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await testMissingSecretsError(getEmailFromEmailToken, [token]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,23 +269,194 @@ describe("JWT Functions", () => {
|
||||
const result = verifyTokenForLinkSurvey("invalid-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if NEXTAUTH_SECRET is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalSecret = (constants as any).NEXTAUTH_SECRET;
|
||||
(constants as any).NEXTAUTH_SECRET = undefined;
|
||||
|
||||
const result = verifyTokenForLinkSurvey("any-token", "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Restore
|
||||
(constants as any).NEXTAUTH_SECRET = originalSecret;
|
||||
});
|
||||
|
||||
test("should return null if surveyId doesn't match", () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const differentSurveyId = "different-survey-id";
|
||||
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, differentSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should return null if email is missing from payload", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ surveyId: "test-survey-id" }, TEST_NEXTAUTH_SECRET);
|
||||
const result = verifyTokenForLinkSurvey(tokenWithoutEmail, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should fall back to original email if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementationOnce(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
// Create legacy token with unencrypted email
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, "test-survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should fall back to original email if ENCRYPTION_KEY is not set", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
// Create a token with unencrypted email (as it would be if ENCRYPTION_KEY was not set during creation)
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: mockUser.email,
|
||||
surveyId: "survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "survey-id");
|
||||
expect(result).toBe(mockUser.email);
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
|
||||
test("should verify legacy survey tokens with surveyId-based secret", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + surveyId)
|
||||
const legacyToken = jwt.sign({ email: `encrypted_${mockUser.email}` }, TEST_NEXTAUTH_SECRET + surveyId);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should reject survey tokens that fail both new and legacy verification", async () => {
|
||||
const surveyId = "test-survey-id";
|
||||
const invalidToken = jwt.sign({ email: "encrypted_test@example.com" }, "wrong-secret");
|
||||
|
||||
const result = verifyTokenForLinkSurvey(invalidToken, surveyId);
|
||||
expect(result).toBeNull();
|
||||
|
||||
// Verify error logging
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.any(Error), "Survey link token verification failed");
|
||||
});
|
||||
|
||||
test("should reject legacy survey tokens for wrong survey", () => {
|
||||
const correctSurveyId = "correct-survey-id";
|
||||
const wrongSurveyId = "wrong-survey-id";
|
||||
|
||||
// Create legacy token for one survey
|
||||
const legacyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + correctSurveyId
|
||||
);
|
||||
|
||||
// Try to verify with different survey ID
|
||||
const result = verifyTokenForLinkSurvey(legacyToken, wrongSurveyId);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyToken", () => {
|
||||
test("should verify valid token", async () => {
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const token = createToken(mockUser.id);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id,
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error if user not found", async () => {
|
||||
(prisma.user.findUnique as any).mockResolvedValue(null);
|
||||
const token = createToken(mockUser.id, mockUser.email);
|
||||
const token = createToken(mockUser.id);
|
||||
await expect(verifyToken(token)).rejects.toThrow("User not found");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET is not set", async () => {
|
||||
await testMissingSecretsError(verifyToken, ["any-token"], {
|
||||
testNextAuthSecret: true,
|
||||
testEncryptionKey: false,
|
||||
isAsync: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for invalid token signature", async () => {
|
||||
const invalidToken = jwt.sign({ id: "test-id" }, DIFFERENT_SECRET);
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should throw error if token payload is missing id", async () => {
|
||||
const tokenWithoutId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyToken(tokenWithoutId)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should return raw id from payload", async () => {
|
||||
// Create token with unencrypted id
|
||||
const token = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const verified = await verifyToken(token);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the raw ID from payload
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should verify legacy tokens with email-based secret", async () => {
|
||||
// Create legacy token with old format (NEXTAUTH_SECRET + userEmail)
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
const verified = await verifyToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
id: mockUser.id, // Returns the decrypted user ID
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should prioritize new tokens over legacy tokens", async () => {
|
||||
// Create both new and legacy tokens for the same user
|
||||
const newToken = createToken(mockUser.id);
|
||||
const legacyToken = jwt.sign({ id: `encrypted_${mockUser.id}` }, TEST_NEXTAUTH_SECRET + mockUser.email);
|
||||
|
||||
// New token should verify without triggering legacy path
|
||||
const verifiedNew = await verifyToken(newToken);
|
||||
expect(verifiedNew.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Legacy token should trigger legacy path
|
||||
const verifiedLegacy = await verifyToken(legacyToken);
|
||||
expect(verifiedLegacy.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
|
||||
test("should reject tokens that fail both new and legacy verification", async () => {
|
||||
const invalidToken = jwt.sign({ id: "encrypted_test-id" }, "wrong-secret");
|
||||
await expect(verifyToken(invalidToken)).rejects.toThrow("Invalid token");
|
||||
|
||||
// Verify both methods were attempted
|
||||
const { logger } = await import("@formbricks/logger");
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with new method"
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.any(Error),
|
||||
"Token verification failed with legacy method"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyInviteToken", () => {
|
||||
@@ -139,6 +473,53 @@ describe("JWT Functions", () => {
|
||||
test("should throw error for invalid token", () => {
|
||||
expect(() => verifyInviteToken("invalid-token")).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyInviteToken, ["any-token"]);
|
||||
});
|
||||
|
||||
test("should throw error if inviteId is missing", () => {
|
||||
const tokenWithoutInviteId = jwt.sign({ email: mockUser.email }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutInviteId)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", () => {
|
||||
const tokenWithoutEmail = jwt.sign({ inviteId: "test-invite-id" }, TEST_NEXTAUTH_SECRET);
|
||||
expect(() => verifyInviteToken(tokenWithoutEmail)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should fall back to original values if decryption fails", () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const inviteId = "test-invite-id";
|
||||
const legacyToken = jwt.sign(
|
||||
{
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const verified = verifyInviteToken(legacyToken);
|
||||
expect(verified).toEqual({
|
||||
inviteId,
|
||||
email: mockUser.email,
|
||||
});
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
inviteId: "test-invite-id",
|
||||
email: mockUser.email,
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
expect(() => verifyInviteToken(invalidToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyEmailChangeToken", () => {
|
||||
@@ -150,22 +531,478 @@ describe("JWT Functions", () => {
|
||||
expect(result).toEqual({ id: userId, email });
|
||||
});
|
||||
|
||||
test("should throw error if NEXTAUTH_SECRET or ENCRYPTION_KEY is not set", async () => {
|
||||
await testMissingSecretsError(verifyEmailChangeToken, ["any-token"], { isAsync: true });
|
||||
});
|
||||
|
||||
test("should throw error if token is invalid or missing fields", async () => {
|
||||
// Create a token with missing fields
|
||||
const jwt = await import("jsonwebtoken");
|
||||
const token = jwt.sign({ foo: "bar" }, env.NEXTAUTH_SECRET as string);
|
||||
const token = jwt.sign({ foo: "bar" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if id is missing", async () => {
|
||||
const token = jwt.sign({ email: "test@example.com" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if email is missing", async () => {
|
||||
const token = jwt.sign({ id: "test-id" }, TEST_NEXTAUTH_SECRET);
|
||||
await expect(verifyEmailChangeToken(token)).rejects.toThrow(
|
||||
"Token is invalid or missing required fields"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return original id/email if decryption fails", async () => {
|
||||
// Create a token with non-encrypted id/email
|
||||
const jwt = await import("jsonwebtoken");
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Decryption failed");
|
||||
});
|
||||
|
||||
const payload = { id: "plain-id", email: "plain@example.com" };
|
||||
const token = jwt.sign(payload, env.NEXTAUTH_SECRET as string);
|
||||
const token = jwt.sign(payload, TEST_NEXTAUTH_SECRET);
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
test("should throw error for token with wrong signature", async () => {
|
||||
const invalidToken = jwt.sign(
|
||||
{
|
||||
id: "test-id",
|
||||
email: "test@example.com",
|
||||
},
|
||||
DIFFERENT_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(invalidToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// SECURITY SCENARIO TESTS
|
||||
describe("Security Scenarios", () => {
|
||||
describe("Algorithm Confusion Attack Prevention", () => {
|
||||
test("should reject 'none' algorithm tokens in verifyToken", async () => {
|
||||
// Create malicious token with "none" algorithm
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyTokenForLinkSurvey", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
email: "encrypted_attacker@evil.com",
|
||||
surveyId: "test-survey-id",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
const result = verifyTokenForLinkSurvey(maliciousToken, "test-survey-id");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyInviteToken", () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
inviteId: "encrypted_malicious-invite",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
expect(() => verifyInviteToken(maliciousToken)).toThrow("Invalid or expired invite token");
|
||||
});
|
||||
|
||||
test("should reject 'none' algorithm tokens in verifyEmailChangeToken", async () => {
|
||||
const maliciousToken =
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "none",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url") +
|
||||
"." +
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
email: "encrypted_attacker@evil.com",
|
||||
})
|
||||
).toString("base64url") +
|
||||
".";
|
||||
|
||||
await expect(verifyEmailChangeToken(maliciousToken)).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("should reject RS256 algorithm tokens (HS256/RS256 confusion)", async () => {
|
||||
// Create malicious token with RS256 algorithm header but HS256 signature
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg: "RS256",
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_malicious-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
// Create signature using HMAC (as if it were HS256)
|
||||
const crypto = require("crypto");
|
||||
const signature = crypto
|
||||
.createHmac("sha256", TEST_NEXTAUTH_SECRET)
|
||||
.update(`${maliciousHeader}.${maliciousPayload}`)
|
||||
.digest("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should only accept HS256 algorithm", async () => {
|
||||
// Test that other valid algorithms are rejected
|
||||
const otherAlgorithms = ["HS384", "HS512", "RS256", "RS384", "RS512", "ES256", "ES384", "ES512"];
|
||||
|
||||
for (const alg of otherAlgorithms) {
|
||||
const maliciousHeader = Buffer.from(
|
||||
JSON.stringify({
|
||||
alg,
|
||||
typ: "JWT",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousPayload = Buffer.from(
|
||||
JSON.stringify({
|
||||
id: "encrypted_test-id",
|
||||
})
|
||||
).toString("base64url");
|
||||
|
||||
const maliciousToken = `${maliciousHeader}.${maliciousPayload}.fake-signature`;
|
||||
|
||||
await expect(verifyToken(maliciousToken)).rejects.toThrow("Invalid token");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Tampering", () => {
|
||||
test("should reject tokens with modified payload", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload, signature] = token.split(".");
|
||||
|
||||
// Modify the payload
|
||||
const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString());
|
||||
decodedPayload.id = "malicious-id";
|
||||
const tamperedPayload = Buffer.from(JSON.stringify(decodedPayload)).toString("base64url");
|
||||
const tamperedToken = `${header}.${tamperedPayload}.${signature}`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject tokens with modified signature", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
const [header, payload] = token.split(".");
|
||||
const tamperedToken = `${header}.${payload}.tamperedsignature`;
|
||||
|
||||
await expect(verifyToken(tamperedToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject malformed tokens", async () => {
|
||||
const malformedTokens = [
|
||||
"not.a.jwt",
|
||||
"only.two.parts",
|
||||
"too.many.parts.here.invalid",
|
||||
"",
|
||||
"invalid-base64",
|
||||
];
|
||||
|
||||
for (const malformedToken of malformedTokens) {
|
||||
await expect(verifyToken(malformedToken)).rejects.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cross-Survey Token Reuse", () => {
|
||||
test("should reject survey tokens used for different surveys", () => {
|
||||
const surveyId1 = "survey-1";
|
||||
const surveyId2 = "survey-2";
|
||||
|
||||
const token = createTokenForLinkSurvey(surveyId1, mockUser.email);
|
||||
const result = verifyTokenForLinkSurvey(token, surveyId2);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Expired Tokens", () => {
|
||||
test("should reject expired tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyToken(expiredToken)).rejects.toThrow("Invalid token");
|
||||
});
|
||||
|
||||
test("should reject expired email change tokens", async () => {
|
||||
const expiredToken = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "encrypted_test@example.com",
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
await expect(verifyEmailChangeToken(expiredToken)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Encryption Key Attacks", () => {
|
||||
test("should fail gracefully with wrong encryption key", async () => {
|
||||
mockSymmetricDecrypt.mockImplementation(() => {
|
||||
throw new Error("Authentication tag verification failed");
|
||||
});
|
||||
|
||||
// Mock findUnique to only return user for correct decrypted ID, not ciphertext
|
||||
(prisma.user.findUnique as any).mockImplementation(({ where }: { where: { id: string } }) => {
|
||||
if (where.id === mockUser.id) {
|
||||
return Promise.resolve(mockUser);
|
||||
}
|
||||
return Promise.resolve(null); // Return null for ciphertext IDs
|
||||
});
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
// Should fail because ciphertext passed as userId won't match any user in DB
|
||||
await expect(verifyToken(token)).rejects.toThrow(/User not found/i);
|
||||
});
|
||||
|
||||
test("should handle encryption key not set gracefully", async () => {
|
||||
const constants = await import("@/lib/constants");
|
||||
const originalKey = (constants as any).ENCRYPTION_KEY;
|
||||
(constants as any).ENCRYPTION_KEY = undefined;
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
email: "test@example.com",
|
||||
surveyId: "test-survey-id",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(token, "test-survey-id");
|
||||
expect(result).toBe("test@example.com");
|
||||
|
||||
// Restore
|
||||
(constants as any).ENCRYPTION_KEY = originalKey;
|
||||
});
|
||||
});
|
||||
|
||||
describe("SQL Injection Attempts", () => {
|
||||
test("should safely handle malicious user IDs", async () => {
|
||||
const maliciousIds = [
|
||||
"'; DROP TABLE users; --",
|
||||
"1' OR '1'='1",
|
||||
"admin'/*",
|
||||
"<script>alert('xss')</script>",
|
||||
"../../etc/passwd",
|
||||
];
|
||||
|
||||
for (const maliciousId of maliciousIds) {
|
||||
mockSymmetricDecrypt.mockReturnValueOnce(maliciousId);
|
||||
|
||||
const token = jwt.sign({ id: "encrypted_malicious" }, TEST_NEXTAUTH_SECRET);
|
||||
|
||||
// The function should look up the user safely
|
||||
await verifyToken(token);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: maliciousId },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Token Reuse and Replay Attacks", () => {
|
||||
test("should allow legitimate token reuse within validity period", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
// First use
|
||||
const result1 = await verifyToken(token);
|
||||
expect(result1.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
|
||||
// Second use (should still work)
|
||||
const result2 = await verifyToken(token);
|
||||
expect(result2.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
|
||||
describe("Legacy Token Compatibility", () => {
|
||||
test("should handle legacy unencrypted tokens gracefully", async () => {
|
||||
// Legacy token with plain text data
|
||||
const legacyToken = jwt.sign({ id: mockUser.id }, TEST_NEXTAUTH_SECRET);
|
||||
const result = await verifyToken(legacyToken);
|
||||
|
||||
expect(result.id).toBe(mockUser.id); // Returns raw ID from payload
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should handle mixed encrypted/unencrypted fields", async () => {
|
||||
mockSymmetricDecrypt
|
||||
.mockImplementationOnce(() => mockUser.id) // id decrypts successfully
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("Email not encrypted");
|
||||
}); // email fails
|
||||
|
||||
const token = jwt.sign(
|
||||
{
|
||||
id: "encrypted_test-id",
|
||||
email: "plain-email@example.com",
|
||||
},
|
||||
TEST_NEXTAUTH_SECRET
|
||||
);
|
||||
|
||||
const result = await verifyEmailChangeToken(token);
|
||||
expect(result.id).toBe(mockUser.id);
|
||||
expect(result.email).toBe("plain-email@example.com");
|
||||
});
|
||||
|
||||
test("should verify old format user tokens with email-based secrets", async () => {
|
||||
// Simulate old token format with per-user secret
|
||||
const oldFormatToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
const result = await verifyToken(oldFormatToken);
|
||||
expect(result.id).toBe(mockUser.id); // Returns decrypted user ID
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should verify old format survey tokens with survey-based secrets", () => {
|
||||
const surveyId = "legacy-survey-id";
|
||||
|
||||
// Simulate old survey token format
|
||||
const oldFormatSurveyToken = jwt.sign(
|
||||
{ email: `encrypted_${mockUser.email}` },
|
||||
TEST_NEXTAUTH_SECRET + surveyId
|
||||
);
|
||||
|
||||
const result = verifyTokenForLinkSurvey(oldFormatSurveyToken, surveyId);
|
||||
expect(result).toBe(mockUser.email);
|
||||
});
|
||||
|
||||
test("should gracefully handle database errors during legacy verification", async () => {
|
||||
// Create token that will fail new method
|
||||
const legacyToken = jwt.sign(
|
||||
{ id: `encrypted_${mockUser.id}` },
|
||||
TEST_NEXTAUTH_SECRET + mockUser.email
|
||||
);
|
||||
|
||||
// Make database lookup fail
|
||||
(prisma.user.findUnique as any).mockRejectedValueOnce(new Error("DB connection lost"));
|
||||
|
||||
await expect(verifyToken(legacyToken)).rejects.toThrow("DB connection lost");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge Cases and Error Handling", () => {
|
||||
test("should handle database connection errors gracefully", async () => {
|
||||
(prisma.user.findUnique as any).mockRejectedValue(new Error("Database connection failed"));
|
||||
|
||||
const token = createToken(mockUser.id);
|
||||
await expect(verifyToken(token)).rejects.toThrow("Database connection failed");
|
||||
});
|
||||
|
||||
test("should handle crypto module errors", () => {
|
||||
mockSymmetricEncrypt.mockImplementation(() => {
|
||||
throw new Error("Crypto module error");
|
||||
});
|
||||
|
||||
expect(() => createToken(mockUser.id)).toThrow("Crypto module error");
|
||||
});
|
||||
|
||||
test("should validate email format in tokens", () => {
|
||||
const invalidEmails = ["", "not-an-email", "missing@", "@missing-local.com", "spaces in@email.com"];
|
||||
|
||||
invalidEmails.forEach((invalidEmail) => {
|
||||
expect(() => createEmailToken(invalidEmail)).not.toThrow();
|
||||
// Note: JWT functions don't validate email format, they just encrypt/decrypt
|
||||
// Email validation should happen at a higher level
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle extremely long inputs", () => {
|
||||
const longString = "a".repeat(10000);
|
||||
|
||||
expect(() => createToken(longString)).not.toThrow();
|
||||
expect(() => createEmailToken(longString)).not.toThrow();
|
||||
});
|
||||
|
||||
test("should handle special characters in user data", () => {
|
||||
const specialChars = "!@#$%^&*()_+-=[]{}|;:'\",.<>?/~`";
|
||||
|
||||
expect(() => createToken(specialChars)).not.toThrow();
|
||||
expect(() => createEmailToken(specialChars)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Performance and Resource Exhaustion", () => {
|
||||
test("should handle rapid token creation without memory leaks", () => {
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
tokens.push(createToken(`user-${i}`));
|
||||
}
|
||||
|
||||
expect(tokens.length).toBe(1000);
|
||||
expect(tokens.every((token) => typeof token === "string")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle rapid token verification", async () => {
|
||||
const token = createToken(mockUser.id);
|
||||
|
||||
const verifications: Promise<any>[] = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
verifications.push(verifyToken(token));
|
||||
}
|
||||
|
||||
const results = await Promise.all(verifications);
|
||||
expect(results.length).toBe(100);
|
||||
expect(results.every((result: any) => result.id === mockUser.id)).toBe(true); // Returns decrypted user ID
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,64 @@
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { env } from "@/lib/env";
|
||||
import jwt, { JwtPayload } from "jsonwebtoken";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
|
||||
export const createToken = (userId: string, userEmail: string, options = {}): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, env.NEXTAUTH_SECRET + userEmail, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET + surveyId);
|
||||
// Helper function to decrypt with fallback to plain text
|
||||
const decryptWithFallback = (encryptedText: string, key: string): string => {
|
||||
try {
|
||||
return symmetricDecrypt(encryptedText, key);
|
||||
} catch {
|
||||
return encryptedText; // Return as-is if decryption fails (legacy format)
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
export const createToken = (userId: string, options = {}): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as { id: string; email: string };
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(userEmail, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail, surveyId }, NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const verifyEmailChangeToken = async (token: string): Promise<{ id: string; email: string }> => {
|
||||
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 {
|
||||
id: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
if (!payload?.id || !payload?.email) {
|
||||
throw new Error("Token is invalid or missing required fields");
|
||||
}
|
||||
|
||||
let decryptedId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(payload.id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = payload.id;
|
||||
}
|
||||
|
||||
try {
|
||||
decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedEmail = payload.email;
|
||||
}
|
||||
// Decrypt both fields with fallback
|
||||
const decryptedId = decryptWithFallback(payload.id, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
id: decryptedId,
|
||||
@@ -46,127 +67,230 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||
const encryptedUserId = symmetricEncrypt(userId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedUserId = symmetricEncrypt(userId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
|
||||
const payload = {
|
||||
id: encryptedUserId,
|
||||
email: encryptedEmail,
|
||||
};
|
||||
|
||||
return jwt.sign(payload, env.NEXTAUTH_SECRET as string, {
|
||||
return jwt.sign(payload, NEXTAUTH_SECRET, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
};
|
||||
|
||||
export const createEmailToken = (email: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, env.NEXTAUTH_SECRET);
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ email: encryptedEmail }, NEXTAUTH_SECRET);
|
||||
};
|
||||
|
||||
export const getEmailFromEmailToken = (token: string): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, env.NEXTAUTH_SECRET) as JwtPayload;
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
const decryptedEmail = symmetricDecrypt(payload.email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return payload.email;
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
return decryptWithFallback(payload.email, ENCRYPTION_KEY);
|
||||
};
|
||||
|
||||
export const createInviteToken = (inviteId: string, email: string, options = {}): string => {
|
||||
if (!env.NEXTAUTH_SECRET) {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, env.ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, env.NEXTAUTH_SECRET, options);
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
const encryptedInviteId = symmetricEncrypt(inviteId, ENCRYPTION_KEY);
|
||||
const encryptedEmail = symmetricEncrypt(email, ENCRYPTION_KEY);
|
||||
return jwt.sign({ inviteId: encryptedInviteId, email: encryptedEmail }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
|
||||
export const verifyTokenForLinkSurvey = (token: string, surveyId: string): string | null => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const { email } = jwt.verify(token, env.NEXTAUTH_SECRET + surveyId) as JwtPayload;
|
||||
let payload: JwtPayload & { email: string; surveyId?: string };
|
||||
|
||||
// Try primary method first (consistent secret)
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
surveyId: string;
|
||||
};
|
||||
} catch (primaryError) {
|
||||
logger.error(primaryError, "Token verification failed with primary method");
|
||||
|
||||
// Fallback to legacy method (surveyId-based secret)
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + surveyId, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
email: string;
|
||||
};
|
||||
} catch (legacyError) {
|
||||
logger.error(legacyError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
const decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
return decryptedEmail;
|
||||
} catch {
|
||||
// If decryption fails, return the original email (for older tokens)
|
||||
return email;
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
// Verify the surveyId matches if present in payload (new format)
|
||||
if (payload.surveyId && payload.surveyId !== surveyId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { email } = payload;
|
||||
if (!email) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decrypt email with fallback to plain text
|
||||
if (!ENCRYPTION_KEY) {
|
||||
return email; // Return as-is if encryption key not set
|
||||
}
|
||||
|
||||
return decryptWithFallback(email, ENCRYPTION_KEY);
|
||||
} catch (error) {
|
||||
logger.error(error, "Survey link token verification failed");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
// First decode to get the ID
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
// Helper function to get user email for legacy verification
|
||||
const getUserEmailForLegacyVerification = async (
|
||||
token: string,
|
||||
userId?: string
|
||||
): Promise<{ userId: string; userEmail: string }> => {
|
||||
if (!userId) {
|
||||
const decoded = jwt.decode(token);
|
||||
|
||||
if (!payload) {
|
||||
throw new Error("Token is invalid");
|
||||
// Validate decoded token structure before using it
|
||||
if (
|
||||
!decoded ||
|
||||
typeof decoded !== "object" ||
|
||||
!decoded.id ||
|
||||
typeof decoded.id !== "string" ||
|
||||
decoded.id.trim() === ""
|
||||
) {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
userId = decoded.id;
|
||||
}
|
||||
|
||||
const { id } = payload;
|
||||
if (!id) {
|
||||
throw new Error("Token missing required field: id");
|
||||
const decryptedId = decryptWithFallback(userId, ENCRYPTION_KEY);
|
||||
|
||||
// Validate decrypted ID before database query
|
||||
if (!decryptedId || typeof decryptedId !== "string" || decryptedId.trim() === "") {
|
||||
logger.error("Invalid token: missing or invalid user ID");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Try to decrypt the ID (for newer tokens), if it fails use the ID as-is (for older tokens)
|
||||
let decryptedId: string;
|
||||
try {
|
||||
decryptedId = symmetricDecrypt(id, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
decryptedId = id;
|
||||
}
|
||||
|
||||
// If no email provided, look up the user
|
||||
const foundUser = await prisma.user.findUnique({
|
||||
where: { id: decryptedId },
|
||||
});
|
||||
|
||||
if (!foundUser) {
|
||||
throw new Error("User not found");
|
||||
const errorMessage = "User not found";
|
||||
logger.error(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const userEmail = foundUser.email;
|
||||
return { userId: decryptedId, userEmail: foundUser.email };
|
||||
};
|
||||
|
||||
return { id: decryptedId, email: userEmail };
|
||||
export const verifyToken = async (token: string): Promise<JwtPayload> => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
let payload: JwtPayload & { id: string };
|
||||
let userData: { userId: string; userEmail: string } | null = null;
|
||||
|
||||
// Try new method first, with smart fallback to legacy
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (newMethodError) {
|
||||
logger.error(newMethodError, "Token verification failed with new method");
|
||||
|
||||
// Get user email for legacy verification
|
||||
userData = await getUserEmailForLegacyVerification(token);
|
||||
|
||||
// Try legacy verification with email-based secret
|
||||
try {
|
||||
payload = jwt.verify(token, NEXTAUTH_SECRET + userData.userEmail, {
|
||||
algorithms: ["HS256"],
|
||||
}) as JwtPayload & {
|
||||
id: string;
|
||||
};
|
||||
} catch (legacyMethodError) {
|
||||
logger.error(legacyMethodError, "Token verification failed with legacy method");
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
}
|
||||
|
||||
if (!payload?.id) {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Get user email if we don't have it yet
|
||||
userData ??= await getUserEmailForLegacyVerification(token, payload.id);
|
||||
|
||||
return { id: userData.userId, email: userData.userEmail };
|
||||
};
|
||||
|
||||
export const verifyInviteToken = (token: string): { inviteId: string; email: string } => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
if (!ENCRYPTION_KEY) {
|
||||
throw new Error("ENCRYPTION_KEY is not set");
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.decode(token);
|
||||
const payload: JwtPayload = decoded as JwtPayload;
|
||||
const payload = jwt.verify(token, NEXTAUTH_SECRET, { algorithms: ["HS256"] }) as JwtPayload & {
|
||||
inviteId: string;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const { inviteId, email } = payload;
|
||||
const { inviteId: encryptedInviteId, email: encryptedEmail } = payload;
|
||||
|
||||
let decryptedInviteId: string;
|
||||
let decryptedEmail: string;
|
||||
|
||||
try {
|
||||
// Try to decrypt first (for newer tokens)
|
||||
decryptedInviteId = symmetricDecrypt(inviteId, env.ENCRYPTION_KEY);
|
||||
decryptedEmail = symmetricDecrypt(email, env.ENCRYPTION_KEY);
|
||||
} catch {
|
||||
// If decryption fails, use original values (for older tokens)
|
||||
decryptedInviteId = inviteId;
|
||||
decryptedEmail = email;
|
||||
if (!encryptedInviteId || !encryptedEmail) {
|
||||
throw new Error("Invalid token");
|
||||
}
|
||||
|
||||
// Decrypt both fields with fallback to original values
|
||||
const decryptedInviteId = decryptWithFallback(encryptedInviteId, ENCRYPTION_KEY);
|
||||
const decryptedEmail = decryptWithFallback(encryptedEmail, ENCRYPTION_KEY);
|
||||
|
||||
return {
|
||||
inviteId: decryptedInviteId,
|
||||
email: decryptedEmail,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { randomBytes } from "crypto";
|
||||
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 { createToken } from "@/lib/jwt";
|
||||
// Import mocked rate limiting functions
|
||||
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { randomBytes } from "crypto";
|
||||
import { Provider } from "next-auth/providers/index";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
@@ -31,7 +31,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SESSION_MAX_AGE: 86400,
|
||||
NEXTAUTH_SECRET: "test-secret",
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
ENCRYPTION_KEY: "test-encryption-key-32-chars-long",
|
||||
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
|
||||
REDIS_URL: undefined,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
AUDIT_LOG_GET_USER_IP: false,
|
||||
@@ -261,7 +261,7 @@ describe("authOptions", () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUser.id) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Email already verified"
|
||||
@@ -280,7 +280,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
const result = await tokenProvider.options.authorize(credentials, {});
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
@@ -303,7 +303,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("authOptions", () => {
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
@@ -339,7 +339,7 @@ describe("authOptions", () => {
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId, mockUser.email) };
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
|
||||
@@ -66,8 +66,21 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Validate password length to prevent CPU DoS attacks
|
||||
// bcrypt processes passwords up to 72 bytes, but we limit to 128 characters for security
|
||||
if (credentials.password && credentials.password.length > 128) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("password_too_long", "credentials", "password_validation", UNKNOWN_DATA, credentials?.email);
|
||||
}
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Use a control hash when user doesn't exist to maintain constant timing.
|
||||
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
let user;
|
||||
try {
|
||||
// Perform database lookup
|
||||
user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: credentials?.email,
|
||||
@@ -79,6 +92,12 @@ export const authOptions: NextAuthOptions = {
|
||||
throw Error("Internal server error. Please try again later");
|
||||
}
|
||||
|
||||
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
|
||||
// Use actual hash if user exists, control hash if user doesn't exist
|
||||
const hashToVerify = user?.password || controlHash;
|
||||
const isValid = await verifyPassword(credentials.password, hashToVerify);
|
||||
|
||||
// Now check all conditions after constant-time operations are complete
|
||||
if (!user) {
|
||||
if (await shouldLogAuthFailure(identifier)) {
|
||||
logAuthAttempt("user_not_found", "credentials", "user_lookup", UNKNOWN_DATA, credentials?.email);
|
||||
@@ -96,8 +115,6 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Your account is currently inactive. Please contact the organization admin.");
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(credentials.password, user.password);
|
||||
|
||||
if (!isValid) {
|
||||
if (await shouldLogAuthFailure(user.email)) {
|
||||
logAuthAttempt("invalid_password", "credentials", "password_validation", user.id, user.email);
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@/lib/localStorage";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
@@ -10,19 +19,13 @@ import { TwoFactorBackup } from "@/modules/ee/two-factor-auth/components/two-fac
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { FormControl, FormError, FormField, FormItem } from "@/modules/ui/components/form";
|
||||
import { PasswordInput } from "@/modules/ui/components/password-input";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import Link from "next/dist/client/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { z } from "zod";
|
||||
|
||||
const ZLoginForm = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, { message: "Password must be at least 8 characters long" })
|
||||
.max(128, { message: "Password must be 128 characters or less" }),
|
||||
totpCode: z.string().optional(),
|
||||
backupCode: z.string().optional(),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const rateLimitConfigs = {
|
||||
// Authentication endpoints - stricter limits for security
|
||||
auth: {
|
||||
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
|
||||
login: { interval: 900, allowedPerInterval: 10, namespace: "auth:login" }, // 10 per 15 minutes
|
||||
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
|
||||
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
|
||||
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { TOrganization, TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
|
||||
import { TPricingPlan } from "../api/lib/constants";
|
||||
|
||||
interface PricingCardProps {
|
||||
@@ -170,14 +170,13 @@ export const PricingCard = ({
|
||||
|
||||
{plan.id !== projectFeatureKeys.FREE && isCurrentPlan && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
await onManageSubscription();
|
||||
setLoading(false);
|
||||
}}
|
||||
className="flex justify-center">
|
||||
className="flex justify-center bg-[#635bff]">
|
||||
{t("environments.settings.billing.manage_subscription")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -1,3 +1,12 @@
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import {
|
||||
DEBUG,
|
||||
MAIL_FROM,
|
||||
@@ -17,15 +26,6 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
|
||||
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { render } from "@react-email/render";
|
||||
import { createTransport } from "nodemailer";
|
||||
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import type { TResponse } from "@formbricks/types/responses";
|
||||
import type { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
|
||||
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
|
||||
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
|
||||
import { VerificationEmail } from "./emails/auth/verification-email";
|
||||
@@ -111,7 +111,7 @@ export const sendVerificationEmail = async ({
|
||||
}): Promise<boolean> => {
|
||||
try {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(id, email, {
|
||||
const token = createToken(id, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
@@ -136,7 +136,7 @@ export const sendForgotPasswordEmail = async (user: {
|
||||
locale: TUserLocale;
|
||||
}): Promise<boolean> => {
|
||||
const t = await getTranslate();
|
||||
const token = createToken(user.id, user.email, {
|
||||
const token = createToken(user.id, {
|
||||
expiresIn: "1d",
|
||||
});
|
||||
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRightIcon, EllipsisIcon } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
HTMLElement,
|
||||
@@ -15,7 +15,10 @@ const BreadcrumbList = React.forwardRef<HTMLOListElement, React.ComponentPropsWi
|
||||
({ className, ...props }, ref) => (
|
||||
<ol
|
||||
ref={ref}
|
||||
className={cn("flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500", className)}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-1.5 break-words text-sm text-slate-500 hover:text-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -32,7 +35,7 @@ const BreadcrumbItem = React.forwardRef<
|
||||
<li
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:outline hover:outline-slate-300",
|
||||
"inline-flex items-center gap-1.5 space-x-1 rounded-md px-1.5 py-1 hover:bg-white hover:outline hover:outline-slate-300",
|
||||
isActive && "bg-slate-100 outline outline-slate-300",
|
||||
isHighlighted && "bg-red-800 text-white outline hover:outline-red-800",
|
||||
className
|
||||
@@ -80,14 +83,15 @@ const BreadcrumbEllipsis = ({ className, ...props }: React.ComponentProps<"span"
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis";
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbEllipsis,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { Check, Copy } from "lucide-react";
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface BadgeContentProps {
|
||||
id: string | number;
|
||||
@@ -59,8 +59,8 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
||||
};
|
||||
|
||||
const content = (
|
||||
<button
|
||||
type="button"
|
||||
// eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions, jsx-a11y/prefer-tag-over-role, jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
|
||||
<div
|
||||
role={isCopyEnabled ? "button" : undefined}
|
||||
className={getButtonClasses()}
|
||||
onClick={handleCopy}
|
||||
@@ -69,7 +69,7 @@ export const BadgeContent: React.FC<BadgeContentProps> = ({
|
||||
onMouseLeave={isCopyEnabled ? () => setIsHovered(false) : undefined}>
|
||||
<span>{id}</span>
|
||||
{renderIcon()}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getTooltipContent = () => {
|
||||
|
||||
@@ -96,7 +96,7 @@ describe("IdBadge", () => {
|
||||
test("removes interactive elements when copy is disabled", () => {
|
||||
const { container } = render(<IdBadge id="1734" copyDisabled={true} />);
|
||||
|
||||
const badge = container.querySelector("button");
|
||||
const badge = container.querySelector("div");
|
||||
|
||||
// Should not have cursor-pointer class
|
||||
expect(badge).not.toHaveClass("cursor-pointer");
|
||||
|
||||
402
apps/web/playwright/api/auth/security.spec.ts
Normal file
402
apps/web/playwright/api/auth/security.spec.ts
Normal file
@@ -0,0 +1,402 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
// Authentication endpoints are hardcoded to avoid import issues
|
||||
|
||||
test.describe("Authentication Security Tests - Vulnerability Prevention", () => {
|
||||
let csrfToken: string;
|
||||
let testUser: { email: string; password: string };
|
||||
|
||||
test.beforeEach(async ({ request, users }) => {
|
||||
// Get CSRF token for authentication requests
|
||||
const csrfResponse = await request.get("/api/auth/csrf");
|
||||
const csrfData = await csrfResponse.json();
|
||||
csrfToken = csrfData.csrfToken;
|
||||
|
||||
// Create a test user for "existing user" scenarios with unique email
|
||||
const uniqueId = Date.now() + Math.random();
|
||||
const userName = "Security Test User";
|
||||
const userEmail = `security-test-${uniqueId}@example.com`;
|
||||
await users.create({
|
||||
name: userName,
|
||||
email: userEmail,
|
||||
});
|
||||
testUser = {
|
||||
email: userEmail,
|
||||
password: userName, // The fixture uses the name as password
|
||||
};
|
||||
});
|
||||
|
||||
test.describe("DoS Protection - Password Length Limits", () => {
|
||||
test("should handle extremely long passwords without crashing", async ({ request }) => {
|
||||
const email = "nonexistent-dos-test@example.com"; // Use non-existent email for DoS test
|
||||
const extremelyLongPassword = "A".repeat(50000); // 50,000 characters
|
||||
|
||||
const start = Date.now();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: email,
|
||||
password: extremelyLongPassword,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
// Should not crash the server (no 500 errors)
|
||||
expect(response.status()).not.toBe(500);
|
||||
|
||||
// Should handle gracefully
|
||||
expect([200, 400, 401, 429]).toContain(response.status());
|
||||
|
||||
logger.info(
|
||||
`Extremely long password (50k chars) processing time: ${responseTime}ms, status: ${response.status()}`
|
||||
);
|
||||
|
||||
// Verify the security fix is working: long passwords should be rejected quickly
|
||||
// In production, this should be much faster, but test environment has overhead
|
||||
if (responseTime < 5000) {
|
||||
logger.info("✅ Long password rejected quickly - DoS protection working");
|
||||
} else {
|
||||
logger.warn("⚠️ Long password took longer than expected - check DoS protection");
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle password at 128 character limit", async ({ request }) => {
|
||||
const email = "nonexistent-limit-test@example.com"; // Use non-existent email for limit test
|
||||
const maxLengthPassword = "A".repeat(128); // Exactly at the 128 character limit
|
||||
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: email,
|
||||
password: maxLengthPassword,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
// Should process normally (not rejected for length)
|
||||
expect(response.status()).not.toBe(500);
|
||||
expect([200, 400, 401, 429]).toContain(response.status());
|
||||
|
||||
logger.info(`Max length password (128 chars) status: ${response.status()}`);
|
||||
});
|
||||
|
||||
test("should reject passwords over 128 characters", async ({ request }) => {
|
||||
const email = "nonexistent-overlimit-test@example.com"; // Use non-existent email for over-limit test
|
||||
const overLimitPassword = "A".repeat(10000); // 10,000 characters (over limit)
|
||||
|
||||
const start = Date.now();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: email,
|
||||
password: overLimitPassword,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const responseTime = Date.now() - start;
|
||||
|
||||
// Should not crash
|
||||
expect(response.status()).not.toBe(500);
|
||||
|
||||
logger.info(
|
||||
`Over-limit password (10k chars) processing time: ${responseTime}ms, status: ${response.status()}`
|
||||
);
|
||||
|
||||
// The key security test: verify it doesn't take exponentially longer than shorter passwords
|
||||
// This tests the DoS protection is working
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Timing Attack Prevention - User Enumeration Protection", () => {
|
||||
test("should not reveal user existence through response timing differences", async ({ request }) => {
|
||||
// Test multiple attempts to get reliable timing measurements
|
||||
const attempts = 50;
|
||||
const nonExistentTimes: number[] = [];
|
||||
const existingUserTimes: number[] = [];
|
||||
|
||||
// Test non-existent user timing (multiple attempts for statistical reliability)
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: `nonexistent-timing-${i}@example.com`,
|
||||
password: "somepassword",
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const end = process.hrtime.bigint();
|
||||
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
|
||||
|
||||
nonExistentTimes.push(responseTime);
|
||||
expect(response.status()).not.toBe(500);
|
||||
}
|
||||
|
||||
// Test existing user with wrong password timing (multiple attempts)
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const start = process.hrtime.bigint();
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: testUser.email,
|
||||
password: "wrongpassword123",
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
const end = process.hrtime.bigint();
|
||||
const responseTime = Number(end - start) / 1000000; // Convert to milliseconds
|
||||
|
||||
existingUserTimes.push(responseTime);
|
||||
expect(response.status()).not.toBe(500);
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const avgNonExistent = nonExistentTimes.reduce((a, b) => a + b, 0) / nonExistentTimes.length;
|
||||
const avgExisting = existingUserTimes.reduce((a, b) => a + b, 0) / existingUserTimes.length;
|
||||
|
||||
// Calculate the timing difference percentage
|
||||
const timingDifference = Math.abs(avgExisting - avgNonExistent);
|
||||
const timingDifferencePercent = (timingDifference / Math.max(avgExisting, avgNonExistent)) * 100;
|
||||
|
||||
logger.info(
|
||||
`Non-existent user avg: ${avgNonExistent.toFixed(2)}ms (${nonExistentTimes.map((t) => t.toFixed(0)).join(", ")})`
|
||||
);
|
||||
logger.info(
|
||||
`Existing user avg: ${avgExisting.toFixed(2)}ms (${existingUserTimes.map((t) => t.toFixed(0)).join(", ")})`
|
||||
);
|
||||
logger.info(
|
||||
`Timing difference: ${timingDifference.toFixed(2)}ms (${timingDifferencePercent.toFixed(1)}%)`
|
||||
);
|
||||
|
||||
// CRITICAL SECURITY TEST: Timing difference should be minimal
|
||||
// A large timing difference could allow attackers to enumerate users
|
||||
// Allow up to 20% difference to account for network/system variance
|
||||
if (timingDifferencePercent > 20) {
|
||||
logger.warn(
|
||||
`⚠️ SECURITY RISK: Timing difference of ${timingDifferencePercent.toFixed(1)}% could allow user enumeration!`
|
||||
);
|
||||
logger.warn(`⚠️ Consider implementing constant-time authentication to prevent timing attacks`);
|
||||
} else {
|
||||
logger.info(
|
||||
`✅ Timing attack protection: Only ${timingDifferencePercent.toFixed(1)}% difference between scenarios`
|
||||
);
|
||||
}
|
||||
|
||||
// Fail the test if timing difference exceeds our security threshold
|
||||
expect(timingDifferencePercent).toBeLessThan(20); // Fail at our actual security threshold
|
||||
});
|
||||
|
||||
test("should return consistent status codes regardless of user existence", async ({ request }) => {
|
||||
const scenarios = [
|
||||
{
|
||||
email: "nonexistent-status@example.com",
|
||||
password: "testpassword",
|
||||
description: "non-existent user",
|
||||
},
|
||||
{ email: testUser.email, password: "wrongpassword", description: "existing user, wrong password" },
|
||||
];
|
||||
|
||||
const results: { scenario: string; status: number }[] = [];
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: scenario.email,
|
||||
password: scenario.password,
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
results.push({
|
||||
scenario: scenario.description,
|
||||
status: response.status(),
|
||||
});
|
||||
|
||||
expect(response.status()).not.toBe(500);
|
||||
}
|
||||
|
||||
// Log results
|
||||
results.forEach(({ scenario, status }) => {
|
||||
logger.info(`Status test - ${scenario}: ${status}`);
|
||||
});
|
||||
|
||||
// CRITICAL: Both scenarios should return the same status code
|
||||
// Different status codes could reveal user existence
|
||||
const statuses = results.map((r) => r.status);
|
||||
const uniqueStatuses = [...new Set(statuses)];
|
||||
|
||||
if (uniqueStatuses.length > 1) {
|
||||
logger.warn(
|
||||
`⚠️ SECURITY RISK: Different status codes (${uniqueStatuses.join(", ")}) could allow user enumeration!`
|
||||
);
|
||||
} else {
|
||||
logger.info(`✅ Status code consistency: Both scenarios return ${statuses[0]}`);
|
||||
}
|
||||
|
||||
expect(uniqueStatuses.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Security Headers and Response Safety", () => {
|
||||
test("should include security headers in responses", async ({ request }) => {
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: "nonexistent-headers-test@example.com",
|
||||
password: "testpassword",
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
// Check for important security headers
|
||||
const headers = response.headers();
|
||||
|
||||
// These headers should be present for security
|
||||
expect(headers["x-frame-options"]).toBeDefined();
|
||||
expect(headers["x-content-type-options"]).toBe("nosniff");
|
||||
|
||||
if (headers["strict-transport-security"]) {
|
||||
expect(headers["strict-transport-security"]).toContain("max-age");
|
||||
}
|
||||
if (headers["content-security-policy"]) {
|
||||
expect(headers["content-security-policy"]).toContain("default-src");
|
||||
}
|
||||
|
||||
logger.info("✅ Security headers present in authentication responses");
|
||||
});
|
||||
|
||||
test("should not expose sensitive information in error responses", async ({ request }) => {
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: "nonexistent-disclosure-test@example.com",
|
||||
password: "A".repeat(10000), // Trigger long password handling
|
||||
redirect: "false",
|
||||
csrfToken: csrfToken,
|
||||
json: "true",
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
const responseBody = await response.text();
|
||||
|
||||
// Log the actual response for debugging
|
||||
logger.info(`Response status: ${response.status()}`);
|
||||
logger.info(`Response body (first 500 chars): ${responseBody.substring(0, 500)}`);
|
||||
|
||||
// Check if this is an HTML response (which indicates NextAuth.js is returning a page instead of API response)
|
||||
const isHtmlResponse =
|
||||
responseBody.trim().startsWith("<!DOCTYPE html>") || responseBody.includes("<html");
|
||||
|
||||
if (isHtmlResponse) {
|
||||
logger.info(
|
||||
"✅ NextAuth.js returned HTML page instead of API response - this is expected behavior for security"
|
||||
);
|
||||
logger.info("✅ No sensitive technical information exposed in authentication API");
|
||||
return; // Skip the sensitive information check for HTML responses
|
||||
}
|
||||
|
||||
// Only check for sensitive information in actual API responses (JSON/text)
|
||||
const sensitiveTerms = [
|
||||
"password_too_long",
|
||||
"bcrypt",
|
||||
"hash",
|
||||
"redis",
|
||||
"database",
|
||||
"prisma",
|
||||
"stack trace",
|
||||
"rate limit exceeded",
|
||||
"authentication failed",
|
||||
"sql",
|
||||
"query",
|
||||
"connection timeout",
|
||||
"internal error",
|
||||
];
|
||||
|
||||
let foundSensitiveInfo = false;
|
||||
const foundTerms: string[] = [];
|
||||
|
||||
for (const term of sensitiveTerms) {
|
||||
if (responseBody.toLowerCase().includes(term.toLowerCase())) {
|
||||
foundSensitiveInfo = true;
|
||||
foundTerms.push(term);
|
||||
logger.warn(`Found "${term}" in response`);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundSensitiveInfo) {
|
||||
logger.warn(`⚠️ Found sensitive information in response: ${foundTerms.join(", ")}`);
|
||||
logger.warn(`Full response body: ${responseBody}`);
|
||||
} else {
|
||||
logger.info("✅ No sensitive technical information exposed in error responses");
|
||||
}
|
||||
|
||||
// Don't fail the test for generic web responses, only for actual security leaks
|
||||
expect(foundSensitiveInfo).toBe(false);
|
||||
});
|
||||
|
||||
test("should handle malformed requests gracefully", async ({ request }) => {
|
||||
// Test with missing CSRF token
|
||||
const response = await request.post("/api/auth/callback/credentials", {
|
||||
data: {
|
||||
callbackUrl: "",
|
||||
email: "nonexistent-malformed-test@example.com",
|
||||
password: "testpassword",
|
||||
redirect: "false",
|
||||
json: "true",
|
||||
// Missing csrfToken
|
||||
},
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
});
|
||||
|
||||
// Should handle gracefully, not crash
|
||||
expect(response.status()).not.toBe(500);
|
||||
expect([200, 400, 401, 403, 429]).toContain(response.status());
|
||||
|
||||
logger.info(`✅ Malformed request handled gracefully: status ${response.status()}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,10 @@ export const ROLES_API_URL = `/api/v2/roles`;
|
||||
export const ME_API_URL = `/api/v2/me`;
|
||||
export const HEALTH_API_URL = `/api/v2/health`;
|
||||
|
||||
// Authentication endpoints
|
||||
export const AUTH_CALLBACK_URL = `/api/auth/callback/credentials`;
|
||||
export const AUTH_CSRF_URL = `/api/auth/csrf`;
|
||||
|
||||
export const TEAMS_API_URL = (organizationId: string) => `/api/v2/organizations/${organizationId}/teams`;
|
||||
export const PROJECT_TEAMS_API_URL = (organizationId: string) =>
|
||||
`/api/v2/organizations/${organizationId}/project-teams`;
|
||||
|
||||
@@ -424,12 +424,20 @@ EOT
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
|
||||
# Step 3: Build service snippets and inject them BEFORE the volumes section (robust, no sed -i multiline)
|
||||
# Step 3: Build service snippets and inject them BEFORE the volumes section (non-destructive: skip if service exists)
|
||||
services_snippet_file="services_snippet.yml"
|
||||
: > "$services_snippet_file"
|
||||
|
||||
insert_traefik="y"
|
||||
if grep -q "^ traefik:" docker-compose.yml; then insert_traefik="n"; fi
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
cat > "$services_snippet_file" << EOF
|
||||
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 [[ $insert_minio == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
|
||||
minio:
|
||||
restart: always
|
||||
@@ -458,6 +466,11 @@ EOT
|
||||
- "traefik.http.middlewares.minio-cors.headers.addvaryheader=true"
|
||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.average=100"
|
||||
- "traefik.http.middlewares.minio-ratelimit.ratelimit.burst=200"
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ $insert_minio_init == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
minio-init:
|
||||
image: minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868
|
||||
depends_on:
|
||||
@@ -471,7 +484,11 @@ EOT
|
||||
entrypoint: ["/bin/sh", "/tmp/minio-init.sh"]
|
||||
volumes:
|
||||
- ./minio-init.sh:/tmp/minio-init.sh:ro
|
||||
EOF
|
||||
fi
|
||||
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat >> "$services_snippet_file" << EOF
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
restart: always
|
||||
@@ -488,6 +505,7 @@ EOT
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Downgrade MinIO router to plain HTTP when HTTPS is not configured
|
||||
if [[ $https_setup != "y" ]]; then
|
||||
@@ -497,7 +515,8 @@ EOF
|
||||
sed -i "s|accesscontrolalloworiginlist=https://$domain_name|accesscontrolalloworiginlist=http://$domain_name|" "$services_snippet_file"
|
||||
fi
|
||||
else
|
||||
cat > "$services_snippet_file" << EOF
|
||||
if [[ $insert_traefik == "y" ]]; then
|
||||
cat > "$services_snippet_file" << EOF
|
||||
|
||||
traefik:
|
||||
image: "traefik:v2.7"
|
||||
@@ -514,6 +533,9 @@ EOF
|
||||
- ./acme.json:/acme.json
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
EOF
|
||||
else
|
||||
: > "$services_snippet_file"
|
||||
fi
|
||||
fi
|
||||
|
||||
awk '
|
||||
@@ -529,24 +551,51 @@ EOF
|
||||
|
||||
rm -f "$services_snippet_file"
|
||||
|
||||
# Deterministically rewrite the volumes section to include required volumes
|
||||
awk -v add_minio="$minio_storage" '
|
||||
BEGIN { in_vol=0 }
|
||||
/^volumes:/ {
|
||||
print "volumes:";
|
||||
print " postgres:";
|
||||
print " driver: local";
|
||||
print " uploads:";
|
||||
print " driver: local";
|
||||
if (add_minio == "y") {
|
||||
print " minio-data:";
|
||||
print " driver: local";
|
||||
}
|
||||
in_vol=1; skip=1; next
|
||||
}
|
||||
# Skip original volumes block lines until EOF (we already printed ours)
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
# Ensure required volumes exist without removing user-defined volumes
|
||||
if grep -q '^volumes:' docker-compose.yml; then
|
||||
# Ensure postgres
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="postgres:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " postgres:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " postgres:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
# Ensure redis
|
||||
if ! awk '/^volumes:/{invol=1; next} invol && (/^[^[:space:]]/ || NF==0){invol=0} invol{ if($1=="redis:") found=1 } END{ exit(found?0:1) }' docker-compose.yml; then
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " redis:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
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
|
||||
awk '
|
||||
/^volumes:/ { print; invol=1; next }
|
||||
invol && /^[^[:space:]]/ { if(!added){ print " minio-data:"; print " driver: local"; added=1 } ; invol=0 }
|
||||
{ print }
|
||||
END { if (invol && !added) { print " minio-data:"; print " driver: local" } }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
fi
|
||||
fi
|
||||
else
|
||||
{
|
||||
echo ""
|
||||
echo "volumes:"
|
||||
echo " postgres:"
|
||||
echo " driver: local"
|
||||
echo " redis:"
|
||||
echo " driver: local"
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo " minio-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
|
||||
@@ -618,105 +667,6 @@ MINIO_SCRIPT_EOF
|
||||
|
||||
docker compose up -d
|
||||
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo "ℹ️ Waiting for MinIO to be ready..."
|
||||
attempts=0
|
||||
max_attempts=30
|
||||
until docker run --rm --network $(basename "$PWD")_default --entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc \
|
||||
"mc alias set minio http://minio:9000 '$minio_root_user' '$minio_root_password' >/dev/null 2>&1 && mc admin info minio >/dev/null 2>&1"; do
|
||||
attempts=$((attempts+1))
|
||||
if [ $attempts -ge $max_attempts ]; then
|
||||
echo "❌ MinIO did not become ready in time. Proceeding, but subsequent steps may fail."
|
||||
break
|
||||
fi
|
||||
echo "...attempt $attempts/$max_attempts"
|
||||
sleep 5
|
||||
done
|
||||
|
||||
echo "ℹ️ Ensuring bucket exists..."
|
||||
docker run --rm --network $(basename "$PWD")_default \
|
||||
-e MINIO_ROOT_USER="$minio_root_user" \
|
||||
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
|
||||
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
|
||||
--entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
|
||||
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
|
||||
mc mb minio/"$minio_bucket_name" --ignore-existing
|
||||
'
|
||||
|
||||
echo "ℹ️ Ensuring service user and policy exist (idempotent)..."
|
||||
docker run --rm --network $(basename "$PWD")_default \
|
||||
-e MINIO_ROOT_USER="$minio_root_user" \
|
||||
-e MINIO_ROOT_PASSWORD="$minio_root_password" \
|
||||
-e MINIO_SERVICE_USER="$minio_service_user" \
|
||||
-e MINIO_SERVICE_PASSWORD="$minio_service_password" \
|
||||
-e MINIO_BUCKET_NAME="$minio_bucket_name" \
|
||||
--entrypoint /bin/sh \
|
||||
minio/mc@sha256:95b5f3f7969a5c5a9f3a700ba72d5c84172819e13385aaf916e237cf111ab868 -lc '
|
||||
mc alias set minio http://minio:9000 "$minio_root_user" "$minio_root_password" >/dev/null 2>&1;
|
||||
if ! mc admin policy info minio formbricks-policy >/dev/null 2>&1; then
|
||||
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
|
||||
mc admin policy create minio formbricks-policy /tmp/formbricks-policy.json >/dev/null 2>&1 || true
|
||||
fi;
|
||||
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" >/dev/null 2>&1 || true
|
||||
fi;
|
||||
mc admin policy attach minio formbricks-policy --user "$minio_service_user" >/dev/null 2>&1 || true
|
||||
'
|
||||
fi
|
||||
|
||||
if [[ $minio_storage == "y" ]]; then
|
||||
echo "⏳ Finalizing MinIO setup..."
|
||||
attempts=0; max_attempts=60
|
||||
while cid=$(docker compose ps -q minio-init 2>/dev/null); do
|
||||
status=$(docker inspect -f '{{.State.Status}}' "$cid" 2>/dev/null || echo "")
|
||||
if [ "$status" = "exited" ] || [ -z "$status" ]; then
|
||||
break
|
||||
fi
|
||||
attempts=$((attempts+1))
|
||||
if [ $attempts -ge $max_attempts ]; then
|
||||
echo "⚠️ minio-init still running after wait; proceeding with cleanup anyway."
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "🧹 Cleaning up minio-init service and references..."
|
||||
|
||||
awk '
|
||||
BEGIN{skip=0}
|
||||
/^services:[[:space:]]*$/ { print; next }
|
||||
/^ minio-init:/ { skip=1; next }
|
||||
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Remove list-style "- minio-init" lines under depends_on (if any)
|
||||
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
|
||||
# Remove the minio-init mapping and its condition line
|
||||
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
|
||||
# Remove any stopped minio-init container and restart without orphans
|
||||
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
# Clean up the temporary minio-init script
|
||||
rm -f minio-init.sh
|
||||
|
||||
echo "✅ MinIO one-time init cleaned up."
|
||||
fi
|
||||
|
||||
echo "🔗 To edit more variables and deeper config, go to the formbricks/docker-compose.yml, edit the file, and restart the container!"
|
||||
|
||||
echo "🚨 Make sure you have set up the DNS records as well as inbound rules for the domain name and IP address of this instance."
|
||||
@@ -780,6 +730,40 @@ get_logs() {
|
||||
sudo docker compose logs
|
||||
}
|
||||
|
||||
cleanup_minio_init() {
|
||||
echo "🧹 Cleaning up MinIO init service and references..."
|
||||
cd formbricks
|
||||
|
||||
# Remove minio-init service block from docker-compose.yml
|
||||
awk '
|
||||
BEGIN{skip=0}
|
||||
/^services:[[:space:]]*$/ { print; next }
|
||||
/^ minio-init:/ { skip=1; next }
|
||||
/^ [A-Za-z0-9_-]+:/ { if (skip) skip=0 }
|
||||
{ if (!skip) print }
|
||||
' docker-compose.yml > tmp.yml && mv tmp.yml docker-compose.yml
|
||||
|
||||
# Remove list-style "- minio-init" lines under depends_on (if any)
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -E -i '/^[[:space:]]*-[[:space:]]*minio-init[[:space:]]*$/d' docker-compose.yml
|
||||
else
|
||||
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)
|
||||
if sed --version >/dev/null 2>&1; then
|
||||
sed -i '/^[[:space:]]*minio-init:[[:space:]]*$/,/^[[:space:]]*condition:[[:space:]]*service_completed_successfully[[:space:]]*$/d' docker-compose.yml
|
||||
else
|
||||
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
|
||||
docker compose rm -f -s minio-init >/dev/null 2>&1 || true
|
||||
docker compose up -d --remove-orphans
|
||||
|
||||
echo "✅ MinIO init cleanup complete."
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
install)
|
||||
install_formbricks
|
||||
@@ -796,6 +780,9 @@ restart)
|
||||
logs)
|
||||
get_logs
|
||||
;;
|
||||
cleanup-minio-init)
|
||||
cleanup_minio_init
|
||||
;;
|
||||
uninstall)
|
||||
uninstall_formbricks
|
||||
;;
|
||||
|
||||
@@ -79,7 +79,8 @@
|
||||
"xm-and-surveys/surveys/general-features/hide-back-button",
|
||||
"xm-and-surveys/surveys/general-features/email-followups",
|
||||
"xm-and-surveys/surveys/general-features/quota-management",
|
||||
"xm-and-surveys/surveys/general-features/spam-protection"
|
||||
"xm-and-surveys/surveys/general-features/spam-protection",
|
||||
"xm-and-surveys/surveys/general-features/tags"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -6,23 +6,37 @@ icon: "key"
|
||||
|
||||
To unlock Formbricks Enterprise Edition features, you need to activate your Enterprise License Key. Follow these steps to activate your license:
|
||||
|
||||
## 1. Set the License Key
|
||||
<Steps>
|
||||
<Step title="Set the License Key">
|
||||
Add your Enterprise License Key as an environment variable in your deployment:
|
||||
|
||||
Add your Enterprise License Key as an environment variable in your deployment:
|
||||
```bash
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
```
|
||||
|
||||
```bash
|
||||
ENTERPRISE_LICENSE_KEY=
|
||||
```
|
||||
- Add your Enterprise License Key after `ENTERPRISE_LICENSE_KEY=` with the key you received from Formbricks.
|
||||
- How you set environment variables depends on your deployment (Docker, Kubernetes, .env file, etc.).
|
||||
</Step>
|
||||
|
||||
- Add your Enterprise License Key after `ENTERPRISE_LICENSE_KEY=` with the key you received from Formbricks.
|
||||
- How you set environment variables depends on your deployment (Docker, Kubernetes, .env file, etc.).
|
||||
<Step title="Restart Your Instance">
|
||||
After setting the environment variable, **restart your Formbricks instance** to apply the changes.
|
||||
</Step>
|
||||
|
||||
## 2. Restart Your Instance
|
||||
<Step title="Verify License Activation">
|
||||
To verify if your license is active, visit `Organization Settings` -> `Enterprise License` to check the confirmation screen.
|
||||
</Step>
|
||||
|
||||
After setting the environment variable, **restart your Formbricks instance** to apply the changes.
|
||||
<Step title="Ensure License Server is Reachable">
|
||||
Your Formbricks instance performs a daily license check to verify your Enterprise License. If you're deploying Formbricks behind a firewall or have network restrictions, you need to whitelist the following:
|
||||
|
||||
## 3. Verify License Activation
|
||||
To verify if your license is active, visit `Organization Settings` -> `Enterprise License` to check the confirmation screen.
|
||||
- **Domain**: `ee.formbricks.com`
|
||||
- **URL**: `https://ee.formbricks.com/api/licenses/check`
|
||||
- **Protocol**: HTTPS (Port 443)
|
||||
- **Method**: POST
|
||||
- **Frequency**: Every 24 hours
|
||||
|
||||
### Troubleshooting
|
||||
Your server needs to be able to reach the Formbricks License Server. In case you're deploying Formbricks behind a firewall, please reach out to hola@formbricks.com for more info.
|
||||
The license check includes a 3-day grace period. If the check fails temporarily, your instance will continue using cached license information for up to 3 days.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
If you have questions or need assistance with network configuration, please reach out to hola@formbricks.com.
|
||||
@@ -4,10 +4,10 @@ description: "License for Formbricks"
|
||||
icon: "file-certificate"
|
||||
---
|
||||
|
||||
The Formbricks core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises under a separate, paid Enterprise License. This assures the long-term sustainability of the open source project. All free features are listed [below](#what-features-are-free).
|
||||
The Formbricks core source code is licensed under AGPLv3 and available on GitHub. Additionally, we offer features for bigger organisations & enterprises under a separate, paid Enterprise License. This assures the long-term sustainability of the open source project. All free features are listed [below](#what-features-are-free%3F).
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
@@ -18,21 +18,17 @@ Additional to the AGPLv3 licensed Formbricks core, the Formbricks repository con
|
||||
|
||||
## When do I need an Enterprise License?
|
||||
|
||||
| | Community Edition | Enterprise License |
|
||||
| | Community Edition | Enterprise Edition |
|
||||
| ------------------------------------------------------------- | ----------------- | ------------------ |
|
||||
| Self-host for commercial purposes | ✅ | No license needed |
|
||||
| Fork codebase, make changes, release under AGPLv3 | ✅ | No license needed |
|
||||
| Self-host for commercial purposes | ✅ | ✅ |
|
||||
| Fork codebase, make changes, release under AGPLv3 | ✅ | ✅ |
|
||||
| Fork codebase, make changes, **keep private** | ❌ | ✅ |
|
||||
| Unlimited responses | ✅ | No license needed |
|
||||
| Unlimited surveys | ✅ | No license needed |
|
||||
| Unlimited users | ✅ | No license needed |
|
||||
| Unlimited responses | ✅ | Pay per response |
|
||||
| Unlimited surveys | ✅ | ✅ |
|
||||
| Unlimited users | ✅ | ✅ |
|
||||
| Projects | 3 | Unlimited |
|
||||
| Use any of the other [free features](#what-features-are-free) | ✅ | No license needed |
|
||||
| Remove branding | ❌ | ✅ |
|
||||
| SSO | ❌ | ✅ |
|
||||
| Contacts & Targeting | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Use any of the [paid features](#what-features-are-free) | ❌ | ✅ |
|
||||
| Use all [free features](#what-features-are-free%3F) | ✅ | ✅ |
|
||||
| Use [paid features](#what-features-are-free%3F) | ❌ | Pay per feature |
|
||||
|
||||
## Open Core Licensing
|
||||
|
||||
@@ -45,14 +41,14 @@ The Formbricks core application is licensed under the [AGPLv3 Open Source Licens
|
||||
Additional to the AGPL licensed Formbricks core, this repository contains code licensed under an Enterprise license. The [code](https://github.com/formbricks/formbricks/tree/main/apps/web/modules/ee) and [license](https://github.com/formbricks/formbricks/blob/main/apps/web/modules/ee/LICENSE) for the enterprise functionality can be found in the `/apps/web/modules/ee` folder of this repository. This additional functionality is not part of the AGPLv3 licensed Formbricks core and is designed to meet the needs of larger teams and enterprises. This advanced functionality is already included in the Docker images, but you need an [Enterprise License Key](https://formbricks.com/enterprise-license?source=docs) to unlock it.
|
||||
|
||||
<Note>
|
||||
Want to get your hands on the Enterprise Edition? [Request a free 60-day Enterprise Edition
|
||||
Want to get your hands on the Enterprise Edition? [Request a free Enterprise Edition
|
||||
Trial](https://formbricks.com/enterprise-license?source=docs) License to build a fully functioning Proof of
|
||||
Concept.
|
||||
</Note>
|
||||
|
||||
## White-Labeling Formbricks and Other Licensing Needs
|
||||
|
||||
We currently do not offer Formbricks white-labeled. Any other needs? [Send us an email](mailto:hola@formbricks.com).
|
||||
We offer Formbricks white-labeled in some cases. [Please send us an email with a project description and we'll get back to you.](mailto:hola@formbricks.com).
|
||||
|
||||
## Why charge for Enterprise Features?
|
||||
|
||||
@@ -63,6 +59,8 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Feature | Community Edition | Enterprise Edition |
|
||||
| ---------------------------------------------- | ----------------- | ------------------ |
|
||||
| Unlimited surveys | ✅ | ✅ |
|
||||
| Full API Access | ✅ | ✅ |
|
||||
| All SDKs | ✅ | ✅ |
|
||||
| Website & App surveys | ✅ | ✅ |
|
||||
| Link surveys | ✅ | ✅ |
|
||||
| Email embedded surveys | ✅ | ✅ |
|
||||
@@ -77,18 +75,18 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| Hidden fields | ✅ | ✅ |
|
||||
| Single-use links | ✅ | ✅ |
|
||||
| Pin-protected surveys | ✅ | ✅ |
|
||||
| Full API Access | ✅ | ✅ |
|
||||
| All SDKs | ✅ | ✅ |
|
||||
| Webhooks | ✅ | ✅ |
|
||||
| Email follow-ups | ✅ | ✅ |
|
||||
| Multi-language UI | ✅ | ✅ |
|
||||
| All integrations (Slack, Zapier, Notion, etc.) | ✅ | ✅ |
|
||||
| Domain Split Configuration | ✅ | ✅ |
|
||||
| Cluster Hosting via Formbricks Helm Chart | ✅ | ✅ |
|
||||
| Hide "Powered by Formbricks" | ❌ | ✅ |
|
||||
| Whitelabel email follow-ups | ❌ | ✅ |
|
||||
| Teams & access roles | ❌ | ✅ |
|
||||
| Contact management & segments | ❌ | ✅ |
|
||||
| Multi-language surveys | ❌ | ✅ |
|
||||
| Quota Management | ❌ | ✅ |
|
||||
| Audit Logs | ❌ | ✅ |
|
||||
| OIDC SSO (AzureAD, Google, OpenID) | ❌ | ✅ |
|
||||
| SAML SSO | ❌ | ✅ |
|
||||
@@ -98,4 +96,4 @@ The Enterprise Edition allows us to fund the development of Formbricks sustainab
|
||||
| White-glove onboarding | ❌ | ✅ |
|
||||
| Support SLAs | ❌ | ✅ |
|
||||
|
||||
**Any more questions?** [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
|
||||
Questions? [Send us an email](mailto:johannes@formbricks.com) or [book a call with us.](https://cal.com/johannes/license)
|
||||
|
||||
@@ -31,7 +31,6 @@ Use cloud storage services for production deployments:
|
||||
|
||||
- **AWS S3** (Amazon Web Services)
|
||||
- **DigitalOcean Spaces**
|
||||
- **Backblaze B2**
|
||||
- **Wasabi**
|
||||
- **StorJ**
|
||||
- Any S3-compatible storage service
|
||||
@@ -121,6 +120,13 @@ S3_ENDPOINT_URL=https://your-endpoint.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
<Note>
|
||||
<strong>AWS S3 vs. third‑party S3:</strong> 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,
|
||||
DigitalOcean Spaces, Wasabi, Storj), you typically must set `S3_ENDPOINT_URL` to the provider's endpoint and
|
||||
set `S3_FORCE_PATH_STYLE=1`.
|
||||
</Note>
|
||||
|
||||
## Provider-Specific Examples
|
||||
|
||||
### AWS S3
|
||||
@@ -156,16 +162,13 @@ S3_ENDPOINT_URL=https://files.yourdomain.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
|
||||
### Backblaze B2
|
||||
### Compatibility requirement: S3 POST Object support
|
||||
|
||||
```bash
|
||||
S3_ACCESS_KEY=your_b2_key_id
|
||||
S3_SECRET_KEY=your_b2_application_key
|
||||
S3_REGION=us-west-000
|
||||
S3_BUCKET_NAME=my-formbricks-bucket
|
||||
S3_ENDPOINT_URL=https://s3.us-west-000.backblazeb2.com
|
||||
S3_FORCE_PATH_STYLE=1
|
||||
```
|
||||
Formbricks uses the S3 [POST Object](https://docs.aws.amazon.com/AmazonS3/latest/API/RESTObjectPOST.html)
|
||||
operation (presigned POST) for uploads. Your object storage provider must support this operation. Providers
|
||||
that do not implement POST Object are not compatible with Formbricks uploads. For example, Backblaze B2's
|
||||
S3‑compatible API currently does not support POST Object and therefore will not work with Formbricks file
|
||||
uploads.
|
||||
|
||||
## Bundled MinIO Setup
|
||||
|
||||
|
||||
@@ -20,8 +20,6 @@ Running Formbricks as a cluster of multiple instances offers several key advanta
|
||||
|
||||
To run Formbricks in a cluster setup, you'll need:
|
||||
|
||||
- Enterprise Edition license key
|
||||
|
||||
- Shared PostgreSQL database
|
||||
|
||||
- Shared Redis cache for session management and caching
|
||||
|
||||
@@ -312,6 +312,18 @@ 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)
|
||||
|
||||
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.
|
||||
|
||||
If you prefer to remove the `minio-init` service and its references after a successful setup, run:
|
||||
|
||||
```
|
||||
./formbricks.sh cleanup-minio-init
|
||||
```
|
||||
|
||||
This only removes the init job and its Compose references; it does not delete any data or affect your MinIO configuration.
|
||||
|
||||
## Uninstall
|
||||
|
||||
To uninstall Formbricks, simply run the following command, but keep in mind that this will delete all your data!
|
||||
|
||||
143
docs/xm-and-surveys/surveys/general-features/tags.mdx
Normal file
143
docs/xm-and-surveys/surveys/general-features/tags.mdx
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
title: "Tags"
|
||||
description: "Organize and categorize survey responses to easily filter, analyze, and manage your data."
|
||||
icon: "tag"
|
||||
---
|
||||
|
||||
## What are Tags?
|
||||
|
||||
Tags are labels that you can apply to individual survey responses. They allow you to:
|
||||
|
||||
- Categorize responses by topic, sentiment, or any custom criteria
|
||||
- Filter responses to find specific subsets of data
|
||||
- Track and organize feedback across multiple surveys
|
||||
- Simplify analysis and reporting workflows
|
||||
|
||||
Tags are environment-specific, meaning each environment maintains its own set of tags.
|
||||
|
||||
## Add tags to responses
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to responses">
|
||||
Go to the **Responses** tab of your survey.
|
||||
</Step>
|
||||
|
||||
<Step title="Open a response">
|
||||
Click on any response card to view the full response details.
|
||||
</Step>
|
||||
|
||||
<Step title="Add a tag">
|
||||
At the bottom of the response card, click the **Add Tag** button.
|
||||
</Step>
|
||||
|
||||
<Step title="Select or create a tag">
|
||||
- Select an existing tag from the dropdown list, or
|
||||
- Type a new tag name and click **+ Add [tag name]** to create a new tag
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
The tag will be immediately applied to the response. You can add multiple tags to a single response.
|
||||
|
||||
## Remove tags from responses
|
||||
|
||||
To remove a tag from a response:
|
||||
|
||||
1. Open the response card
|
||||
2. Click the **X** icon on the tag you want to remove
|
||||
|
||||
The tag will be removed from the response immediately.
|
||||
|
||||
## Manage tags
|
||||
|
||||
Access the tag management page to view and organize all tags in your environment.
|
||||
|
||||
<Steps>
|
||||
<Step title="Navigate to Configuration">
|
||||
Click on **Project Configuration** > **Tags**.
|
||||
</Step>
|
||||
|
||||
<Step title="View all tags">
|
||||
You'll see a list of all tags in your environment with their usage count showing how many responses have each tag applied.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Edit tag names
|
||||
|
||||
1. In the tag management page, click on the tag name field
|
||||
2. Edit the name directly
|
||||
3. Click outside the field or press Enter to save
|
||||
|
||||
<Note>
|
||||
Tag names must be unique within an environment. If you try to use an existing tag name, you'll receive an error.
|
||||
</Note>
|
||||
|
||||
### Merge tags
|
||||
|
||||
Merging tags is useful when you have duplicate or similar tags that you want to consolidate.
|
||||
|
||||
1. In the tag management page, find the tag you want to merge
|
||||
2. Click the **Merge into** dropdown
|
||||
3. Select the destination tag
|
||||
4. Confirm the merge
|
||||
|
||||
All responses tagged with the original tag will be updated to use the destination tag, and the original tag will be deleted.
|
||||
|
||||
### Delete tags
|
||||
|
||||
1. In the tag management page, find the tag you want to delete
|
||||
2. Click the **Delete** button
|
||||
3. Confirm the deletion
|
||||
|
||||
<Warning>
|
||||
Deleting a tag will remove it from all responses. This action cannot be undone.
|
||||
</Warning>
|
||||
|
||||
## Filter responses by tags
|
||||
|
||||
Use tags to filter and find specific responses in your survey analysis.
|
||||
|
||||
<Steps>
|
||||
<Step title="Open filters">
|
||||
In the **Responses** tab, click the **Filter** button.
|
||||
</Step>
|
||||
|
||||
<Step title="Select tag filter">
|
||||
Scroll to the **Tags** section and select a tag from the dropdown.
|
||||
</Step>
|
||||
|
||||
<Step title="Choose filter type">
|
||||
Choose whether to filter by:
|
||||
- **Applied**: Show only responses that have this tag
|
||||
- **Not Applied**: Show only responses that don't have this tag
|
||||
</Step>
|
||||
|
||||
<Step title="View filtered results">
|
||||
The response list will update to show only responses matching your tag filter.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
You can combine tag filters with other filters (questions, attributes, metadata) to create complex filter criteria.
|
||||
|
||||
## Use cases
|
||||
|
||||
Here are some common ways to use tags:
|
||||
|
||||
- **Sentiment tracking**: Tag responses as "positive", "negative", or "neutral"
|
||||
- **Follow-up needed**: Mark responses that require action with a "follow-up" tag
|
||||
- **Feature requests**: Categorize feedback by product area or feature
|
||||
- **Customer segments**: Tag responses by customer type, industry, or plan level
|
||||
- **Priority levels**: Mark critical issues with "urgent" or "high-priority" tags
|
||||
- **Review status**: Track which responses have been reviewed with "reviewed" or "pending" tags
|
||||
|
||||
## Permissions
|
||||
|
||||
Tag management requires appropriate permissions:
|
||||
|
||||
- **View tags**: All users with access to the environment can view tags on responses
|
||||
- **Add/remove tags on responses**: Users with read-write access can apply and remove tags
|
||||
- **Manage tags** (create, edit, merge, delete): Users with project team read-write permission or organization owner/manager roles
|
||||
|
||||
---
|
||||
|
||||
**Need help?** [Reach out in Github Discussions](https://github.com/formbricks/formbricks/discussions)
|
||||
|
||||
@@ -82,8 +82,8 @@ describe("client.ts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when access key is missing", async () => {
|
||||
// Mock constants with missing access key
|
||||
test("should create S3 client when access key is missing (IAM role authentication)", async () => {
|
||||
// Mock constants with missing access key - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
@@ -93,14 +93,20 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when secret key is missing", async () => {
|
||||
// Mock constants with missing secret key
|
||||
test("should create S3 client when secret key is missing (IAM role authentication)", async () => {
|
||||
// Mock constants with missing secret key - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_SECRET_KEY: undefined,
|
||||
@@ -110,14 +116,20 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when both credentials are missing", async () => {
|
||||
// Mock constants with no credentials
|
||||
test("should create S3 client when both credentials are missing (IAM role authentication)", async () => {
|
||||
// Mock constants with no credentials - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
@@ -128,14 +140,20 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when credentials are empty strings", async () => {
|
||||
// Mock constants with empty string credentials
|
||||
test("should create S3 client when credentials are empty strings (IAM role authentication)", async () => {
|
||||
// Mock constants with empty string credentials - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: "",
|
||||
@@ -146,14 +164,20 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when mixed empty and undefined credentials", async () => {
|
||||
// Mock constants with mixed empty and undefined
|
||||
test("should create S3 client when mixed empty and undefined credentials (IAM role authentication)", async () => {
|
||||
// Mock constants with mixed empty and undefined - should work with IAM roles
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: "",
|
||||
@@ -164,6 +188,81 @@ describe("client.ts", () => {
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
region: mockConstants.S3_REGION,
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client when region is missing (uses AWS SDK defaults)", async () => {
|
||||
// Mock constants with missing region - should still work
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_REGION: undefined,
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
credentials: {
|
||||
accessKeyId: mockConstants.S3_ACCESS_KEY,
|
||||
secretAccessKey: mockConstants.S3_SECRET_KEY,
|
||||
},
|
||||
endpoint: mockConstants.S3_ENDPOINT_URL,
|
||||
forcePathStyle: mockConstants.S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should create S3 client with only bucket name (minimal config for IAM roles)", async () => {
|
||||
// Mock constants with only bucket name - minimal required config
|
||||
vi.doMock("./constants", () => ({
|
||||
S3_ACCESS_KEY: undefined,
|
||||
S3_SECRET_KEY: undefined,
|
||||
S3_REGION: undefined,
|
||||
S3_BUCKET_NAME: "test-bucket",
|
||||
S3_ENDPOINT_URL: undefined,
|
||||
S3_FORCE_PATH_STYLE: false,
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(mockS3Client).toHaveBeenCalledWith({
|
||||
endpoint: undefined,
|
||||
forcePathStyle: false,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
test("should return error when bucket name is missing", async () => {
|
||||
// Mock constants with missing bucket name
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
}));
|
||||
|
||||
const { createS3ClientFromEnv } = await import("./client");
|
||||
|
||||
const result = createS3ClientFromEnv();
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error.code).toBe("s3_credentials_error");
|
||||
@@ -254,11 +353,10 @@ describe("client.ts", () => {
|
||||
});
|
||||
|
||||
test("should return undefined when creating from env fails and no client provided", async () => {
|
||||
// Mock constants with missing credentials
|
||||
// Mock constants with missing bucket name (the only required field)
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
S3_SECRET_KEY: undefined,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
}));
|
||||
|
||||
const { createS3Client } = await import("./client");
|
||||
@@ -290,8 +388,7 @@ describe("client.ts", () => {
|
||||
test("returns undefined when env is invalid and does not construct client", async () => {
|
||||
vi.doMock("./constants", () => ({
|
||||
...mockConstants,
|
||||
S3_ACCESS_KEY: undefined,
|
||||
S3_SECRET_KEY: undefined,
|
||||
S3_BUCKET_NAME: undefined,
|
||||
}));
|
||||
|
||||
const { getCachedS3Client } = await import("./client");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { S3Client } from "@aws-sdk/client-s3";
|
||||
import { S3Client, type S3ClientConfig } from "@aws-sdk/client-s3";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { type Result, type StorageError, StorageErrorCode, err, ok } from "../types/error";
|
||||
import {
|
||||
@@ -19,19 +19,35 @@ let cachedS3Client: S3Client | undefined;
|
||||
*/
|
||||
export const createS3ClientFromEnv = (): Result<S3Client, StorageError> => {
|
||||
try {
|
||||
if (!S3_ACCESS_KEY || !S3_SECRET_KEY || !S3_BUCKET_NAME || !S3_REGION) {
|
||||
logger.error("S3 Client: S3 credentials are not set");
|
||||
// Only S3_BUCKET_NAME is required - S3_REGION is optional and will default to AWS SDK defaults
|
||||
if (!S3_BUCKET_NAME) {
|
||||
logger.error("S3 Client: S3_BUCKET_NAME is required");
|
||||
return err({
|
||||
code: StorageErrorCode.S3CredentialsError,
|
||||
});
|
||||
}
|
||||
|
||||
const s3ClientInstance = new S3Client({
|
||||
credentials: { accessKeyId: S3_ACCESS_KEY, secretAccessKey: S3_SECRET_KEY },
|
||||
region: S3_REGION,
|
||||
// Build S3 client configuration
|
||||
const s3Config: S3ClientConfig = {
|
||||
endpoint: S3_ENDPOINT_URL,
|
||||
forcePathStyle: S3_FORCE_PATH_STYLE,
|
||||
});
|
||||
};
|
||||
|
||||
// Only set region if it's provided, otherwise let AWS SDK use its defaults
|
||||
if (S3_REGION) {
|
||||
s3Config.region = S3_REGION;
|
||||
}
|
||||
|
||||
// Only add credentials if both access key and secret key are provided
|
||||
// This allows the AWS SDK to use IAM roles, instance profiles, or other credential providers
|
||||
if (S3_ACCESS_KEY && S3_SECRET_KEY) {
|
||||
s3Config.credentials = {
|
||||
accessKeyId: S3_ACCESS_KEY,
|
||||
secretAccessKey: S3_SECRET_KEY,
|
||||
};
|
||||
}
|
||||
|
||||
const s3ClientInstance = new S3Client(s3Config);
|
||||
|
||||
return ok(s3ClientInstance);
|
||||
} catch (error) {
|
||||
|
||||
150
packages/surveys/README.md
Normal file
150
packages/surveys/README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
## Overview
|
||||
|
||||
The `@formbricks/surveys` package provides a complete survey rendering system built with Preact/React. It features automated translation management through Lingo.dev.
|
||||
|
||||
## Features
|
||||
|
||||
- **Survey Components**: Complete set of survey question types and UI components
|
||||
- **Internationalization**: Built with i18next and react-i18next
|
||||
- **Type Safety**: Full TypeScript support
|
||||
- **Testing**: Comprehensive test coverage with Vitest
|
||||
- **Lightweight**: Built with Preact for optimal bundle size
|
||||
- **Multi-language Support**: Supports 10+ languages with automated translation generation
|
||||
|
||||
## Architecture
|
||||
|
||||
### File Structure
|
||||
|
||||
```text
|
||||
packages/surveys/
|
||||
├── locales/ # Translation files
|
||||
│ ├── en.json # Source translations (English)
|
||||
│ ├── de.json # Generated translations (German)
|
||||
│ ├── fr.json # Generated translations (French)
|
||||
│ └── ... # Other target languages
|
||||
├── i18n.json # lingo.dev configuration
|
||||
├── src/
|
||||
│ ├── components/
|
||||
│ │ ├── buttons/ # Survey navigation buttons
|
||||
│ │ ├── general/ # Core survey components
|
||||
│ │ ├── i18n/
|
||||
│ │ │ └── provider.tsx # i18n provider component
|
||||
│ │ ├── icons/ # Icon components
|
||||
│ │ ├── questions/ # Question type components
|
||||
│ │ └── wrappers/ # Layout wrappers
|
||||
│ ├── lib/
|
||||
│ │ ├── i18n.config.ts # i18next configuration
|
||||
│ │ ├── i18n-utils.ts # Utility functions
|
||||
│ │ └── ... # Other utilities
|
||||
│ ├── styles/ # CSS styles
|
||||
│ └── types/ # TypeScript types
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Setting Up Automated Translations
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Lingo.dev](https://Lingo.dev) API key
|
||||
- Access to the Formbricks team on Lingo.dev
|
||||
|
||||
### Step-by-Step Setup
|
||||
|
||||
1. **Join the Formbricks Team**
|
||||
|
||||
- Join the Formbricks team on Lingo.dev
|
||||
|
||||
2. **Get Your API Key**
|
||||
|
||||
- In the sidebar, go to **Projects** and open the default project
|
||||
- Navigate to the **Settings** tab
|
||||
- Copy the API key
|
||||
|
||||
3. **Configure Environment Variables**
|
||||
|
||||
In the surveys package directory, create a `.env` file:
|
||||
|
||||
```bash
|
||||
# packages/surveys/.env
|
||||
LINGODOTDEV_API_KEY=<YOUR_API_KEY>
|
||||
```
|
||||
|
||||
4. **Generate Translations**
|
||||
|
||||
Run the translation generation script:
|
||||
|
||||
```bash
|
||||
# From the root of the repo or from within the surveys package
|
||||
pnpm run i18n:generate
|
||||
```
|
||||
|
||||
This will execute the auto-translate script and update translation files if needed.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Adding New Translation Keys
|
||||
|
||||
1. **Update Source File**: Add new keys to `packages/surveys/locales/en.json`
|
||||
2. **Generate Translations**: Run `pnpm run i18n:generate`
|
||||
3. **Update Components**: Use the new translation keys in your components with `useTranslation` hook
|
||||
4. **Test**: Verify translations work across all supported languages
|
||||
|
||||
### Updating Existing Translations
|
||||
|
||||
1. **Update Target File**: Update the translation keys in the target language file (`packages/surveys/locales/<target-language>.json`)
|
||||
2. **Test**: Verify translations work across all supported languages
|
||||
3. You don't need to run the `i18n:generate` command as it is only required when the source language is updated.
|
||||
|
||||
### Adding New Languages
|
||||
|
||||
#### 1. Update lingo.dev Configuration
|
||||
|
||||
Edit `packages/surveys/i18n.json` to include new target languages:
|
||||
|
||||
```json
|
||||
{
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["de", "it", ...otherLanguages, "new-lang"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Update i18n Configuration
|
||||
|
||||
Modify `packages/surveys/src/lib/i18n.config.ts`:
|
||||
|
||||
```tsx
|
||||
// Add new import
|
||||
import newLangTranslations from "../../locales/new-lang.json";
|
||||
|
||||
i18n
|
||||
.use(ICU)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
supportedLngs: ["en", "de", ...otherLanguages, "new-lang"],
|
||||
resources: {
|
||||
// ... existing resources
|
||||
"new-lang": { translation: newLangTranslations },
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. Generate Translation Files
|
||||
|
||||
Run the translation generation command:
|
||||
|
||||
```bash
|
||||
pnpm run i18n:generate
|
||||
```
|
||||
|
||||
This will create new translation files in the `locales/` directory for each target language.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `pnpm dev` - Start development build
|
||||
- `pnpm build` - Build for production
|
||||
- `pnpm test` - Run tests
|
||||
- `pnpm test:coverage` - Run tests with coverage
|
||||
- `pnpm i18n:generate` - Generate translations using Lingo.dev
|
||||
- `pnpm lint` - Lint and fix code
|
||||
@@ -31,7 +31,7 @@ export type TUserEmail = z.infer<typeof ZUserEmail>;
|
||||
|
||||
export const ZUserPassword = z
|
||||
.string()
|
||||
.min(8)
|
||||
.min(8, { message: "Password must be at least 8 characters long" })
|
||||
.max(128, { message: "Password must be 128 characters or less" })
|
||||
.regex(/^(?=.*[A-Z])(?=.*\d).*$/);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user