mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
refactor: generalize gateway token minting
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const { mockCreateFeedbackRecordsGatewayToken, mockWithV3ApiWrapper, mockWrapperAuthentication } =
|
||||
const { mockCreateGatewayServiceTokenResponse, mockWithV3ApiWrapper, mockWrapperAuthentication } =
|
||||
vi.hoisted(() => ({
|
||||
mockCreateFeedbackRecordsGatewayToken: vi.fn(),
|
||||
mockCreateGatewayServiceTokenResponse: vi.fn(),
|
||||
mockWithV3ApiWrapper: vi.fn(),
|
||||
mockWrapperAuthentication: {
|
||||
current: {
|
||||
@@ -25,8 +25,8 @@ const installWrapperMock = () => {
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
createFeedbackRecordsGatewayToken: mockCreateFeedbackRecordsGatewayToken,
|
||||
vi.mock("@/modules/gateway-auth/lib/token", () => ({
|
||||
createGatewayServiceTokenResponse: mockCreateGatewayServiceTokenResponse,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v3/lib/api-wrapper", () => ({
|
||||
@@ -41,14 +41,15 @@ describe("POST /api/v3/feedbackRecords/token", () => {
|
||||
mockWrapperAuthentication.current = {
|
||||
user: { id: "user_1" },
|
||||
};
|
||||
mockCreateGatewayServiceTokenResponse.mockReturnValue(
|
||||
Response.json({
|
||||
token: "gateway-token",
|
||||
expiresAt: "2026-04-24T00:10:00.000Z",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns the minted gateway token payload", async () => {
|
||||
mockCreateFeedbackRecordsGatewayToken.mockReturnValue({
|
||||
token: "gateway-token",
|
||||
expiresAt: "2026-04-24T00:10:00.000Z",
|
||||
});
|
||||
|
||||
test("delegates to the generic gateway token helper for feedbackRecords", async () => {
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST({} as never, {} as never);
|
||||
|
||||
@@ -57,16 +58,9 @@ describe("POST /api/v3/feedbackRecords/token", () => {
|
||||
token: "gateway-token",
|
||||
expiresAt: "2026-04-24T00:10:00.000Z",
|
||||
});
|
||||
expect(mockCreateFeedbackRecordsGatewayToken).toHaveBeenCalledWith("user_1");
|
||||
});
|
||||
|
||||
test("returns 401 when the session authentication payload is unexpectedly missing", async () => {
|
||||
mockWrapperAuthentication.current = null;
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST({} as never, {} as never);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockCreateFeedbackRecordsGatewayToken).not.toHaveBeenCalled();
|
||||
expect(mockCreateGatewayServiceTokenResponse).toHaveBeenCalledWith(
|
||||
{ user: { id: "user_1" } },
|
||||
"feedbackRecords"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { createFeedbackRecordsGatewayToken } from "@/lib/jwt";
|
||||
import { createGatewayServiceTokenResponse } from "@/modules/gateway-auth/lib/token";
|
||||
|
||||
export const POST = withV3ApiWrapper({
|
||||
auth: "session",
|
||||
handler: async ({ authentication }) => {
|
||||
const userId = authentication && "user" in authentication ? authentication.user?.id : null;
|
||||
if (!userId) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createFeedbackRecordsGatewayToken(userId);
|
||||
return Response.json({ token, expiresAt });
|
||||
return createGatewayServiceTokenResponse(authentication, "feedbackRecords");
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockCreateGatewayServiceTokenResponse,
|
||||
mockWithV3ApiWrapper,
|
||||
mockWrapperAuthentication,
|
||||
mockParsedInputBody,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateGatewayServiceTokenResponse: vi.fn(),
|
||||
mockWithV3ApiWrapper: vi.fn(),
|
||||
mockWrapperAuthentication: {
|
||||
current: {
|
||||
user: { id: "user_1" },
|
||||
} as { user: { id: string } } | null,
|
||||
},
|
||||
mockParsedInputBody: {
|
||||
current: {
|
||||
service: "feedbackRecords",
|
||||
} as { service: "feedbackRecords" },
|
||||
},
|
||||
}));
|
||||
|
||||
const installWrapperMock = () => {
|
||||
mockWithV3ApiWrapper.mockImplementation(
|
||||
({
|
||||
handler,
|
||||
}: {
|
||||
handler: (params: {
|
||||
authentication: { user: { id: string } } | null;
|
||||
parsedInput: { body: { service: "feedbackRecords" } };
|
||||
}) => Promise<Response>;
|
||||
}) =>
|
||||
async () =>
|
||||
await handler({
|
||||
authentication: mockWrapperAuthentication.current,
|
||||
parsedInput: {
|
||||
body: mockParsedInputBody.current,
|
||||
},
|
||||
} as never)
|
||||
);
|
||||
};
|
||||
|
||||
vi.mock("@/modules/gateway-auth/lib/token", () => ({
|
||||
createGatewayServiceTokenResponse: mockCreateGatewayServiceTokenResponse,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v3/lib/api-wrapper", () => ({
|
||||
withV3ApiWrapper: mockWithV3ApiWrapper,
|
||||
}));
|
||||
|
||||
describe("POST /api/v3/gateway/token", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
installWrapperMock();
|
||||
mockWrapperAuthentication.current = {
|
||||
user: { id: "user_1" },
|
||||
};
|
||||
mockParsedInputBody.current = {
|
||||
service: "feedbackRecords",
|
||||
};
|
||||
mockCreateGatewayServiceTokenResponse.mockReturnValue(
|
||||
Response.json({
|
||||
token: "gateway-token",
|
||||
expiresAt: "2026-04-24T00:10:00.000Z",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("mints a gateway token for the requested service", async () => {
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST({} as never, {} as never);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
token: "gateway-token",
|
||||
expiresAt: "2026-04-24T00:10:00.000Z",
|
||||
});
|
||||
expect(mockCreateGatewayServiceTokenResponse).toHaveBeenCalledWith(
|
||||
{ user: { id: "user_1" } },
|
||||
"feedbackRecords"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { ZGatewayAuthService } from "@/modules/gateway-auth/lib/service";
|
||||
import { createGatewayServiceTokenResponse } from "@/modules/gateway-auth/lib/token";
|
||||
|
||||
export const POST = withV3ApiWrapper({
|
||||
auth: "session",
|
||||
schemas: {
|
||||
body: z.object({
|
||||
service: ZGatewayAuthService,
|
||||
}),
|
||||
},
|
||||
handler: async ({ authentication, parsedInput }) => {
|
||||
return createGatewayServiceTokenResponse(authentication, parsedInput.body.service);
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import * as crypto from "@/lib/crypto";
|
||||
import {
|
||||
createGatewayServiceToken,
|
||||
createFeedbackRecordsGatewayToken,
|
||||
createEmailChangeToken,
|
||||
createEmailToken,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
createToken,
|
||||
createTokenForLinkSurvey,
|
||||
getEmailFromEmailToken,
|
||||
verifyGatewayServiceToken,
|
||||
verifyFeedbackRecordsGatewayToken,
|
||||
verifyEmailChangeToken,
|
||||
verifyInviteToken,
|
||||
@@ -154,6 +156,14 @@ describe("JWT Functions - Comprehensive Security Tests", () => {
|
||||
});
|
||||
|
||||
describe("feedback records gateway tokens", () => {
|
||||
test("creates and verifies a generic gateway token for feedbackRecords", () => {
|
||||
const { token, expiresAt } = createGatewayServiceToken(mockUser.id, "feedbackRecords");
|
||||
|
||||
expect(token).toBeDefined();
|
||||
expect(new Date(expiresAt).toString()).not.toBe("Invalid Date");
|
||||
expect(verifyGatewayServiceToken(token, "feedbackRecords")).toEqual({ userId: mockUser.id });
|
||||
});
|
||||
|
||||
test("creates and verifies a feedback records gateway token", () => {
|
||||
const { token, expiresAt } = createFeedbackRecordsGatewayToken(mockUser.id);
|
||||
|
||||
|
||||
+25
-9
@@ -3,8 +3,10 @@ import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ENCRYPTION_KEY, NEXTAUTH_SECRET } from "@/lib/constants";
|
||||
import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto";
|
||||
import { getGatewayAuthServiceTokenPurpose, TGatewayAuthService } from "@/modules/gateway-auth/lib/service";
|
||||
|
||||
export const FEEDBACK_RECORDS_GATEWAY_TOKEN_PURPOSE = "feedback_records_gateway";
|
||||
export const FEEDBACK_RECORDS_GATEWAY_TOKEN_PURPOSE =
|
||||
getGatewayAuthServiceTokenPurpose("feedbackRecords");
|
||||
const FEEDBACK_RECORDS_GATEWAY_TOKEN_TTL_SECONDS = 60 * 10;
|
||||
|
||||
// Helper function to decrypt with fallback to plain text
|
||||
@@ -29,9 +31,7 @@ export const createToken = (userId: string, options = {}): string => {
|
||||
return jwt.sign({ id: encryptedUserId }, NEXTAUTH_SECRET, options);
|
||||
};
|
||||
|
||||
export const createFeedbackRecordsGatewayToken = (
|
||||
userId: string
|
||||
): {
|
||||
export const createGatewayServiceToken = (userId: string, service: TGatewayAuthService): {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
} => {
|
||||
@@ -39,7 +39,7 @@ export const createFeedbackRecordsGatewayToken = (
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
}
|
||||
|
||||
const token = jwt.sign({ purpose: FEEDBACK_RECORDS_GATEWAY_TOKEN_PURPOSE }, NEXTAUTH_SECRET, {
|
||||
const token = jwt.sign({ purpose: getGatewayAuthServiceTokenPurpose(service) }, NEXTAUTH_SECRET, {
|
||||
algorithm: "HS256",
|
||||
expiresIn: FEEDBACK_RECORDS_GATEWAY_TOKEN_TTL_SECONDS,
|
||||
subject: userId,
|
||||
@@ -55,6 +55,16 @@ export const createFeedbackRecordsGatewayToken = (
|
||||
expiresAt: new Date(decodedToken.exp * 1000).toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
export const createFeedbackRecordsGatewayToken = (
|
||||
userId: string
|
||||
): {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
} => {
|
||||
return createGatewayServiceToken(userId, "feedbackRecords");
|
||||
};
|
||||
|
||||
export const createTokenForLinkSurvey = (surveyId: string, userEmail: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
@@ -96,9 +106,7 @@ export const verifyEmailChangeToken = async (token: string): Promise<{ id: strin
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyFeedbackRecordsGatewayToken = (
|
||||
token: string
|
||||
): {
|
||||
export const verifyGatewayServiceToken = (token: string, service: TGatewayAuthService): {
|
||||
userId: string;
|
||||
} => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
@@ -110,7 +118,7 @@ export const verifyFeedbackRecordsGatewayToken = (
|
||||
sub?: string;
|
||||
};
|
||||
|
||||
if (payload.purpose !== FEEDBACK_RECORDS_GATEWAY_TOKEN_PURPOSE || !payload.sub) {
|
||||
if (payload.purpose !== getGatewayAuthServiceTokenPurpose(service) || !payload.sub) {
|
||||
throw new Error("Invalid feedback records gateway token");
|
||||
}
|
||||
|
||||
@@ -119,6 +127,14 @@ export const verifyFeedbackRecordsGatewayToken = (
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyFeedbackRecordsGatewayToken = (
|
||||
token: string
|
||||
): {
|
||||
userId: string;
|
||||
} => {
|
||||
return verifyGatewayServiceToken(token, "feedbackRecords");
|
||||
};
|
||||
|
||||
export const createEmailChangeToken = (userId: string, email: string): string => {
|
||||
if (!NEXTAUTH_SECRET) {
|
||||
throw new Error("NEXTAUTH_SECRET is not set");
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { getGatewayAuthServiceTokenPurpose, ZGatewayAuthService } from "./service";
|
||||
|
||||
describe("gateway auth service registry", () => {
|
||||
test("returns the configured token purpose for feedbackRecords", () => {
|
||||
expect(getGatewayAuthServiceTokenPurpose("feedbackRecords")).toBe("feedback_records_gateway");
|
||||
});
|
||||
|
||||
test("validates supported gateway auth services", () => {
|
||||
expect(ZGatewayAuthService.parse("feedbackRecords")).toBe("feedbackRecords");
|
||||
expect(() => ZGatewayAuthService.parse("unknownService")).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const gatewayAuthServices = {
|
||||
feedbackRecords: {
|
||||
tokenPurpose: "feedback_records_gateway",
|
||||
},
|
||||
} as const;
|
||||
|
||||
const gatewayAuthServiceKeys = Object.keys(gatewayAuthServices) as [
|
||||
keyof typeof gatewayAuthServices,
|
||||
...(keyof typeof gatewayAuthServices)[],
|
||||
];
|
||||
|
||||
export const ZGatewayAuthService = z.enum(gatewayAuthServiceKeys);
|
||||
|
||||
export type TGatewayAuthService = z.infer<typeof ZGatewayAuthService>;
|
||||
|
||||
export const getGatewayAuthServiceTokenPurpose = (service: TGatewayAuthService): string => {
|
||||
return gatewayAuthServices[service].tokenPurpose;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createGatewayServiceTokenResponse } from "./token";
|
||||
|
||||
const { mockCreateGatewayServiceToken } = vi.hoisted(() => ({
|
||||
mockCreateGatewayServiceToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jwt", () => ({
|
||||
createGatewayServiceToken: mockCreateGatewayServiceToken,
|
||||
}));
|
||||
|
||||
describe("createGatewayServiceTokenResponse", () => {
|
||||
test("returns 401 when no authenticated user is present", () => {
|
||||
const response = createGatewayServiceTokenResponse(null, "feedbackRecords");
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mockCreateGatewayServiceToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns the minted token payload for the requested service", async () => {
|
||||
mockCreateGatewayServiceToken.mockReturnValue({
|
||||
token: "gateway-token",
|
||||
expiresAt: "2026-04-24T00:10:00.000Z",
|
||||
});
|
||||
|
||||
const response = createGatewayServiceTokenResponse(
|
||||
{
|
||||
user: {
|
||||
id: "user_1",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
},
|
||||
expires: "2026-04-25T00:00:00.000Z",
|
||||
},
|
||||
"feedbackRecords"
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
token: "gateway-token",
|
||||
expiresAt: "2026-04-24T00:10:00.000Z",
|
||||
});
|
||||
expect(mockCreateGatewayServiceToken).toHaveBeenCalledWith("user_1", "feedbackRecords");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { TV3Authentication } from "@/app/api/v3/lib/types";
|
||||
import { createGatewayServiceToken } from "@/lib/jwt";
|
||||
import { TGatewayAuthService } from "./service";
|
||||
|
||||
const getAuthenticatedUserId = (authentication: TV3Authentication): string | null => {
|
||||
return authentication && "user" in authentication ? authentication.user?.id : null;
|
||||
};
|
||||
|
||||
export const createGatewayServiceTokenResponse = (
|
||||
authentication: TV3Authentication,
|
||||
service: TGatewayAuthService
|
||||
): Response => {
|
||||
const userId = getAuthenticatedUserId(authentication);
|
||||
if (!userId) {
|
||||
return new Response("Unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
const { token, expiresAt } = createGatewayServiceToken(userId, service);
|
||||
return Response.json({ token, expiresAt });
|
||||
};
|
||||
Reference in New Issue
Block a user