refactor: generalize gateway token minting

This commit is contained in:
Bhagya Amarasinghe
2026-04-29 12:16:08 +05:30
parent be80db8418
commit 79d618f77c
10 changed files with 250 additions and 38 deletions
@@ -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);
},
});
+10
View File
@@ -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
View File
@@ -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 });
};