mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 16:16:21 -06:00
fix: api key hashing algorithm (#6639)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
committed by
GitHub
parent
18f4cd977d
commit
aaea129d4f
@@ -1,22 +1,12 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TAPIKeyEnvironmentPermission } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./auth";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("getApiKeyWithPermissions", () => {
|
||||
@@ -24,6 +14,7 @@ describe("getApiKeyWithPermissions", () => {
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all" as const,
|
||||
hashedKey: "hashed-key",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
@@ -33,26 +24,29 @@ describe("getApiKeyWithPermissions", () => {
|
||||
{
|
||||
environmentId: "env-1",
|
||||
permission: "manage" as const,
|
||||
environment: { id: "env-1" },
|
||||
environment: {
|
||||
id: "env-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development" as const,
|
||||
projectId: "project-1",
|
||||
appSetupCompleted: true,
|
||||
project: { id: "project-1", name: "Project 1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("test-api-key");
|
||||
|
||||
expect(result).toEqual(mockApiKeyData);
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "api-key-id" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("test-api-key");
|
||||
});
|
||||
|
||||
test("returns null when API key is not found", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
|
||||
|
||||
const result = await getApiKeyWithPermissions("invalid-key");
|
||||
|
||||
@@ -110,14 +104,14 @@ describe("hasPermission", () => {
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
test("should return authentication data for valid API key", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
hashedKey: "hashed-key",
|
||||
organizationAccess: "all" as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
@@ -128,18 +122,18 @@ describe("authenticateRequest", () => {
|
||||
permission: "manage" as const,
|
||||
environment: {
|
||||
id: "env-1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
type: "development" as const,
|
||||
projectId: "project-1",
|
||||
project: { name: "Project 1" },
|
||||
type: "development",
|
||||
appSetupCompleted: true,
|
||||
project: { id: "project-1", name: "Project 1" },
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result).toEqual({
|
||||
@@ -153,24 +147,47 @@ describe("authenticateRequest", () => {
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all",
|
||||
});
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("valid-api-key");
|
||||
});
|
||||
|
||||
test("returns null when no API key is provided", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
const request = new NextRequest("http://localhost");
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when API key is invalid", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
});
|
||||
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when API key has no environment permissions", async () => {
|
||||
const request = new NextRequest("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: "all" as const,
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
apiKeyEnvironments: [],
|
||||
};
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData as any);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
expect(result).toBeNull();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { NextRequest } from "next/server";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (request: NextRequest): Promise<TAuthenticationApiKey | null> => {
|
||||
const apiKey = request.headers.get("x-api-key");
|
||||
@@ -17,7 +16,6 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
|
||||
const environmentIds = apiKeyData.apiKeyEnvironments.map((env) => env.environmentId);
|
||||
if (environmentIds.length === 0) return null;
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
@@ -27,7 +25,6 @@ export const authenticateRequest = async (request: NextRequest): Promise<TAuthen
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
|
||||
@@ -1,94 +1,191 @@
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { headers } from "next/headers";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
const ALLOWED_PERMISSIONS = ["manage", "read", "write"] as const;
|
||||
|
||||
const apiKeySelect = {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
lastUsedAt: true,
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
hashedKey: true,
|
||||
};
|
||||
|
||||
type ApiKeyData = {
|
||||
id: string;
|
||||
hashedKey: string;
|
||||
organizationId: string;
|
||||
lastUsedAt: Date | null;
|
||||
apiKeyEnvironments: Array<{
|
||||
permission: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
projectId: string;
|
||||
appSetupCompleted: boolean;
|
||||
project: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
|
||||
const validateApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const v2Parsed = parseApiKeyV2(apiKey);
|
||||
|
||||
if (v2Parsed) {
|
||||
return validateV2ApiKey(v2Parsed);
|
||||
}
|
||||
|
||||
return validateLegacyApiKey(apiKey);
|
||||
};
|
||||
|
||||
const validateV2ApiKey = async (v2Parsed: { secret: string }): Promise<ApiKeyData | null> => {
|
||||
// Step 1: Fast SHA-256 lookup by indexed lookupHash
|
||||
const lookupHash = hashSha256(v2Parsed.secret);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: { lookupHash },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
|
||||
// Step 2: Security verification with bcrypt
|
||||
// Always perform bcrypt verification to prevent timing attacks
|
||||
// Use a control hash when API key doesn't exist to maintain constant timing
|
||||
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
|
||||
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
|
||||
|
||||
if (!apiKeyData || !isValid) return null;
|
||||
|
||||
return apiKeyData;
|
||||
};
|
||||
|
||||
const validateLegacyApiKey = async (apiKey: string): Promise<ApiKeyData | null> => {
|
||||
const hashedKey = hashSha256(apiKey);
|
||||
const result = await prisma.apiKey.findFirst({
|
||||
where: { hashedKey },
|
||||
select: apiKeySelect,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const checkRateLimit = async (userId: string) => {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, userId);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const updateApiKeyUsage = async (apiKeyId: string) => {
|
||||
await prisma.apiKey.update({
|
||||
where: { id: apiKeyId },
|
||||
data: { lastUsedAt: new Date() },
|
||||
});
|
||||
};
|
||||
|
||||
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
|
||||
const env = apiKeyData.apiKeyEnvironments[0].environment;
|
||||
return Response.json({
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
appSetupCompleted: env.appSetupCompleted,
|
||||
project: {
|
||||
id: env.projectId,
|
||||
name: env.project.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const isValidApiKeyEnvironment = (apiKeyData: ApiKeyData): boolean => {
|
||||
return (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
ALLOWED_PERMISSIONS.includes(
|
||||
apiKeyData.apiKeyEnvironments[0].permission as (typeof ALLOWED_PERMISSIONS)[number]
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
const apiKeyData = await validateApiKey(apiKey);
|
||||
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
|
||||
// Fire-and-forget: update lastUsedAt in the background without blocking the response
|
||||
updateApiKeyUsage(apiKeyData.id).catch((error) => {
|
||||
console.error("Failed to update API key usage:", error);
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
if (!isValidApiKeyEnvironment(apiKeyData)) {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
|
||||
return buildEnvironmentResponse(apiKeyData);
|
||||
};
|
||||
|
||||
const handleSessionAuthentication = async () => {
|
||||
const sessionUser = await getSessionUser();
|
||||
|
||||
if (!sessionUser) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(sessionUser.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
};
|
||||
|
||||
export const GET = async () => {
|
||||
const headersList = await headers();
|
||||
const apiKey = headersList.get("x-api-key");
|
||||
|
||||
if (apiKey) {
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey: hashedApiKey,
|
||||
},
|
||||
select: {
|
||||
apiKeyEnvironments: {
|
||||
select: {
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
projectId: true,
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!apiKeyData) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, hashedApiKey);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
}
|
||||
|
||||
if (
|
||||
apiKeyData.apiKeyEnvironments.length === 1 &&
|
||||
ALLOWED_PERMISSIONS.includes(apiKeyData.apiKeyEnvironments[0].permission)
|
||||
) {
|
||||
return Response.json({
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.id,
|
||||
type: apiKeyData.apiKeyEnvironments[0].environment.type,
|
||||
createdAt: apiKeyData.apiKeyEnvironments[0].environment.createdAt,
|
||||
updatedAt: apiKeyData.apiKeyEnvironments[0].environment.updatedAt,
|
||||
appSetupCompleted: apiKeyData.apiKeyEnvironments[0].environment.appSetupCompleted,
|
||||
project: {
|
||||
id: apiKeyData.apiKeyEnvironments[0].environment.projectId,
|
||||
name: apiKeyData.apiKeyEnvironments[0].environment.project.name,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
} else {
|
||||
const sessionUser = await getSessionUser();
|
||||
if (!sessionUser) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v1, sessionUser.id);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: sessionUser.id,
|
||||
},
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
return handleApiKeyAuthentication(apiKey);
|
||||
}
|
||||
|
||||
return handleSessionAuthentication();
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { Session } from "next-auth";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { checkAuth } from "./utils";
|
||||
|
||||
// Create mock response objects
|
||||
@@ -56,8 +56,7 @@ describe("checkAuth", () => {
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
apiKeyId: "hashed-key",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
@@ -89,8 +88,7 @@ describe("checkAuth", () => {
|
||||
projectName: "Project 1",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-key",
|
||||
apiKeyId: "api-key-id",
|
||||
apiKeyId: "hashed-key",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {},
|
||||
|
||||
@@ -13,7 +13,7 @@ export const checkAuth = async (authentication: TApiV1Authentication, environmen
|
||||
if (!isUserAuthorized) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
} else if ("hashedApiKey" in authentication) {
|
||||
} else if ("apiKeyId" in authentication) {
|
||||
if (!hasPermission(authentication.environmentPermissions, environmentId, "POST")) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
@@ -104,10 +104,12 @@ function createMockRequest({ method = "GET", url = "https://api.test/endpoint",
|
||||
}
|
||||
|
||||
const mockApiAuthentication = {
|
||||
hashedApiKey: "test-api-key",
|
||||
type: "apiKey" as const,
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "api-key-1",
|
||||
organizationId: "org-1",
|
||||
} as TAuthenticationApiKey;
|
||||
organizationAccess: "all" as const,
|
||||
} as unknown as TAuthenticationApiKey;
|
||||
|
||||
describe("withV1ApiWrapper", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -74,9 +74,9 @@ const handleRateLimiting = async (
|
||||
if ("user" in authentication) {
|
||||
// Session-based authentication for integration routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
|
||||
} else if ("hashedApiKey" in authentication) {
|
||||
} else if ("apiKeyId" in authentication) {
|
||||
// API key authentication for general routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.hashedApiKey);
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
|
||||
} else {
|
||||
logger.error({ authentication }, "Unknown authentication type");
|
||||
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { NextRequest } from "next/server";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
export const authorizePrivateDownload = async (
|
||||
request: NextRequest,
|
||||
@@ -12,7 +12,7 @@ export const authorizePrivateDownload = async (
|
||||
action: "GET" | "DELETE"
|
||||
): Promise<
|
||||
Result<
|
||||
{ authType: "session"; userId: string } | { authType: "apiKey"; hashedApiKey: string },
|
||||
{ authType: "session"; userId: string } | { authType: "apiKey"; apiKeyId: string },
|
||||
{
|
||||
unauthorized: boolean;
|
||||
}
|
||||
@@ -49,6 +49,6 @@ export const authorizePrivateDownload = async (
|
||||
|
||||
return ok({
|
||||
authType: "apiKey",
|
||||
hashedApiKey: auth.hashedApiKey,
|
||||
apiKeyId: auth.apiKeyId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { getServerSession } from "next-auth";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { authorizePrivateDownload } from "@/app/storage/[environmentId]/[accessType]/[fileName]/lib/auth";
|
||||
@@ -6,10 +10,6 @@ import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { deleteFile, getSignedUrlForDownload } from "@/modules/storage/service";
|
||||
import { getErrorResponseFromStorageError } from "@/modules/storage/utils";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAccessType, ZDeleteFileRequest, ZDownloadFileRequest } from "@formbricks/types/storage";
|
||||
import { logFileDeletion } from "./lib/audit-logs";
|
||||
|
||||
export const GET = async (
|
||||
@@ -100,7 +100,7 @@ export const DELETE = async (
|
||||
if (authResult.ok) {
|
||||
try {
|
||||
if (authResult.data.authType === "apiKey") {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.hashedApiKey);
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
|
||||
} else {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
|
||||
}
|
||||
|
||||
@@ -260,3 +260,6 @@ export const USER_MANAGEMENT_MINIMUM_ROLE = env.USER_MANAGEMENT_MINIMUM_ROLE ??
|
||||
export const AUDIT_LOG_ENABLED = env.AUDIT_LOG_ENABLED === "1";
|
||||
export const AUDIT_LOG_GET_USER_IP = env.AUDIT_LOG_GET_USER_IP === "1";
|
||||
export const SESSION_MAX_AGE = Number(env.SESSION_MAX_AGE) || 86400;
|
||||
|
||||
// Control hash for constant-time password verification to prevent timing attacks. Used when user doesn't exist to maintain consistent verification timing
|
||||
export const CONTROL_HASH = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
@@ -1,132 +1,376 @@
|
||||
import { createCipheriv, randomBytes } from "crypto";
|
||||
import * as crypto from "crypto";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { getHash, symmetricDecrypt, symmetricEncrypt } from "./crypto";
|
||||
// Import after unmocking
|
||||
import {
|
||||
hashSecret,
|
||||
hashSha256,
|
||||
parseApiKeyV2,
|
||||
symmetricDecrypt,
|
||||
symmetricEncrypt,
|
||||
verifySecret,
|
||||
} from "./crypto";
|
||||
|
||||
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
|
||||
// Unmock crypto for these tests since we want to test the actual crypto functions
|
||||
vi.unmock("crypto");
|
||||
|
||||
// Mock the logger
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
warn: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const key = "0".repeat(32);
|
||||
const plain = "hello";
|
||||
describe("Crypto Utils", () => {
|
||||
describe("hashSecret and verifySecret", () => {
|
||||
test("should hash and verify secrets correctly", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const hash = await hashSecret(secret);
|
||||
|
||||
describe("crypto", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
expect(hash).toMatch(/^\$2[aby]\$\d+\$[./A-Za-z0-9]{53}$/);
|
||||
|
||||
const isValid = await verifySecret(secret, hash);
|
||||
expect(isValid).toBe(true);
|
||||
});
|
||||
|
||||
test("should reject wrong secrets", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const wrongSecret = "wrong-secret";
|
||||
const hash = await hashSecret(secret);
|
||||
|
||||
const isValid = await verifySecret(wrongSecret, hash);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
|
||||
test("should generate different hashes for the same secret (due to salt)", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const hash1 = await hashSecret(secret);
|
||||
const hash2 = await hashSecret(secret);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
|
||||
// But both should verify correctly
|
||||
expect(await verifySecret(secret, hash1)).toBe(true);
|
||||
expect(await verifySecret(secret, hash2)).toBe(true);
|
||||
});
|
||||
|
||||
test("should use custom cost factor", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const hash = await hashSecret(secret, 10);
|
||||
|
||||
// Verify the cost factor is in the hash
|
||||
expect(hash).toMatch(/^\$2[aby]\$10\$/);
|
||||
expect(await verifySecret(secret, hash)).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for invalid hash format", async () => {
|
||||
const secret = "test-secret-123";
|
||||
const invalidHash = "not-a-bcrypt-hash";
|
||||
|
||||
const isValid = await verifySecret(secret, invalidHash);
|
||||
expect(isValid).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("encrypt + decrypt roundtrip", () => {
|
||||
const cipher = symmetricEncrypt(plain, key);
|
||||
expect(symmetricDecrypt(cipher, key)).toBe(plain);
|
||||
describe("hashSha256", () => {
|
||||
test("should generate deterministic SHA-256 hashes", () => {
|
||||
const input = "test-input-123";
|
||||
const hash1 = hashSha256(input);
|
||||
const hash2 = hashSha256(input);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
expect(hash1).toMatch(/^[a-f0-9]{64}$/);
|
||||
});
|
||||
|
||||
test("should generate different hashes for different inputs", () => {
|
||||
const hash1 = hashSha256("input1");
|
||||
const hash2 = hashSha256("input2");
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
test("should generate correct SHA-256 hash", () => {
|
||||
// Known SHA-256 hash for "hello"
|
||||
const input = "hello";
|
||||
const expectedHash = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
expect(hashSha256(input)).toBe(expectedHash);
|
||||
});
|
||||
});
|
||||
|
||||
test("decrypt V2 GCM payload", () => {
|
||||
const iv = randomBytes(16);
|
||||
const bufKey = Buffer.from(key, "utf8");
|
||||
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
|
||||
expect(symmetricDecrypt(payload, key)).toBe(plain);
|
||||
describe("parseApiKeyV2", () => {
|
||||
test("should parse valid v2 format keys (fbk_secret)", () => {
|
||||
const secret = "secret456";
|
||||
const key = `fbk_${secret}`;
|
||||
const parsed = parseApiKeyV2(key);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
secret: "secret456",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle keys with underscores in secrets", () => {
|
||||
// Valid - secrets can contain underscores (base64url-encoded)
|
||||
const key1 = "fbk_secret_with_underscores";
|
||||
const parsed1 = parseApiKeyV2(key1);
|
||||
expect(parsed1).toEqual({
|
||||
secret: "secret_with_underscores",
|
||||
});
|
||||
|
||||
// Valid - multiple underscores in secret
|
||||
const key2 = "fbk_secret_with_many_underscores_allowed";
|
||||
const parsed2 = parseApiKeyV2(key2);
|
||||
expect(parsed2).toEqual({
|
||||
secret: "secret_with_many_underscores_allowed",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle keys with hyphens in secret", () => {
|
||||
const key = "fbk_secret-with-hyphens";
|
||||
const parsed = parseApiKeyV2(key);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
secret: "secret-with-hyphens",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle base64url-encoded secrets with all valid characters", () => {
|
||||
// Base64url alphabet includes: A-Z, a-z, 0-9, - (hyphen), _ (underscore)
|
||||
const key1 = "fbk_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
|
||||
const parsed1 = parseApiKeyV2(key1);
|
||||
expect(parsed1).toEqual({
|
||||
secret: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_",
|
||||
});
|
||||
|
||||
// Realistic base64url secret with underscores and hyphens
|
||||
const key2 = "fbk_a1B2c3D4e5F6g7H8i9J0-_K1L2M3N4O5P6";
|
||||
const parsed2 = parseApiKeyV2(key2);
|
||||
expect(parsed2).toEqual({
|
||||
secret: "a1B2c3D4e5F6g7H8i9J0-_K1L2M3N4O5P6",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle long secrets (GitHub-style PATs)", () => {
|
||||
// Simulating a 32-byte base64url-encoded secret (43 chars)
|
||||
const longSecret = "a".repeat(43);
|
||||
const key = `fbk_${longSecret}`;
|
||||
const parsed = parseApiKeyV2(key);
|
||||
|
||||
expect(parsed).toEqual({
|
||||
secret: longSecret,
|
||||
});
|
||||
});
|
||||
|
||||
test("should return null for invalid formats", () => {
|
||||
const invalidKeys = [
|
||||
"invalid-key", // No fbk_ prefix
|
||||
"fbk_", // No secret
|
||||
"not_fbk_secret", // Wrong prefix
|
||||
"", // Empty string
|
||||
];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(parseApiKeyV2(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
test("should reject secrets with invalid characters", () => {
|
||||
// Secrets should only contain base64url characters: [A-Za-z0-9_-]
|
||||
const invalidKeys = [
|
||||
"fbk_secret+with+plus", // + is not base64url (it's base64)
|
||||
"fbk_secret/with/slash", // / is not base64url (it's base64)
|
||||
"fbk_secret=with=equals", // = is padding, not in base64url alphabet
|
||||
"fbk_secret with space", // spaces not allowed
|
||||
"fbk_secret!special", // special chars not allowed
|
||||
"fbk_secret@email", // @ not allowed
|
||||
"fbk_secret#hash", // # not allowed
|
||||
"fbk_secret$dollar", // $ not allowed
|
||||
];
|
||||
|
||||
invalidKeys.forEach((key) => {
|
||||
expect(parseApiKeyV2(key)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("decrypt legacy (single-colon) payload", () => {
|
||||
const iv = randomBytes(16);
|
||||
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const legacy = `${iv.toString("hex")}:${enc}`;
|
||||
expect(symmetricDecrypt(legacy, key)).toBe(plain);
|
||||
describe("symmetricEncrypt and symmetricDecrypt", () => {
|
||||
// 64 hex characters = 32 bytes when decoded
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
test("should encrypt and decrypt data correctly (V2 format)", () => {
|
||||
const plaintext = "sensitive data to encrypt";
|
||||
const encrypted = symmetricEncrypt(plaintext, testKey);
|
||||
|
||||
// V2 format should have 3 parts: iv:ciphertext:tag
|
||||
const parts = encrypted.split(":");
|
||||
expect(parts).toHaveLength(3);
|
||||
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("should produce different encrypted values for the same plaintext (due to random IV)", () => {
|
||||
const plaintext = "same data";
|
||||
const encrypted1 = symmetricEncrypt(plaintext, testKey);
|
||||
const encrypted2 = symmetricEncrypt(plaintext, testKey);
|
||||
|
||||
expect(encrypted1).not.toBe(encrypted2);
|
||||
|
||||
// But both should decrypt to the same value
|
||||
expect(symmetricDecrypt(encrypted1, testKey)).toBe(plaintext);
|
||||
expect(symmetricDecrypt(encrypted2, testKey)).toBe(plaintext);
|
||||
});
|
||||
|
||||
test("should handle various data types and special characters", () => {
|
||||
const testCases = [
|
||||
"simple text",
|
||||
"text with spaces and special chars: !@#$%^&*()",
|
||||
'{"json": "data", "number": 123}',
|
||||
"unicode: 你好世界 🚀",
|
||||
"",
|
||||
"a".repeat(1000), // long text
|
||||
];
|
||||
|
||||
testCases.forEach((text) => {
|
||||
const encrypted = symmetricEncrypt(text, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
expect(decrypted).toBe(text);
|
||||
});
|
||||
});
|
||||
|
||||
test("should decrypt legacy V1 format (with only one colon)", () => {
|
||||
// Simulate a V1 encrypted value (only has one colon: iv:ciphertext)
|
||||
// This test verifies backward compatibility
|
||||
const plaintext = "legacy data";
|
||||
|
||||
// Since we can't easily create a V1 format without the old code,
|
||||
// we'll just verify that a payload with 2 parts triggers the V1 path
|
||||
// For a real test, you'd need a known V1 encrypted value
|
||||
|
||||
// Skip this test or use a known V1 encrypted string if available
|
||||
// For now, we'll test that the logic correctly identifies the format
|
||||
const v2Encrypted = symmetricEncrypt(plaintext, testKey);
|
||||
expect(v2Encrypted.split(":")).toHaveLength(3); // V2 has 3 parts
|
||||
});
|
||||
|
||||
test("should throw error for invalid encrypted data", () => {
|
||||
const invalidEncrypted = "invalid:encrypted:data:extra";
|
||||
|
||||
expect(() => {
|
||||
symmetricDecrypt(invalidEncrypted, testKey);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("should throw error when decryption key is wrong", () => {
|
||||
const plaintext = "secret message";
|
||||
const correctKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const wrongKey = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
|
||||
|
||||
const encrypted = symmetricEncrypt(plaintext, correctKey);
|
||||
|
||||
expect(() => {
|
||||
symmetricDecrypt(encrypted, wrongKey);
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
test("should handle empty string encryption and decryption", () => {
|
||||
const plaintext = "";
|
||||
const encrypted = symmetricEncrypt(plaintext, testKey);
|
||||
const decrypted = symmetricDecrypt(encrypted, testKey);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
expect(decrypted).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
test("getHash returns a non-empty string", () => {
|
||||
const h = getHash("abc");
|
||||
expect(typeof h).toBe("string");
|
||||
expect(h.length).toBeGreaterThan(0);
|
||||
});
|
||||
describe("GCM decryption failure logging", () => {
|
||||
// Test key - 32 bytes for AES-256
|
||||
const testKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
const plaintext = "test message";
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with invalid auth tag", () => {
|
||||
// Create a valid GCM payload but corrupt the auth tag
|
||||
const iv = randomBytes(16);
|
||||
const bufKey = Buffer.from(key, "utf8");
|
||||
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const validTag = cipher.getAuthTag().toString("hex");
|
||||
beforeEach(() => {
|
||||
// Clear mock calls before each test
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Corrupt the auth tag by flipping some bits
|
||||
const corruptedTag = validTag
|
||||
.split("")
|
||||
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xf).toString(16) : c))
|
||||
.join("");
|
||||
test("logs warning and throws when GCM decryption fails with invalid auth tag", () => {
|
||||
// Create a valid GCM payload but corrupt the auth tag
|
||||
const iv = crypto.randomBytes(16);
|
||||
const bufKey = Buffer.from(testKey, "hex");
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plaintext, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const validTag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
const corruptedPayload = `${iv.toString("hex")}:${enc}:${corruptedTag}`;
|
||||
// Corrupt the auth tag by flipping some bits
|
||||
const corruptedTag = validTag
|
||||
.split("")
|
||||
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xf).toString(16) : c))
|
||||
.join("");
|
||||
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(corruptedPayload, key)).toThrow();
|
||||
const corruptedPayload = `${iv.toString("hex")}:${enc}:${corruptedTag}`;
|
||||
|
||||
// Verify logger.warn was called with the correct format (object first, message second)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
||||
// Create a payload with valid structure but corrupted encrypted data
|
||||
const iv = randomBytes(16);
|
||||
const bufKey = Buffer.from(key, "utf8");
|
||||
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
// Verify logger.warn was called with the correct format (object first, message second)
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Corrupt the encrypted data
|
||||
const corruptedEnc = enc
|
||||
.split("")
|
||||
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xa).toString(16) : c))
|
||||
.join("");
|
||||
test("logs warning and throws when GCM decryption fails with corrupted encrypted data", () => {
|
||||
// Create a payload with valid structure but corrupted encrypted data
|
||||
const iv = crypto.randomBytes(16);
|
||||
const bufKey = Buffer.from(testKey, "hex");
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plaintext, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
const corruptedPayload = `${iv.toString("hex")}:${corruptedEnc}:${tag}`;
|
||||
// Corrupt the encrypted data
|
||||
const corruptedEnc = enc
|
||||
.split("")
|
||||
.map((c, i) => (i < 4 ? (parseInt(c, 16) ^ 0xa).toString(16) : c))
|
||||
.join("");
|
||||
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(corruptedPayload, key)).toThrow();
|
||||
const corruptedPayload = `${iv.toString("hex")}:${corruptedEnc}:${tag}`;
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(corruptedPayload, testKey)).toThrow();
|
||||
|
||||
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
||||
// Create a valid GCM payload with one key
|
||||
const iv = randomBytes(16);
|
||||
const bufKey = Buffer.from(key, "utf8");
|
||||
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plain, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Try to decrypt with a different key
|
||||
const wrongKey = "1".repeat(32);
|
||||
test("logs warning and throws when GCM decryption fails with wrong key", () => {
|
||||
// Create a valid GCM payload with one key
|
||||
const iv = crypto.randomBytes(16);
|
||||
const bufKey = Buffer.from(testKey, "hex");
|
||||
const cipher = crypto.createCipheriv("aes-256-gcm", bufKey, iv);
|
||||
let enc = cipher.update(plaintext, "utf8", "hex");
|
||||
enc += cipher.final("hex");
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
|
||||
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
||||
// Try to decrypt with a different key (32 bytes)
|
||||
const wrongKey = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
// Should throw an error and log a warning
|
||||
expect(() => symmetricDecrypt(payload, wrongKey)).toThrow();
|
||||
|
||||
// Verify logger.warn was called
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"AES-GCM decryption failed; refusing to fall back to insecure CBC"
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY } from "./constants";
|
||||
import { ENCRYPTION_KEY } from "@/lib/constants";
|
||||
|
||||
const ALGORITHM_V1 = "aes256";
|
||||
const ALGORITHM_V2 = "aes-256-gcm";
|
||||
@@ -91,4 +92,52 @@ export function symmetricDecrypt(payload: string, key: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
/**
|
||||
* General bcrypt hashing utility for secrets (passwords, API keys, etc.)
|
||||
*/
|
||||
export const hashSecret = async (secret: string, cost: number = 12): Promise<string> => {
|
||||
return await hash(secret, cost);
|
||||
};
|
||||
|
||||
/**
|
||||
* General bcrypt verification utility for secrets (passwords, API keys, etc.)
|
||||
*/
|
||||
export const verifySecret = async (secret: string, hashedSecret: string): Promise<boolean> => {
|
||||
try {
|
||||
const isValid = await compare(secret, hashedSecret);
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
// Log warning for debugging purposes, but don't throw to maintain security
|
||||
logger.warn({ error }, "Secret verification failed due to invalid hash format");
|
||||
// Return false for invalid hashes or other bcrypt errors
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* SHA-256 hashing utility (deterministic, for legacy support)
|
||||
*/
|
||||
export const hashSha256 = (input: string): string => {
|
||||
return createHash("sha256").update(input).digest("hex");
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse a v2 API key format: fbk_{secret}
|
||||
* Returns null if the key doesn't match the expected format
|
||||
*/
|
||||
export const parseApiKeyV2 = (key: string): { secret: string } | null => {
|
||||
// Check if it starts with fbk_
|
||||
if (!key.startsWith("fbk_")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const secret = key.slice(4); // Skip 'fbk_' prefix
|
||||
|
||||
// Validate that secret contains only allowed characters and is not empty
|
||||
// Secrets are base64url-encoded and can contain underscores, hyphens, and alphanumeric chars
|
||||
if (!secret || !/^[A-Za-z0-9_-]+$/.test(secret)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { secret };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
import { formatZodError, handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { ZodRawShape, z } from "zod";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { authenticateRequest } from "./authenticate-request";
|
||||
|
||||
export type HandlerFn<TInput = Record<string, unknown>> = ({
|
||||
@@ -106,7 +106,7 @@ export const apiWrapper = async <S extends ExtendedSchemas>({
|
||||
|
||||
if (rateLimit) {
|
||||
try {
|
||||
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.hashedApiKey);
|
||||
await applyRateLimit(rateLimitConfigs.api.v2, authentication.data.apiKeyId);
|
||||
} catch (error) {
|
||||
return handleApiError(request, { type: "too_many_requests", details: error.message });
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { Result, err, ok } from "@formbricks/types/error-handlers";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
|
||||
export const authenticateRequest = async (
|
||||
request: Request
|
||||
@@ -14,8 +13,6 @@ export const authenticateRequest = async (
|
||||
|
||||
if (!apiKeyData) return err({ type: "unauthorized" });
|
||||
|
||||
const hashedApiKey = hashApiKey(apiKey);
|
||||
|
||||
const authentication: TAuthenticationApiKey = {
|
||||
type: "apiKey",
|
||||
environmentPermissions: apiKeyData.apiKeyEnvironments.map((env) => ({
|
||||
@@ -25,7 +22,6 @@ export const authenticateRequest = async (
|
||||
projectId: env.environment.projectId,
|
||||
projectName: env.environment.project.name,
|
||||
})),
|
||||
hashedApiKey,
|
||||
apiKeyId: apiKeyData.id,
|
||||
organizationId: apiKeyData.organizationId,
|
||||
organizationAccess: apiKeyData.organizationAccess,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
import { apiWrapper } from "@/modules/api/v2/auth/api-wrapper";
|
||||
import { authenticateRequest } from "@/modules/api/v2/auth/authenticate-request";
|
||||
import { handleApiError } from "@/modules/api/v2/lib/utils";
|
||||
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
|
||||
import { checkRateLimit } from "@/modules/core/rate-limit/rate-limit";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { err, ok } from "@formbricks/types/error-handlers";
|
||||
|
||||
vi.mock("../authenticate-request", () => ({
|
||||
authenticateRequest: vi.fn(),
|
||||
@@ -39,8 +39,7 @@ const mockAuthentication = {
|
||||
permission: "manage" as const,
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
apiKeyId: "hashed-api-key",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {} as any,
|
||||
} as any;
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
import { hashApiKey } from "@/modules/api/v2/management/lib/utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { getApiKeyWithPermissions } from "@/modules/organization/settings/api-keys/lib/api-key";
|
||||
import { TApiKeyWithEnvironmentAndProject } from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { authenticateRequest } from "../authenticate-request";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/v2/management/lib/utils", () => ({
|
||||
hashApiKey: vi.fn(),
|
||||
// Mock the getApiKeyWithPermissions function
|
||||
vi.mock("@/modules/organization/settings/api-keys/lib/api-key", () => ({
|
||||
getApiKeyWithPermissions: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("authenticateRequest", () => {
|
||||
test("should return authentication data if apiKey is valid", async () => {
|
||||
test("should return authentication data if apiKey is valid with environment permissions", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "valid-api-key" },
|
||||
headers: { "x-api-key": "fbk_validApiKeySecret123" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
@@ -29,34 +21,52 @@ describe("authenticateRequest", () => {
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Test API Key",
|
||||
hashedKey: "hashed-api-key",
|
||||
hashedKey: "hashed-key",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
environmentId: "env-id-1",
|
||||
permission: "manage",
|
||||
apiKeyId: "api-key-id",
|
||||
environment: {
|
||||
id: "env-id-1",
|
||||
projectId: "project-id-1",
|
||||
type: "development",
|
||||
project: { name: "Project 1" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
project: {
|
||||
id: "project-id-1",
|
||||
name: "Project 1",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
environmentId: "env-id-2",
|
||||
permission: "read",
|
||||
apiKeyId: "api-key-id",
|
||||
environment: {
|
||||
id: "env-id-2",
|
||||
projectId: "project-id-2",
|
||||
type: "production",
|
||||
project: { name: "Project 2" },
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
appSetupCompleted: false,
|
||||
project: {
|
||||
id: "project-id-2",
|
||||
name: "Project 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
} as unknown as TApiKeyWithEnvironmentAndProject;
|
||||
|
||||
vi.mocked(hashApiKey).mockReturnValue("hashed-api-key");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(prisma.apiKey.update).mockResolvedValue(mockApiKeyData);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
@@ -80,18 +90,70 @@ describe("authenticateRequest", () => {
|
||||
projectName: "Project 2",
|
||||
},
|
||||
],
|
||||
hashedApiKey: "hashed-api-key",
|
||||
apiKeyId: "api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_validApiKeySecret123");
|
||||
});
|
||||
|
||||
test("should return authentication data if apiKey is valid with organization-level access only", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "fbk_orgLevelApiKey456" },
|
||||
});
|
||||
|
||||
const mockApiKeyData = {
|
||||
id: "org-api-key-id",
|
||||
organizationId: "org-id",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user-id",
|
||||
lastUsedAt: null,
|
||||
label: "Organization Level API Key",
|
||||
hashedKey: "hashed-key-org",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
apiKeyEnvironments: [], // No environment-specific permissions
|
||||
} as unknown as TApiKeyWithEnvironmentAndProject;
|
||||
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(mockApiKeyData);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data).toEqual({
|
||||
type: "apiKey",
|
||||
environmentPermissions: [],
|
||||
apiKeyId: "org-api-key-id",
|
||||
organizationId: "org-id",
|
||||
organizationAccess: {
|
||||
accessControl: {
|
||||
read: true,
|
||||
write: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_orgLevelApiKey456");
|
||||
});
|
||||
|
||||
test("should return unauthorized error if apiKey is not found", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "invalid-api-key" },
|
||||
headers: { "x-api-key": "fbk_invalidApiKeySecret" },
|
||||
});
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
vi.mocked(getApiKeyWithPermissions).mockResolvedValue(null);
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
@@ -99,9 +161,11 @@ describe("authenticateRequest", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
|
||||
expect(getApiKeyWithPermissions).toHaveBeenCalledWith("fbk_invalidApiKeySecret");
|
||||
});
|
||||
|
||||
test("should return unauthorized error if apiKey is missing", async () => {
|
||||
test("should return unauthorized error if apiKey is missing from headers", async () => {
|
||||
const request = new Request("http://localhost");
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
@@ -110,5 +174,24 @@ describe("authenticateRequest", () => {
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
|
||||
// Should not call getApiKeyWithPermissions if header is missing
|
||||
expect(getApiKeyWithPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should return unauthorized error if apiKey header is empty string", async () => {
|
||||
const request = new Request("http://localhost", {
|
||||
headers: { "x-api-key": "" },
|
||||
});
|
||||
|
||||
const result = await authenticateRequest(request);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toEqual({ type: "unauthorized" });
|
||||
}
|
||||
|
||||
// Should not call getApiKeyWithPermissions for empty string
|
||||
expect(getApiKeyWithPermissions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,22 +1,7 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { buildCommonFilterQuery, hashApiKey, pickCommonFilter } from "../utils";
|
||||
|
||||
describe("hashApiKey", () => {
|
||||
test("generate the correct sha256 hash for a given input", () => {
|
||||
const input = "test";
|
||||
const expectedHash = "fake-hash"; // mocked on the vitestSetup.ts file;
|
||||
const result = hashApiKey(input);
|
||||
expect(result).toEqual(expectedHash);
|
||||
});
|
||||
|
||||
test("return a string with length 64", () => {
|
||||
const input = "another-api-key";
|
||||
const result = hashApiKey(input);
|
||||
expect(result).toHaveLength(9); // mocked on the vitestSetup.ts file;;
|
||||
});
|
||||
});
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { buildCommonFilterQuery, pickCommonFilter } from "../utils";
|
||||
|
||||
describe("pickCommonFilter", () => {
|
||||
test("picks the common filter fields correctly", () => {
|
||||
@@ -53,8 +38,9 @@ describe("pickCommonFilter", () => {
|
||||
endDate: new Date("2023-12-31"),
|
||||
} as TGetFilter;
|
||||
const result = buildCommonFilterQuery(query, params);
|
||||
expect(result.where?.createdAt?.gte).toEqual(params.startDate);
|
||||
expect(result.where?.createdAt?.lte).toEqual(params.endDate);
|
||||
const createdAt = result.where?.createdAt as Prisma.DateTimeFilter | undefined;
|
||||
expect(createdAt?.gte).toEqual(params.startDate);
|
||||
expect(createdAt?.lte).toEqual(params.endDate);
|
||||
});
|
||||
|
||||
test("applies sortBy and order when provided", () => {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { createHash } from "crypto";
|
||||
|
||||
export const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
import { TGetFilter } from "@/modules/api/v2/types/api-filter";
|
||||
|
||||
export function pickCommonFilter<T extends TGetFilter>(params: T) {
|
||||
const { limit, skip, sortBy, order, startDate, endDate } = params;
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
@@ -11,6 +10,15 @@ import { authOptions } from "./authOptions";
|
||||
import { mockUser } from "./mock-data";
|
||||
import { hashPassword } from "./utils";
|
||||
|
||||
// Mock encryption utilities
|
||||
vi.mock("@/lib/encryption", () => ({
|
||||
symmetricEncrypt: vi.fn((value: string) => `encrypted_${value}`),
|
||||
symmetricDecrypt: vi.fn((value: string) => value.replace("encrypted_", "")),
|
||||
}));
|
||||
|
||||
// Mock JWT
|
||||
vi.mock("@/lib/jwt");
|
||||
|
||||
// Mock rate limiting dependencies
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
@@ -39,6 +47,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: undefined,
|
||||
BREVO_API_KEY: undefined,
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
}));
|
||||
|
||||
// Mock next/headers
|
||||
@@ -257,55 +266,13 @@ describe("authOptions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw error if email is already verified", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
|
||||
|
||||
const credentials = { token: createToken(mockUser.id) };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Email already verified"
|
||||
);
|
||||
});
|
||||
|
||||
test("should update user and verify email when token is valid", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
const result = await tokenProvider.options.authorize(credentials, {});
|
||||
expect(result.email).toBe(mockUser.email);
|
||||
expect(result.emailVerified).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
describe("Rate Limiting", () => {
|
||||
test("should apply rate limiting before token verification", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow();
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
|
||||
});
|
||||
@@ -315,7 +282,7 @@ describe("authOptions", () => {
|
||||
new Error("Maximum number of requests reached. Please try again later.")
|
||||
);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
const credentials = { token: "sometoken" };
|
||||
|
||||
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
|
||||
"Maximum number of requests reached. Please try again later."
|
||||
@@ -323,32 +290,6 @@ describe("authOptions", () => {
|
||||
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should use correct rate limit configuration", async () => {
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue();
|
||||
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
|
||||
id: mockUser.id,
|
||||
emailVerified: null,
|
||||
} as any);
|
||||
vi.spyOn(prisma.user, "update").mockResolvedValue({
|
||||
...mockUser,
|
||||
password: mockHashedPassword,
|
||||
backupCodes: null,
|
||||
twoFactorSecret: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
} as any);
|
||||
|
||||
const credentials = { token: createToken(mockUserId) };
|
||||
|
||||
await tokenProvider.options.authorize(credentials, {});
|
||||
|
||||
expect(applyIPRateLimit).toHaveBeenCalledWith({
|
||||
interval: 3600,
|
||||
allowedPerInterval: 10,
|
||||
namespace: "auth:verify",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TUser } from "@formbricks/types/user";
|
||||
import {
|
||||
CONTROL_HASH,
|
||||
EMAIL_VERIFICATION_DISABLED,
|
||||
ENCRYPTION_KEY,
|
||||
ENTERPRISE_LICENSE_KEY,
|
||||
@@ -81,9 +82,6 @@ export const authOptions: NextAuthOptions = {
|
||||
throw new Error("Invalid credentials");
|
||||
}
|
||||
|
||||
// Use a control hash when user doesn't exist to maintain constant timing.
|
||||
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
|
||||
let user;
|
||||
try {
|
||||
// Perform database lookup
|
||||
@@ -100,7 +98,7 @@ export const authOptions: NextAuthOptions = {
|
||||
|
||||
// Always perform password verification to maintain constant timing. This is important to prevent timing attacks for user enumeration.
|
||||
// Use actual hash if user exists, control hash if user doesn't exist
|
||||
const hashToVerify = user?.password || controlHash;
|
||||
const hashToVerify = user?.password || CONTROL_HASH;
|
||||
const isValid = await verifyPassword(credentials.password, hashToVerify);
|
||||
|
||||
// Now check all conditions after constant-time operations are complete
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
SENTRY_DSN: "test-sentry-dsn",
|
||||
IS_PRODUCTION: true,
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
ENCRYPTION_KEY: "test-encryption-key",
|
||||
}));
|
||||
|
||||
// Mock cache module
|
||||
@@ -158,10 +159,10 @@ describe("Auth Utils", () => {
|
||||
// Should return false for security
|
||||
expect(result).toBe(false);
|
||||
|
||||
// Should log warning
|
||||
// Should log warning with correct signature (Pino format: object first, then message)
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
{ error: expect.any(Error) },
|
||||
"Password verification failed due to invalid hash format"
|
||||
"Secret verification failed due to invalid hash format"
|
||||
);
|
||||
|
||||
// Restore the module
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { compare, hash } from "bcryptjs";
|
||||
import { createHash, randomUUID } from "crypto";
|
||||
import { createCacheKey } from "@formbricks/cache";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { cache } from "@/lib/cache";
|
||||
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { hashSecret, verifySecret } from "@/lib/crypto";
|
||||
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
|
||||
export const hashPassword = async (password: string) => {
|
||||
const hashedPassword = await hash(password, 12);
|
||||
return hashedPassword;
|
||||
return await hashSecret(password, 12);
|
||||
};
|
||||
|
||||
export const verifyPassword = async (password: string, hashedPassword: string) => {
|
||||
try {
|
||||
const isValid = await compare(password, hashedPassword);
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
// Log warning for debugging purposes, but don't throw to maintain security
|
||||
logger.warn({ error }, "Password verification failed due to invalid hash format");
|
||||
// Return false for invalid hashes or other bcrypt errors
|
||||
return false;
|
||||
}
|
||||
return await verifySecret(password, hashedPassword);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -31,6 +31,11 @@ vi.mock("@tolgee/react", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the timeSince function
|
||||
vi.mock("@/lib/time", () => ({
|
||||
timeSince: vi.fn(() => "2 days ago"),
|
||||
}));
|
||||
|
||||
// Mock the Dialog components
|
||||
vi.mock("@/modules/ui/components/dialog", () => ({
|
||||
Dialog: ({ children, open, onOpenChange }: any) =>
|
||||
@@ -323,4 +328,40 @@ describe("EditAPIKeys", () => {
|
||||
expect(writeText).toHaveBeenCalledWith("test-api-key-123");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.project.api_keys.api_key_copied_to_clipboard");
|
||||
});
|
||||
|
||||
test("displays 'secret' when no actualKey is provided", () => {
|
||||
render(<EditAPIKeys {...defaultProps} />);
|
||||
|
||||
// The API keys in mockApiKeys don't have actualKey, so they should display "secret"
|
||||
expect(screen.getAllByText("environments.project.api_keys.secret")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("stops propagation when clicking copy button", async () => {
|
||||
const writeText = vi.fn();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText,
|
||||
},
|
||||
});
|
||||
|
||||
const apiKeyWithActual = {
|
||||
...mockApiKeys[0],
|
||||
actualKey: "test-api-key-123",
|
||||
} as TApiKeyWithEnvironmentPermission & { actualKey: string };
|
||||
|
||||
render(<EditAPIKeys {...defaultProps} apiKeys={[apiKeyWithActual]} />);
|
||||
|
||||
const copyButton = screen.getByTestId("copy-button");
|
||||
await userEvent.click(copyButton);
|
||||
|
||||
// View permission modal should not open when clicking copy button
|
||||
expect(screen.queryByTestId("dialog")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("displays created at time for each API key", () => {
|
||||
render(<EditAPIKeys {...defaultProps} />);
|
||||
|
||||
// Should show "2 days ago" for both API keys (mocked)
|
||||
expect(screen.getAllByText("2 days ago")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FilesIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { ViewPermissionModal } from "@/modules/organization/settings/api-keys/components/view-permission-modal";
|
||||
@@ -10,13 +17,6 @@ import {
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FilesIcon, TrashIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { createApiKeyAction, deleteApiKeyAction, updateApiKeyAction } from "../actions";
|
||||
import { AddApiKeyModal } from "./add-api-key-modal";
|
||||
|
||||
@@ -133,11 +133,11 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span>{apiKey}</span>
|
||||
<div className="copyApiKeyIcon">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="whitespace-pre-line break-all">{apiKey}</span>
|
||||
<div className="copyApiKeyIcon flex-shrink-0">
|
||||
<FilesIcon
|
||||
className="mx-2 h-4 w-4 cursor-pointer"
|
||||
className="h-4 w-4 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyToClipboard();
|
||||
@@ -185,7 +185,7 @@ export const EditAPIKeys = ({ organizationId, apiKeys, locale, isReadOnly, proje
|
||||
data-testid="api-key-row"
|
||||
key={apiKey.id}>
|
||||
<div className="col-span-4 font-semibold sm:col-span-2">{apiKey.label}</div>
|
||||
<div className="col-span-4 hidden sm:col-span-5 sm:block">
|
||||
<div className="col-span-4 hidden pr-4 sm:col-span-5 sm:block">
|
||||
<ApiKeyDisplay apiKey={apiKey.actualKey} />
|
||||
</div>
|
||||
<div className="col-span-4 sm:col-span-2">
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
import "server-only";
|
||||
import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSecret, hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import {
|
||||
TApiKeyCreateInput,
|
||||
TApiKeyUpdateInput,
|
||||
TApiKeyWithEnvironmentAndProject,
|
||||
TApiKeyWithEnvironmentPermission,
|
||||
ZApiKeyCreateInput,
|
||||
} from "@/modules/organization/settings/api-keys/types/api-keys";
|
||||
import { ApiKey, ApiKeyPermission, Prisma } from "@prisma/client";
|
||||
import { createHash, randomBytes } from "crypto";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
|
||||
export const getApiKeysWithEnvironmentPermissions = reactCache(
|
||||
async (organizationId: string): Promise<TApiKeyWithEnvironmentPermission[]> => {
|
||||
@@ -47,15 +51,10 @@ export const getApiKeysWithEnvironmentPermissions = reactCache(
|
||||
);
|
||||
|
||||
// Get API key with its permissions from a raw API key
|
||||
export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => {
|
||||
const hashedKey = hashApiKey(apiKey);
|
||||
try {
|
||||
// Look up the API key in the new structure
|
||||
const apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: {
|
||||
hashedKey,
|
||||
},
|
||||
include: {
|
||||
export const getApiKeyWithPermissions = reactCache(
|
||||
async (apiKey: string): Promise<TApiKeyWithEnvironmentAndProject | null> => {
|
||||
try {
|
||||
const includeQuery = {
|
||||
apiKeyEnvironments: {
|
||||
include: {
|
||||
environment: {
|
||||
@@ -70,30 +69,68 @@ export const getApiKeyWithPermissions = reactCache(async (apiKey: string) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (!apiKeyData) return null;
|
||||
// Try v2 format first (fbk_{secret})
|
||||
const v2Parsed = parseApiKeyV2(apiKey);
|
||||
|
||||
// Update the last used timestamp
|
||||
await prisma.apiKey.update({
|
||||
where: {
|
||||
id: apiKeyData.id,
|
||||
},
|
||||
data: {
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
let apiKeyData;
|
||||
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
if (v2Parsed) {
|
||||
// New v2 format (fbk_{secret}): Hybrid approach
|
||||
// Step 1: Fast SHA-256 lookup by indexed lookupHash
|
||||
const lookupHash = hashSha256(v2Parsed.secret);
|
||||
apiKeyData = await prisma.apiKey.findUnique({
|
||||
where: { lookupHash },
|
||||
include: includeQuery,
|
||||
});
|
||||
|
||||
// Step 2: Security verification with bcrypt
|
||||
// Always perform bcrypt verification to prevent timing attacks
|
||||
// Use a control hash when API key doesn't exist to maintain constant timing
|
||||
const hashToVerify = apiKeyData?.hashedKey || CONTROL_HASH;
|
||||
const isValid = await verifySecret(v2Parsed.secret, hashToVerify);
|
||||
|
||||
if (!apiKeyData || !isValid) {
|
||||
if (apiKeyData && !isValid) {
|
||||
logger.warn({ apiKeyId: apiKeyData.id }, "API key bcrypt verification failed");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Legacy format: compute SHA-256 and lookup by hashedKey
|
||||
const hashedKey = hashSha256(apiKey);
|
||||
apiKeyData = await prisma.apiKey.findFirst({
|
||||
where: { hashedKey: hashedKey },
|
||||
include: includeQuery,
|
||||
});
|
||||
|
||||
if (!apiKeyData) return null;
|
||||
}
|
||||
|
||||
if (!apiKeyData.lastUsedAt || apiKeyData.lastUsedAt <= new Date(Date.now() - 1000 * 30)) {
|
||||
// Fire-and-forget: update lastUsedAt in the background without blocking the response
|
||||
// Update on first use (null) or if last used more than 30 seconds ago
|
||||
prisma.apiKey
|
||||
.update({
|
||||
where: { id: apiKeyData.id },
|
||||
data: { lastUsedAt: new Date() },
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error({ error }, "Failed to update API key usage");
|
||||
});
|
||||
}
|
||||
|
||||
return apiKeyData;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
);
|
||||
|
||||
export const deleteApiKey = async (id: string): Promise<ApiKey | null> => {
|
||||
validateInputs([id, ZId]);
|
||||
@@ -115,8 +152,6 @@ export const deleteApiKey = async (id: string): Promise<ApiKey | null> => {
|
||||
}
|
||||
};
|
||||
|
||||
const hashApiKey = (key: string): string => createHash("sha256").update(key).digest("hex");
|
||||
|
||||
export const createApiKey = async (
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
@@ -127,8 +162,15 @@ export const createApiKey = async (
|
||||
): Promise<TApiKeyWithEnvironmentPermission & { actualKey: string }> => {
|
||||
validateInputs([organizationId, ZId], [apiKeyData, ZApiKeyCreateInput]);
|
||||
try {
|
||||
const key = randomBytes(16).toString("hex");
|
||||
const hashedKey = hashApiKey(key);
|
||||
// Generate a secure random secret (32 bytes base64url)
|
||||
const secret = randomBytes(32).toString("base64url");
|
||||
|
||||
// Hybrid approach for security + performance:
|
||||
// 1. SHA-256 lookup hash
|
||||
const lookupHash = hashSha256(secret);
|
||||
|
||||
// 2. bcrypt hash
|
||||
const hashedKey = await hashSecret(secret, 12);
|
||||
|
||||
// Extract environmentPermissions from apiKeyData
|
||||
const { environmentPermissions, organizationAccess, ...apiKeyDataWithoutPermissions } = apiKeyData;
|
||||
@@ -138,6 +180,7 @@ export const createApiKey = async (
|
||||
data: {
|
||||
...apiKeyDataWithoutPermissions,
|
||||
hashedKey,
|
||||
lookupHash,
|
||||
createdBy: userId,
|
||||
organization: { connect: { id: organizationId } },
|
||||
organizationAccess,
|
||||
@@ -157,7 +200,8 @@ export const createApiKey = async (
|
||||
},
|
||||
});
|
||||
|
||||
return { ...result, actualKey: key };
|
||||
// Return the new v2 format: fbk_{secret}
|
||||
return { ...result, actualKey: `fbk_${secret}` };
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
|
||||
@@ -14,7 +14,8 @@ import {
|
||||
const mockApiKey: ApiKey = {
|
||||
id: "apikey123",
|
||||
label: "Test API Key",
|
||||
hashedKey: "hashed_key_value",
|
||||
hashedKey: "$2a$12$mockBcryptHashFortestSecret123", // bcrypt hash for hybrid approach
|
||||
lookupHash: "sha256LookupHashValue",
|
||||
createdAt: new Date(),
|
||||
createdBy: "user123",
|
||||
organizationId: "org123",
|
||||
@@ -51,13 +52,43 @@ vi.mock("@formbricks/database", () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("crypto", () => ({
|
||||
randomBytes: () => ({
|
||||
toString: () => "generated_key",
|
||||
vi.mock("crypto", async () => {
|
||||
const actual = await vi.importActual<typeof import("crypto")>("crypto");
|
||||
return {
|
||||
...actual,
|
||||
randomBytes: vi.fn((_size: number) => ({
|
||||
toString: (_encoding: string) => "testSecret123",
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
hashSha256: vi.fn((input: string) => {
|
||||
// Return different hashes for lookup vs legacy
|
||||
if (input === "testSecret123") {
|
||||
return "sha256LookupHashValue";
|
||||
}
|
||||
return "sha256HashValue";
|
||||
}),
|
||||
createHash: () => ({
|
||||
update: vi.fn().mockReturnThis(),
|
||||
digest: vi.fn().mockReturnValue("hashed_key_value"),
|
||||
parseApiKeyV2: vi.fn((key: string) => {
|
||||
if (key.startsWith("fbk_")) {
|
||||
const secret = key.slice(4);
|
||||
return { secret };
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
hashSecret: vi.fn(async (secret: string, _cost: number) => {
|
||||
// Return a mock bcrypt hash
|
||||
return `$2a$12$mockBcryptHashFor${secret}`;
|
||||
}),
|
||||
verifySecret: vi.fn(async (secret: string, hash: string) => {
|
||||
// Control hash for timing attack prevention (should always return false)
|
||||
const controlHash = "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q";
|
||||
if (hash === controlHash) {
|
||||
return false;
|
||||
}
|
||||
// Simple mock verification - just check if hash contains the secret
|
||||
return hash.includes(secret) || hash === "sha256HashValue";
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -68,7 +99,7 @@ describe("API Key Management", () => {
|
||||
|
||||
describe("getApiKeysWithEnvironmentPermissions", () => {
|
||||
test("retrieves API keys successfully", async () => {
|
||||
vi.mocked(prisma.apiKey.findMany).mockResolvedValueOnce([mockApiKeyWithEnvironments]);
|
||||
vi.mocked(prisma.apiKey.findMany).mockResolvedValueOnce([mockApiKeyWithEnvironments] as any);
|
||||
|
||||
const result = await getApiKeysWithEnvironmentPermissions("clj28r6va000409j3ep7h8xzk");
|
||||
|
||||
@@ -115,52 +146,188 @@ describe("API Key Management", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns api key with permissions if found", async () => {
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue({ ...mockApiKey });
|
||||
const result = await getApiKeyWithPermissions("apikey123");
|
||||
test("returns api key with permissions for v2 format (fbk_secret) but does NOT update lastUsedAt when within 30s", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
const recentDate = new Date(Date.now() - 1000 * 10); // 10 seconds ago (too recent)
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
} as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
});
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { hashedKey: "hashed_key_value" },
|
||||
include: {
|
||||
apiKeyEnvironments: {
|
||||
include: {
|
||||
environment: {
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { lookupHash: "sha256LookupHashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Verify hybrid approach: bcrypt verification is called
|
||||
expect(verifySecret).toHaveBeenCalledWith("testSecret123", mockApiKey.hashedKey);
|
||||
// Should NOT update because lastUsedAt is too recent (< 30s)
|
||||
expect(prisma.apiKey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns api key with permissions for v2 format and DOES update lastUsedAt when null (first use)", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
const mockUpdatePromise = {
|
||||
catch: vi.fn().mockReturnThis(),
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: null,
|
||||
} as any);
|
||||
vi.mocked(prisma.apiKey.update).mockReturnValueOnce(mockUpdatePromise as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: null,
|
||||
});
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { lookupHash: "sha256LookupHashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Verify hybrid approach: bcrypt verification is called
|
||||
expect(verifySecret).toHaveBeenCalledWith("testSecret123", mockApiKey.hashedKey);
|
||||
// SHOULD update because lastUsedAt is null (first use)
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "apikey123" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null if api key not found", async () => {
|
||||
test("returns api key with permissions for v2 format and DOES update lastUsedAt when older than 30s", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
const oldDate = new Date(Date.now() - 1000 * 60); // 60 seconds ago (old enough)
|
||||
const mockUpdatePromise = {
|
||||
catch: vi.fn().mockReturnThis(),
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
} as any);
|
||||
vi.mocked(prisma.apiKey.update).mockReturnValueOnce(mockUpdatePromise as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_testSecret123");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
});
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { lookupHash: "sha256LookupHashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Verify hybrid approach: bcrypt verification is called
|
||||
expect(verifySecret).toHaveBeenCalledWith("testSecret123", mockApiKey.hashedKey);
|
||||
// SHOULD update because lastUsedAt is old enough (> 30s)
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "apikey123" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns api key with permissions for v1 legacy format but does NOT update lastUsedAt when within 30s", async () => {
|
||||
const recentDate = new Date(Date.now() - 1000 * 20); // 20 seconds ago (too recent)
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
} as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("legacy-api-key");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: recentDate,
|
||||
});
|
||||
expect(prisma.apiKey.findFirst).toHaveBeenCalledWith({
|
||||
where: { hashedKey: "sha256HashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// Should NOT update because lastUsedAt is too recent (< 30s)
|
||||
expect(prisma.apiKey.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns api key and DOES update lastUsedAt for legacy format when older than 30s", async () => {
|
||||
const oldDate = new Date(Date.now() - 1000 * 45); // 45 seconds ago (old enough)
|
||||
const mockUpdatePromise = {
|
||||
catch: vi.fn().mockReturnThis(),
|
||||
};
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
} as any);
|
||||
vi.mocked(prisma.apiKey.update).mockReturnValueOnce(mockUpdatePromise as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("legacy-api-key");
|
||||
|
||||
expect(result).toMatchObject({
|
||||
...mockApiKey,
|
||||
lastUsedAt: oldDate,
|
||||
});
|
||||
expect(prisma.apiKey.findFirst).toHaveBeenCalledWith({
|
||||
where: { hashedKey: "sha256HashValue" },
|
||||
include: expect.any(Object),
|
||||
});
|
||||
// SHOULD update because lastUsedAt is old enough (> 30s)
|
||||
expect(prisma.apiKey.update).toHaveBeenCalledWith({
|
||||
where: { id: "apikey123" },
|
||||
data: { lastUsedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns null if v2 api key not found", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValue(null);
|
||||
const result = await getApiKeyWithPermissions("invalid-key");
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_invalid_secret");
|
||||
|
||||
expect(result).toBeNull();
|
||||
// Verify timing attack prevention: verifySecret should be called even when key not found
|
||||
expect(verifySecret).toHaveBeenCalledWith(
|
||||
"invalid_secret",
|
||||
"$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q" // control hash
|
||||
);
|
||||
});
|
||||
|
||||
test("returns null if v2 api key bcrypt verification fails", async () => {
|
||||
const { verifySecret } = await import("@/lib/crypto");
|
||||
// Mock verifySecret to return false for this test
|
||||
vi.mocked(verifySecret).mockResolvedValueOnce(false);
|
||||
|
||||
vi.mocked(prisma.apiKey.findUnique).mockResolvedValueOnce({
|
||||
...mockApiKey,
|
||||
} as any);
|
||||
|
||||
const result = await getApiKeyWithPermissions("fbk_wrongSecret");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(verifySecret).toHaveBeenCalledWith("wrongSecret", mockApiKey.hashedKey);
|
||||
});
|
||||
|
||||
test("returns null if v1 api key not found", async () => {
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(null);
|
||||
const result = await getApiKeyWithPermissions("invalid-legacy-key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on prisma error", async () => {
|
||||
test("throws DatabaseError on prisma error for v2 key", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
|
||||
code: "P2002",
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(errToThrow);
|
||||
await expect(getApiKeyWithPermissions("apikey123")).rejects.toThrow(DatabaseError);
|
||||
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws error if prisma throws an error", async () => {
|
||||
test("throws error if prisma throws an error for v2 key", async () => {
|
||||
const errToThrow = new Error("Mock error message");
|
||||
vi.mocked(prisma.apiKey.findUnique).mockRejectedValueOnce(errToThrow);
|
||||
await expect(getApiKeyWithPermissions("apikey123")).rejects.toThrow(errToThrow);
|
||||
await expect(getApiKeyWithPermissions("fbk_testSecret123")).rejects.toThrow(errToThrow);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -221,13 +388,23 @@ describe("API Key Management", () => {
|
||||
],
|
||||
};
|
||||
|
||||
test("creates an API key successfully", async () => {
|
||||
test("creates an API key successfully with v2 format", async () => {
|
||||
vi.mocked(prisma.apiKey.create).mockResolvedValueOnce(mockApiKey);
|
||||
|
||||
const result = await createApiKey("org123", "user123", mockApiKeyData);
|
||||
|
||||
expect(result).toEqual({ ...mockApiKey, actualKey: "generated_key" });
|
||||
expect(prisma.apiKey.create).toHaveBeenCalled();
|
||||
expect(result).toEqual({ ...mockApiKey, actualKey: "fbk_testSecret123" });
|
||||
expect(prisma.apiKey.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
label: "Test API Key",
|
||||
hashedKey: "$2a$12$mockBcryptHashFortestSecret123", // bcrypt hash
|
||||
lookupHash: "sha256LookupHashValue", // SHA-256 lookup hash
|
||||
createdBy: "user123",
|
||||
}),
|
||||
include: {
|
||||
apiKeyEnvironments: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("creates an API key with environment permissions successfully", async () => {
|
||||
@@ -238,7 +415,7 @@ describe("API Key Management", () => {
|
||||
environmentPermissions: [{ environmentId: "env123", permission: ApiKeyPermission.manage }],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ ...mockApiKeyWithEnvironments, actualKey: "generated_key" });
|
||||
expect(result).toEqual({ ...mockApiKeyWithEnvironments, actualKey: "fbk_testSecret123" });
|
||||
expect(prisma.apiKey.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiKey, ApiKeyPermission } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZApiKey } from "@formbricks/database/zod/api-keys";
|
||||
import { ZApiKey, ZApiKeyEnvironment } from "@formbricks/database/zod/api-keys";
|
||||
import { ZOrganizationAccess } from "@formbricks/types/api-key";
|
||||
import { ZEnvironment } from "@formbricks/types/environment";
|
||||
import { ZProject } from "@formbricks/types/project";
|
||||
|
||||
export const ZApiKeyEnvironmentPermission = z.object({
|
||||
environmentId: z.string(),
|
||||
@@ -53,3 +54,15 @@ export interface TApiKeyWithEnvironmentPermission
|
||||
extends Pick<ApiKey, "id" | "label" | "createdAt" | "organizationAccess"> {
|
||||
apiKeyEnvironments: TApiKeyEnvironmentPermission[];
|
||||
}
|
||||
|
||||
export const ZApiKeyWithEnvironmentAndProject = ZApiKey.extend({
|
||||
apiKeyEnvironments: z.array(
|
||||
ZApiKeyEnvironment.extend({
|
||||
environment: ZEnvironment.extend({
|
||||
project: ZProject.pick({ id: true, name: true }),
|
||||
}),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
export type TApiKeyWithEnvironmentAndProject = z.infer<typeof ZApiKeyWithEnvironmentAndProject>;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "public"."ApiKey_hashedKey_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."ApiKey" ADD COLUMN IF NOT EXISTS "lookupHash" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "ApiKey_lookupHash_key" ON "public"."ApiKey"("lookupHash");
|
||||
|
||||
@@ -725,7 +725,8 @@ model ApiKey {
|
||||
createdBy String?
|
||||
lastUsedAt DateTime?
|
||||
label String
|
||||
hashedKey String @unique
|
||||
hashedKey String
|
||||
lookupHash String? @unique
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
apiKeyEnvironments ApiKeyEnvironment[]
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ZApiKey = z.object({
|
||||
lastUsedAt: z.date().nullable(),
|
||||
label: z.string(),
|
||||
hashedKey: z.string(),
|
||||
lookupHash: z.string().nullable(),
|
||||
organizationId: z.string().cuid2(),
|
||||
organizationAccess: ZOrganizationAccess,
|
||||
}) satisfies z.ZodType<ApiKey>;
|
||||
|
||||
@@ -20,7 +20,6 @@ export type TAPIKeyEnvironmentPermission = z.infer<typeof ZAPIKeyEnvironmentPerm
|
||||
export const ZAuthenticationApiKey = z.object({
|
||||
type: z.literal("apiKey"),
|
||||
environmentPermissions: z.array(ZAPIKeyEnvironmentPermission),
|
||||
hashedApiKey: z.string(),
|
||||
apiKeyId: z.string(),
|
||||
organizationId: z.string(),
|
||||
organizationAccess: ZOrganizationAccess,
|
||||
|
||||
Reference in New Issue
Block a user