mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 11:59:54 -06:00
chore: backport jwt and script fix (#6607)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
4112722a88
commit
7aedb73378
@@ -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, {});
|
||||
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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
|
||||
;;
|
||||
|
||||
@@ -310,6 +310,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!
|
||||
|
||||
Reference in New Issue
Block a user