mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 02:10:12 -06:00
- Refactored JWT functions to improve security and error handling. - Added tests for various scenarios including missing secrets, token verification, and legacy compatibility. - Implemented fallback mechanisms for decryption and token verification to handle legacy formats. - Enhanced error logging for better traceability during token operations. - Updated tests to ensure robust coverage of new security features and edge cases.
1009 lines
35 KiB
TypeScript
1009 lines
35 KiB
TypeScript
import * as crypto from "@/lib/crypto";
|
|
import jwt from "jsonwebtoken";
|
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
import { prisma } from "@formbricks/database";
|
|
import {
|
|
createEmailChangeToken,
|
|
createEmailToken,
|
|
createInviteToken,
|
|
createToken,
|
|
createTokenForLinkSurvey,
|
|
getEmailFromEmailToken,
|
|
verifyEmailChangeToken,
|
|
verifyInviteToken,
|
|
verifyToken,
|
|
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),
|
|
NEXTAUTH_SECRET: "test-nextauth-secret",
|
|
},
|
|
}));
|
|
|
|
// Mock constants
|
|
vi.mock("@/lib/constants", () => ({
|
|
NEXTAUTH_SECRET: "test-nextauth-secret",
|
|
ENCRYPTION_KEY: "0".repeat(32),
|
|
}));
|
|
|
|
// Mock prisma
|
|
vi.mock("@formbricks/database", () => ({
|
|
prisma: {
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
// 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 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,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("createTokenForLinkSurvey", () => {
|
|
test("should create a valid survey link token", () => {
|
|
const surveyId = "test-survey-id";
|
|
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]);
|
|
});
|
|
});
|
|
|
|
describe("createEmailToken", () => {
|
|
test("should create a valid email token", () => {
|
|
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 or ENCRYPTION_KEY is not set", async () => {
|
|
await testMissingSecretsError(createEmailToken, [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]);
|
|
});
|
|
});
|
|
|
|
describe("createInviteToken", () => {
|
|
test("should create a valid invite token", () => {
|
|
const inviteId = "test-invite-id";
|
|
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]);
|
|
});
|
|
});
|
|
|
|
describe("verifyTokenForLinkSurvey", () => {
|
|
test("should verify valid survey link token", () => {
|
|
const surveyId = "test-survey-id";
|
|
const token = createTokenForLinkSurvey(surveyId, mockUser.email);
|
|
const verifiedEmail = verifyTokenForLinkSurvey(token, surveyId);
|
|
expect(verifiedEmail).toBe(mockUser.email);
|
|
});
|
|
|
|
test("should return null for invalid token", () => {
|
|
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);
|
|
const verified = await verifyToken(token);
|
|
expect(verified).toEqual({
|
|
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);
|
|
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", () => {
|
|
test("should verify valid invite token", () => {
|
|
const inviteId = "test-invite-id";
|
|
const token = createInviteToken(inviteId, mockUser.email);
|
|
const verified = verifyInviteToken(token);
|
|
expect(verified).toEqual({
|
|
inviteId,
|
|
email: mockUser.email,
|
|
});
|
|
});
|
|
|
|
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", () => {
|
|
test("should verify and decrypt valid email change token", async () => {
|
|
const userId = "test-user-id";
|
|
const email = "test@example.com";
|
|
const token = createEmailChangeToken(userId, email);
|
|
const result = await verifyEmailChangeToken(token);
|
|
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 () => {
|
|
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 () => {
|
|
mockSymmetricDecrypt.mockImplementation(() => {
|
|
throw new Error("Decryption failed");
|
|
});
|
|
|
|
const payload = { id: "plain-id", email: "plain@example.com" };
|
|
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
|
|
});
|
|
});
|
|
});
|
|
});
|