diff --git a/apps/web/lib/jwt.test.ts b/apps/web/lib/jwt.test.ts
index de2a4b5a49..28cca14686 100644
--- a/apps/web/lib/jwt.test.ts
+++ b/apps/web/lib/jwt.test.ts
@@ -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'/*",
+ "",
+ "../../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[] = [];
+ 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
+ });
+ });
});
});
diff --git a/apps/web/lib/jwt.ts b/apps/web/lib/jwt.ts
index 88095db6bc..66e305e2b8 100644
--- a/apps/web/lib/jwt.ts
+++ b/apps/web/lib/jwt.ts
@@ -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 => {
- // 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 => {
+ 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,
diff --git a/apps/web/modules/auth/lib/authOptions.test.ts b/apps/web/modules/auth/lib/authOptions.test.ts
index daaa2729c4..fb531e181a 100644
--- a/apps/web/modules/auth/lib/authOptions.test.ts
+++ b/apps/web/modules/auth/lib/authOptions.test.ts
@@ -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, {});
diff --git a/apps/web/modules/email/index.tsx b/apps/web/modules/email/index.tsx
index d8d1637b38..28c2b46a34 100644
--- a/apps/web/modules/email/index.tsx
+++ b/apps/web/modules/email/index.tsx
@@ -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 => {
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 => {
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)}`;