From 79d618f77c54f1e30b14ac0561a99f0c3d0ffb0c Mon Sep 17 00:00:00 2001 From: Bhagya Amarasinghe Date: Wed, 29 Apr 2026 12:16:08 +0530 Subject: [PATCH] refactor: generalize gateway token minting --- .../v3/feedbackRecords/token/route.test.ts | 36 ++++---- .../app/api/v3/feedbackRecords/token/route.ts | 10 +-- .../app/api/v3/gateway/token/route.test.ts | 84 +++++++++++++++++++ apps/web/app/api/v3/gateway/token/route.ts | 16 ++++ apps/web/lib/jwt.test.ts | 10 +++ apps/web/lib/jwt.ts | 34 ++++++-- .../modules/gateway-auth/lib/service.test.ts | 13 +++ apps/web/modules/gateway-auth/lib/service.ts | 20 +++++ .../modules/gateway-auth/lib/token.test.ts | 45 ++++++++++ apps/web/modules/gateway-auth/lib/token.ts | 20 +++++ 10 files changed, 250 insertions(+), 38 deletions(-) create mode 100644 apps/web/app/api/v3/gateway/token/route.test.ts create mode 100644 apps/web/app/api/v3/gateway/token/route.ts create mode 100644 apps/web/modules/gateway-auth/lib/service.test.ts create mode 100644 apps/web/modules/gateway-auth/lib/service.ts create mode 100644 apps/web/modules/gateway-auth/lib/token.test.ts create mode 100644 apps/web/modules/gateway-auth/lib/token.ts diff --git a/apps/web/app/api/v3/feedbackRecords/token/route.test.ts b/apps/web/app/api/v3/feedbackRecords/token/route.test.ts index d6e0070430..f8308cec7c 100644 --- a/apps/web/app/api/v3/feedbackRecords/token/route.test.ts +++ b/apps/web/app/api/v3/feedbackRecords/token/route.test.ts @@ -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" + ); }); }); diff --git a/apps/web/app/api/v3/feedbackRecords/token/route.ts b/apps/web/app/api/v3/feedbackRecords/token/route.ts index 2d77e4b095..bfb378b28b 100644 --- a/apps/web/app/api/v3/feedbackRecords/token/route.ts +++ b/apps/web/app/api/v3/feedbackRecords/token/route.ts @@ -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"); }, }); diff --git a/apps/web/app/api/v3/gateway/token/route.test.ts b/apps/web/app/api/v3/gateway/token/route.test.ts new file mode 100644 index 0000000000..f823da36d0 --- /dev/null +++ b/apps/web/app/api/v3/gateway/token/route.test.ts @@ -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; + }) => + 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" + ); + }); +}); diff --git a/apps/web/app/api/v3/gateway/token/route.ts b/apps/web/app/api/v3/gateway/token/route.ts new file mode 100644 index 0000000000..e4326e8752 --- /dev/null +++ b/apps/web/app/api/v3/gateway/token/route.ts @@ -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); + }, +}); diff --git a/apps/web/lib/jwt.test.ts b/apps/web/lib/jwt.test.ts index 95d7210917..7b95056e84 100644 --- a/apps/web/lib/jwt.test.ts +++ b/apps/web/lib/jwt.test.ts @@ -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); diff --git a/apps/web/lib/jwt.ts b/apps/web/lib/jwt.ts index 41b7730191..7728c398b4 100644 --- a/apps/web/lib/jwt.ts +++ b/apps/web/lib/jwt.ts @@ -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"); diff --git a/apps/web/modules/gateway-auth/lib/service.test.ts b/apps/web/modules/gateway-auth/lib/service.test.ts new file mode 100644 index 0000000000..38bf595799 --- /dev/null +++ b/apps/web/modules/gateway-auth/lib/service.test.ts @@ -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(); + }); +}); diff --git a/apps/web/modules/gateway-auth/lib/service.ts b/apps/web/modules/gateway-auth/lib/service.ts new file mode 100644 index 0000000000..58dcab7384 --- /dev/null +++ b/apps/web/modules/gateway-auth/lib/service.ts @@ -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; + +export const getGatewayAuthServiceTokenPurpose = (service: TGatewayAuthService): string => { + return gatewayAuthServices[service].tokenPurpose; +}; diff --git a/apps/web/modules/gateway-auth/lib/token.test.ts b/apps/web/modules/gateway-auth/lib/token.test.ts new file mode 100644 index 0000000000..b704df56f8 --- /dev/null +++ b/apps/web/modules/gateway-auth/lib/token.test.ts @@ -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"); + }); +}); diff --git a/apps/web/modules/gateway-auth/lib/token.ts b/apps/web/modules/gateway-auth/lib/token.ts new file mode 100644 index 0000000000..5377dc3c1c --- /dev/null +++ b/apps/web/modules/gateway-auth/lib/token.ts @@ -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 }); +};