Compare commits

...

1 Commits

Author SHA1 Message Date
Bhagya Amarasinghe 7b61e3b9bd feat: add Traefik gateway auth adapter 2026-05-14 00:30:47 +05:30
16 changed files with 642 additions and 159 deletions
@@ -0,0 +1,14 @@
import { NextRequest } from "next/server";
import { authorizeTraefikRequest } from "@/modules/traefik-auth/service";
const handler = async (request: NextRequest): Promise<Response> => {
return await authorizeTraefikRequest(request);
};
export const GET = handler;
export const POST = handler;
export const PUT = handler;
export const PATCH = handler;
export const DELETE = handler;
export const HEAD = handler;
export const OPTIONS = handler;
+7 -21
View File
@@ -1,14 +1,8 @@
import "server-only";
import { NextRequest } from "next/server";
import { feedbackRecordsEnvoyAuthorizer } from "@/modules/hub/feedback-records-gateway";
import {
TEnvoyRequestAuthorizer,
authenticateEnvoyRequest,
buildStatusResponse,
parseEnvoyRequestMetadata,
} from "./shared";
const envoyAuthorizers: TEnvoyRequestAuthorizer[] = [feedbackRecordsEnvoyAuthorizer];
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
import { buildEnvoyAllowResponse, parseEnvoyRequestMetadata } from "./shared";
export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Response> => {
const requestMetadata = parseEnvoyRequestMetadata(request);
@@ -16,20 +10,12 @@ export const authorizeEnvoyRequest = async (request: NextRequest): Promise<Respo
return requestMetadata.errorResponse;
}
const authorizer = envoyAuthorizers.find((candidate) => candidate.matches(requestMetadata.originalRequest));
if (!authorizer) {
return buildStatusResponse(400, "Unsupported Envoy auth route");
}
const authenticationResult = await authenticateEnvoyRequest(request, authorizer.gatewayToken);
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
return buildStatusResponse(401, "Unauthorized");
}
return await authorizer.authorize({
return await authorizeGatewayRequest({
request,
originalRequest: requestMetadata.originalRequest,
principal: authenticationResult.principal,
authorizers: gatewayRequestAuthorizers,
requestId: request.headers.get("x-request-id") ?? "unknown",
buildAllowResponse: buildEnvoyAllowResponse,
unsupportedRouteMessage: "Unsupported Envoy auth route",
});
};
+6 -116
View File
@@ -1,51 +1,11 @@
import "server-only";
import { NextRequest } from "next/server";
import { prisma } from "@formbricks/database";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
const ENVOY_AUTH_PREFIX = "/api/envoy-auth";
const HEADERS_TO_REMOVE_ON_ALLOW = "x-api-key,authorization,cookie";
export type TEnvoyOriginalRequest = {
method: string;
url: URL;
};
export type TEnvoyAuthenticatedPrincipal =
| {
type: "apiKey";
authentication: TAuthenticationApiKey;
}
| {
type: "user";
userId: string;
source: "session" | "jwt";
};
export type TEnvoyGatewayTokenHandler = {
getTokenFromHeaders: (headers: Headers) => string | null;
verifyToken: (token: string) => { userId: string };
};
export type TEnvoyAuthenticationResult =
| { status: "authenticated"; principal: TEnvoyAuthenticatedPrincipal }
| { status: "invalid" }
| { status: "missing" };
export type TEnvoyRequestAuthorizer = {
matches: (originalRequest: TEnvoyOriginalRequest) => boolean;
gatewayToken?: TEnvoyGatewayTokenHandler;
authorize: (params: {
request: NextRequest;
originalRequest: TEnvoyOriginalRequest;
principal: TEnvoyAuthenticatedPrincipal;
requestId: string;
}) => Promise<Response>;
};
export const buildAllowResponse = (): Response =>
export const buildEnvoyAllowResponse = (): Response =>
new Response(null, {
status: 200,
headers: {
@@ -53,20 +13,12 @@ export const buildAllowResponse = (): Response =>
},
});
export const buildStatusResponse = (status: number, message: string): Response =>
new Response(message, {
status,
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
export const parseEnvoyRequestMetadata = (
request: NextRequest
): { originalRequest: TEnvoyOriginalRequest } | { errorResponse: Response } => {
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
if (!request.nextUrl.pathname.startsWith(`${ENVOY_AUTH_PREFIX}/`)) {
return {
errorResponse: buildStatusResponse(400, "Invalid Envoy auth request path"),
errorResponse: buildGatewayStatusResponse(400, "Invalid Envoy auth request path"),
};
}
@@ -77,7 +29,7 @@ export const parseEnvoyRequestMetadata = (
if (originalPathSegments.length === 0) {
return {
errorResponse: buildStatusResponse(400, "Missing original request path"),
errorResponse: buildGatewayStatusResponse(400, "Missing original request path"),
};
}
@@ -93,69 +45,7 @@ export const parseEnvoyRequestMetadata = (
};
} catch {
return {
errorResponse: buildStatusResponse(400, "Invalid original request path"),
errorResponse: buildGatewayStatusResponse(400, "Invalid original request path"),
};
}
};
export const authenticateEnvoyRequest = async (
request: NextRequest,
gatewayToken?: TEnvoyGatewayTokenHandler
): Promise<TEnvoyAuthenticationResult> => {
if (getApiKeyFromHeaders(request.headers)) {
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
if (!apiKeyAuthentication) {
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "apiKey",
authentication: apiKeyAuthentication,
},
};
}
if (gatewayToken) {
const token = gatewayToken.getTokenFromHeaders(request.headers);
if (token) {
try {
const { userId } = gatewayToken.verifyToken(token);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: user.id,
source: "jwt",
},
};
} catch {
return { status: "invalid" };
}
}
}
const proxySession = await getProxySession(request);
if (!proxySession) {
return { status: "missing" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: proxySession.userId,
source: "session",
},
};
};
@@ -0,0 +1,5 @@
import "server-only";
import { feedbackRecordsGatewayAuthorizer } from "@/modules/hub/feedback-records-gateway";
import { TGatewayRequestAuthorizer } from "./request";
export const gatewayRequestAuthorizers: TGatewayRequestAuthorizer[] = [feedbackRecordsGatewayAuthorizer];
@@ -0,0 +1,152 @@
import "server-only";
import { NextRequest } from "next/server";
import { prisma } from "@formbricks/database";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateApiKeyFromHeaders, getApiKeyFromHeaders } from "@/modules/api/lib/api-key-auth";
import { getProxySession } from "@/modules/auth/lib/proxy-session";
export type TGatewayOriginalRequest = {
method: string;
url: URL;
};
export type TGatewayAuthenticatedPrincipal =
| {
type: "apiKey";
authentication: TAuthenticationApiKey;
}
| {
type: "user";
userId: string;
source: "session" | "jwt";
};
export type TGatewayTokenHandler = {
getTokenFromHeaders: (headers: Headers) => string | null;
verifyToken: (token: string) => { userId: string };
};
export type TGatewayAuthenticationResult =
| { status: "authenticated"; principal: TGatewayAuthenticatedPrincipal }
| { status: "invalid" }
| { status: "missing" };
export type TGatewayAuthorizationDecision = { status: "allow" } | { status: "deny"; response: Response };
export type TGatewayRequestAuthorizer = {
matches: (originalRequest: TGatewayOriginalRequest) => boolean;
gatewayToken?: TGatewayTokenHandler;
authorize: (params: {
request: NextRequest;
originalRequest: TGatewayOriginalRequest;
principal: TGatewayAuthenticatedPrincipal;
requestId: string;
}) => Promise<TGatewayAuthorizationDecision>;
};
export const buildGatewayStatusResponse = (status: number, message: string): Response =>
new Response(message, {
status,
headers: {
"content-type": "text/plain; charset=utf-8",
},
});
export const allowGatewayRequest = (): TGatewayAuthorizationDecision => ({ status: "allow" });
export const authenticateGatewayRequest = async (
request: NextRequest,
gatewayToken?: TGatewayTokenHandler
): Promise<TGatewayAuthenticationResult> => {
if (getApiKeyFromHeaders(request.headers)) {
const apiKeyAuthentication = await authenticateApiKeyFromHeaders(request.headers);
if (!apiKeyAuthentication) {
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "apiKey",
authentication: apiKeyAuthentication,
},
};
}
if (gatewayToken) {
const token = gatewayToken.getTokenFromHeaders(request.headers);
if (token) {
try {
const { userId } = gatewayToken.verifyToken(token);
const user = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, isActive: true },
});
if (!user || user.isActive === false) {
return { status: "invalid" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: user.id,
source: "jwt",
},
};
} catch {
return { status: "invalid" };
}
}
}
const proxySession = await getProxySession(request);
if (!proxySession) {
return { status: "missing" };
}
return {
status: "authenticated",
principal: {
type: "user",
userId: proxySession.userId,
source: "session",
},
};
};
export const authorizeGatewayRequest = async ({
request,
originalRequest,
authorizers,
requestId,
buildAllowResponse,
unsupportedRouteMessage,
}: {
request: NextRequest;
originalRequest: TGatewayOriginalRequest;
authorizers: TGatewayRequestAuthorizer[];
requestId: string;
buildAllowResponse: () => Response;
unsupportedRouteMessage: string;
}): Promise<Response> => {
const authorizer = authorizers.find((candidate) => candidate.matches(originalRequest));
if (!authorizer) {
return buildGatewayStatusResponse(400, unsupportedRouteMessage);
}
const authenticationResult = await authenticateGatewayRequest(request, authorizer.gatewayToken);
if (authenticationResult.status === "missing" || authenticationResult.status === "invalid") {
return buildGatewayStatusResponse(401, "Unauthorized");
}
const authorizationDecision = await authorizer.authorize({
request,
originalRequest,
principal: authenticationResult.principal,
requestId,
});
return authorizationDecision.status === "allow" ? buildAllowResponse() : authorizationDecision.response;
};
@@ -12,11 +12,11 @@ import { getBearerTokenFromHeaders } from "@/modules/api/lib/api-key-auth";
import { getFeedbackDirectoryAuthContext } from "@/modules/ee/feedback-directory/lib/feedback-directory";
import { getIsUnifyFeedbackEnabled } from "@/modules/ee/license-check/lib/utils";
import {
TEnvoyAuthenticatedPrincipal,
TEnvoyRequestAuthorizer,
buildAllowResponse,
buildStatusResponse,
} from "@/modules/envoy-auth/shared";
TGatewayAuthenticatedPrincipal,
TGatewayRequestAuthorizer,
allowGatewayRequest,
buildGatewayStatusResponse,
} from "@/modules/gateway-auth/lib/request";
import { getFeedbackRecordTenant } from "@/modules/hub/service";
const FEEDBACK_RECORDS_V3_PREFIX = "/api/v3/feedbackRecords";
@@ -137,7 +137,7 @@ const parseFeedbackRecordsGatewayRoute = (method: string, pathname: string): TPa
return null;
};
type TAuthenticatedGatewayPrincipal = TEnvoyAuthenticatedPrincipal;
type TAuthenticatedGatewayPrincipal = TGatewayAuthenticatedPrincipal;
const parseTenantId = (tenantId: string | null): string | null => {
if (!tenantId) {
@@ -200,7 +200,7 @@ const resolveTenantId = async (
const tenantId = parseTenantId(originalUrl.searchParams.get("tenant_id"));
if (!tenantId) {
return {
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
};
}
@@ -212,7 +212,7 @@ const resolveTenantId = async (
const tenantId = parseTenantId(typeof body?.tenant_id === "string" ? body.tenant_id : null);
if (!tenantId) {
return {
errorResponse: buildStatusResponse(400, "Invalid or missing tenant_id"),
errorResponse: buildGatewayStatusResponse(400, "Invalid or missing tenant_id"),
};
}
@@ -227,7 +227,7 @@ const resolveTenantId = async (
"Feedback record tenant lookup returned not found"
);
return {
errorResponse: buildStatusResponse(403, "Forbidden"),
errorResponse: buildGatewayStatusResponse(403, "Forbidden"),
};
}
@@ -236,7 +236,7 @@ const resolveTenantId = async (
"Feedback record tenant lookup failed"
);
return {
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
};
}
@@ -247,14 +247,14 @@ const resolveTenantId = async (
"Feedback record tenant lookup returned invalid tenant"
);
return {
errorResponse: buildStatusResponse(503, "Feedback record lookup failed"),
errorResponse: buildGatewayStatusResponse(503, "Feedback record lookup failed"),
};
}
return { tenantId };
};
const authorizeGatewayRequest = async (
const authorizeFeedbackRecordsGatewayRequest = async (
principal: TAuthenticatedGatewayPrincipal,
feedbackDirectoryId: string,
requiredPermission: TFeedbackRecordsGatewayPermission
@@ -308,7 +308,7 @@ const authorizeGatewayRequest = async (
}
};
export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
export const feedbackRecordsGatewayAuthorizer: TGatewayRequestAuthorizer = {
matches: (originalRequest) => normalizeFeedbackRecordsPath(originalRequest.url.pathname) !== null,
gatewayToken: {
getTokenFromHeaders: getFeedbackRecordsGatewayJwtFromHeaders,
@@ -317,15 +317,21 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
authorize: async ({ request, originalRequest, principal, requestId }) => {
const route = parseFeedbackRecordsGatewayRoute(originalRequest.method, originalRequest.url.pathname);
if (!route) {
return buildStatusResponse(400, "Unsupported FeedbackRecords route");
return {
status: "deny",
response: buildGatewayStatusResponse(400, "Unsupported FeedbackRecords route"),
};
}
const tenantResolution = await resolveTenantId(request, route, originalRequest.url, requestId);
if ("errorResponse" in tenantResolution) {
return tenantResolution.errorResponse;
return {
status: "deny",
response: tenantResolution.errorResponse,
};
}
const authorizationResult = await authorizeGatewayRequest(
const authorizationResult = await authorizeFeedbackRecordsGatewayRequest(
principal,
tenantResolution.tenantId,
route.requiredPermission
@@ -341,7 +347,10 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
},
"Feedback records gateway authorization denied"
);
return buildStatusResponse(403, "Forbidden");
return {
status: "deny",
response: buildGatewayStatusResponse(403, "Forbidden"),
};
}
logger.info(
@@ -355,6 +364,6 @@ export const feedbackRecordsEnvoyAuthorizer: TEnvoyRequestAuthorizer = {
"Feedback records gateway authorization allowed"
);
return buildAllowResponse();
return allowGatewayRequest();
},
};
@@ -0,0 +1,242 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { authorizeTraefikRequest } from "./service";
const {
mockAuthenticateApiKeyFromHeaders,
mockGetApiKeyFromHeaders,
mockGetBearerTokenFromHeaders,
mockGetProxySession,
mockVerifyFeedbackRecordsGatewayToken,
mockGetFeedbackDirectoryAuthContext,
mockGetFeedbackRecordTenant,
mockCheckAuthorizationUpdated,
mockUserFindUnique,
mockGetIsUnifyFeedbackEnabled,
} = vi.hoisted(() => ({
mockAuthenticateApiKeyFromHeaders: vi.fn(),
mockGetApiKeyFromHeaders: vi.fn(),
mockGetBearerTokenFromHeaders: vi.fn(),
mockGetProxySession: vi.fn(),
mockVerifyFeedbackRecordsGatewayToken: vi.fn(),
mockGetFeedbackDirectoryAuthContext: vi.fn(),
mockGetFeedbackRecordTenant: vi.fn(),
mockCheckAuthorizationUpdated: vi.fn(),
mockUserFindUnique: vi.fn(),
mockGetIsUnifyFeedbackEnabled: vi.fn(),
}));
vi.mock("@/modules/api/lib/api-key-auth", () => ({
authenticateApiKeyFromHeaders: mockAuthenticateApiKeyFromHeaders,
getApiKeyFromHeaders: mockGetApiKeyFromHeaders,
getBearerTokenFromHeaders: mockGetBearerTokenFromHeaders,
}));
vi.mock("@/modules/auth/lib/proxy-session", () => ({
getProxySession: mockGetProxySession,
}));
vi.mock("@/lib/jwt", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/jwt")>();
return {
...actual,
verifyFeedbackRecordsGatewayToken: mockVerifyFeedbackRecordsGatewayToken,
};
});
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
findUnique: mockUserFindUnique,
},
},
}));
vi.mock("@/modules/ee/feedback-directory/lib/feedback-directory", () => ({
getFeedbackDirectoryAuthContext: mockGetFeedbackDirectoryAuthContext,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsUnifyFeedbackEnabled: mockGetIsUnifyFeedbackEnabled,
}));
vi.mock("@/modules/hub/service", () => ({
getFeedbackRecordTenant: mockGetFeedbackRecordTenant,
}));
vi.mock("@/lib/utils/action-client/action-client-middleware", () => ({
checkAuthorizationUpdated: mockCheckAuthorizationUpdated,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const feedbackDirectoryId = "clxx1234567890123456789012";
const feedbackRecordId = "0194d8a0-3d55-7ff4-9f62-8d02c3fbcfe8";
const createRequest = ({
method = "GET",
forwardedMethod = "GET",
forwardedUri,
headers = {},
body,
adapterUrl = "http://localhost/api/traefik-auth/feedback-records",
}: {
method?: string;
forwardedMethod?: string;
forwardedUri?: string;
headers?: Record<string, string>;
body?: BodyInit;
adapterUrl?: string;
} = {}) =>
new NextRequest(adapterUrl, {
method,
headers: {
"x-forwarded-method": forwardedMethod,
...(forwardedUri ? { "x-forwarded-uri": forwardedUri } : {}),
"x-forwarded-host": "app.example.com",
"x-forwarded-proto": "https",
...headers,
},
body,
});
describe("authorizeTraefikRequest", () => {
beforeEach(() => {
vi.resetAllMocks();
mockGetApiKeyFromHeaders.mockReturnValue(null);
mockGetBearerTokenFromHeaders.mockReturnValue(null);
mockAuthenticateApiKeyFromHeaders.mockResolvedValue(null);
mockGetProxySession.mockResolvedValue(null);
mockVerifyFeedbackRecordsGatewayToken.mockImplementation(() => {
throw new Error("invalid token");
});
mockGetFeedbackDirectoryAuthContext.mockResolvedValue({
organizationId: "org_1",
workspaceIds: ["workspace_1"],
isArchived: false,
});
mockGetFeedbackRecordTenant.mockResolvedValue({
data: { tenantId: feedbackDirectoryId },
error: null,
});
mockCheckAuthorizationUpdated.mockResolvedValue(true);
mockUserFindUnique.mockResolvedValue({ id: "user_1", isActive: true });
mockGetIsUnifyFeedbackEnabled.mockResolvedValue(true);
});
test("allows requests using Traefik forwarded method and URI metadata", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
});
const response = await authorizeTraefikRequest(
createRequest({
method: "POST",
forwardedMethod: "POST",
forwardedUri: "/api/v3/feedbackRecords",
headers: {
authorization: "Bearer fbk_test",
"content-type": "application/json",
},
body: JSON.stringify({ tenant_id: feedbackDirectoryId }),
})
);
expect(response.status).toBe(200);
expect(response.headers.get("x-envoy-auth-headers-to-remove")).toBeNull();
});
test("uses the forwarded URI instead of the Traefik auth endpoint URL", async () => {
mockGetApiKeyFromHeaders.mockReturnValue("fbk_test");
mockAuthenticateApiKeyFromHeaders.mockResolvedValue({
type: "apiKey",
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: { accessControl: { read: true, write: true } },
workspacePermissions: [],
});
const response = await authorizeTraefikRequest(
createRequest({
adapterUrl: "http://localhost/api/traefik-auth/not-the-original-route",
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
})
);
expect(response.status).toBe(200);
});
test("returns 400 when Traefik forwarded metadata is missing", async () => {
const response = await authorizeTraefikRequest(
new NextRequest("http://localhost/api/traefik-auth/v1/feedback-records", {
method: "GET",
})
);
expect(response.status).toBe(400);
});
test("authorizes record lookups through the shared FeedbackRecords authorizer", async () => {
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
mockVerifyFeedbackRecordsGatewayToken.mockReturnValue({ userId: "user_1" });
const response = await authorizeTraefikRequest(
createRequest({
forwardedMethod: "PATCH",
forwardedUri: `/v1/feedback-records/${feedbackRecordId}`,
headers: {
authorization: "Bearer header.payload.signature",
},
})
);
expect(response.status).toBe(200);
expect(mockGetFeedbackRecordTenant).toHaveBeenCalledWith(feedbackRecordId);
expect(mockCheckAuthorizationUpdated).toHaveBeenCalledWith({
userId: "user_1",
organizationId: "org_1",
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "workspaceTeam",
workspaceId: "workspace_1",
minPermission: "readWrite",
},
],
});
});
test("returns 401 for invalid explicit JWT instead of falling back to session cookies", async () => {
mockGetBearerTokenFromHeaders.mockReturnValue("header.payload.signature");
mockGetProxySession.mockResolvedValue({
userId: "user_1",
});
const response = await authorizeTraefikRequest(
createRequest({
forwardedUri: `/v1/feedback-records?tenant_id=${feedbackDirectoryId}`,
headers: {
authorization: "Bearer header.payload.signature",
cookie: "next-auth.session-token=still-present",
},
})
);
expect(response.status).toBe(401);
expect(mockGetProxySession).not.toHaveBeenCalled();
});
});
+21
View File
@@ -0,0 +1,21 @@
import "server-only";
import { NextRequest } from "next/server";
import { gatewayRequestAuthorizers } from "@/modules/gateway-auth/lib/authorizers";
import { authorizeGatewayRequest } from "@/modules/gateway-auth/lib/request";
import { buildTraefikAllowResponse, parseTraefikRequestMetadata } from "./shared";
export const authorizeTraefikRequest = async (request: NextRequest): Promise<Response> => {
const requestMetadata = parseTraefikRequestMetadata(request);
if ("errorResponse" in requestMetadata) {
return requestMetadata.errorResponse;
}
return await authorizeGatewayRequest({
request,
originalRequest: requestMetadata.originalRequest,
authorizers: gatewayRequestAuthorizers,
requestId: request.headers.get("x-request-id") ?? request.headers.get("x-forwarded-for") ?? "unknown",
buildAllowResponse: buildTraefikAllowResponse,
unsupportedRouteMessage: "Unsupported Traefik auth route",
});
};
+54
View File
@@ -0,0 +1,54 @@
import "server-only";
import { NextRequest } from "next/server";
import { TGatewayOriginalRequest, buildGatewayStatusResponse } from "@/modules/gateway-auth/lib/request";
const TRAEFIK_AUTH_PREFIX = "/api/traefik-auth";
const isTraefikAuthPath = (pathname: string): boolean =>
pathname === TRAEFIK_AUTH_PREFIX || pathname.startsWith(`${TRAEFIK_AUTH_PREFIX}/`);
const buildForwardedRequestUrl = (request: NextRequest, forwardedUri: string): URL => {
if (forwardedUri.startsWith("http://") || forwardedUri.startsWith("https://")) {
return new URL(forwardedUri);
}
const proto = request.headers.get("x-forwarded-proto") || "https";
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || "traefik-auth.local";
const normalizedUri = forwardedUri.startsWith("/") ? forwardedUri : `/${forwardedUri}`;
return new URL(normalizedUri, `${proto}://${host}`);
};
export const buildTraefikAllowResponse = (): Response => new Response(null, { status: 200 });
export const parseTraefikRequestMetadata = (
request: NextRequest
): { originalRequest: TGatewayOriginalRequest } | { errorResponse: Response } => {
if (!isTraefikAuthPath(request.nextUrl.pathname)) {
return {
errorResponse: buildGatewayStatusResponse(400, "Invalid Traefik auth request path"),
};
}
const forwardedMethod = request.headers.get("x-forwarded-method")?.trim();
const forwardedUri = request.headers.get("x-forwarded-uri")?.trim();
if (!forwardedMethod || !forwardedUri) {
return {
errorResponse: buildGatewayStatusResponse(400, "Missing original request metadata"),
};
}
try {
return {
originalRequest: {
method: forwardedMethod.toUpperCase(),
url: buildForwardedRequestUrl(request, forwardedUri),
},
};
} catch {
return {
errorResponse: buildGatewayStatusResponse(400, "Invalid original request URI"),
};
}
};
+5
View File
@@ -37,3 +37,8 @@ The stack includes the [Formbricks Hub](https://github.com/formbricks/hub) API (
- **Development** (`docker-compose.dev.yml`): Hub uses the same local Postgres database and `HUB_API_KEY` defaults to `dev-api-key`. Cube is behind the `xm` profile, `CUBEJS_API_URL` defaults to `http://localhost:4000`, and `pnpm dev:setup` generates `CUBEJS_API_SECRET` in the repo root `.env`. The Hub image is pinned to a semver tag (`hub` and `hub-migrate` share the same value); override `HUB_IMAGE_TAG` in the repo root `.env` to test a specific Hub release.
In development, Hub is exposed locally on port **8080**. When the `xm` profile is enabled, Cube is exposed on **4000** (with the Cube playground on **4001**). In production Docker Compose, Hub stays internal to the compose network at `http://hub:8080`; Cube also stays internal at `http://cube:4000` when enabled.
The one-click Traefik installer exposes Hub-backed FeedbackRecords on the Formbricks origin at
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik uses Formbricks gateway auth, rewrites the v3
path to Hub's `/v1/feedback-records`, injects `Authorization: Bearer ${HUB_API_KEY}` for Hub, and strips client
API key/cookie headers before the Hub hop.
+65 -2
View File
@@ -397,6 +397,12 @@ EOF
print " - \"traefik.http.routers.formbricks.tls=true\""
print " - \"traefik.http.routers.formbricks.tls.certresolver=default\""
print " - \"traefik.http.services.formbricks.loadbalancer.server.port=3000\""
print " - \"traefik.http.routers.feedback-records-token.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
print " - \"traefik.http.routers.feedback-records-token.entrypoints=websecure\""
print " - \"traefik.http.routers.feedback-records-token.tls=true\""
print " - \"traefik.http.routers.feedback-records-token.tls.certresolver=default\""
print " - \"traefik.http.routers.feedback-records-token.service=formbricks\""
print " - \"traefik.http.routers.feedback-records-token.priority=200\""
if (hsts_enabled == "y") {
print " - \"traefik.http.middlewares.hstsHeader.headers.stsSeconds=31536000\""
print " - \"traefik.http.middlewares.hstsHeader.headers.forceSTSHeader=true\""
@@ -405,6 +411,10 @@ EOF
} else {
print " - \"traefik.http.routers.formbricks_http.entrypoints=web\""
print " - \"traefik.http.routers.formbricks_http.rule=Host(`" domain_name "`)\""
print " - \"traefik.http.routers.feedback-records-token-http.rule=Host(`" domain_name "`) && Path(`/api/v3/feedbackRecords/token`)\""
print " - \"traefik.http.routers.feedback-records-token-http.entrypoints=web\""
print " - \"traefik.http.routers.feedback-records-token-http.service=formbricks\""
print " - \"traefik.http.routers.feedback-records-token-http.priority=200\""
}
print $0
} else {
@@ -413,6 +423,57 @@ EOF
next
}
{ print }
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
# Step 1b: Add FeedbackRecords gateway labels to the Hub service.
awk -v domain_name="$domain_name" -v hsts_enabled="$hsts_enabled" '
BEGIN { in_hub = 0; inserted = 0 }
/^ hub:/ { in_hub = 1 }
in_hub && /^ [A-Za-z0-9_-]+:/ && !/^ hub:/ { in_hub = 0 }
{
if (in_hub && !inserted && $0 ~ /^ environment:/) {
print " labels:"
print " - \"traefik.enable=true\""
print " - \"traefik.http.services.feedback-records-hub.loadbalancer.server.port=8080\""
print " - \"traefik.http.routers.feedback-records-v3.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
print " - \"traefik.http.routers.feedback-records-v3.entrypoints=websecure\""
print " - \"traefik.http.routers.feedback-records-v3.tls=true\""
print " - \"traefik.http.routers.feedback-records-v3.tls.certresolver=default\""
print " - \"traefik.http.routers.feedback-records-v3.service=feedback-records-hub\""
print " - \"traefik.http.routers.feedback-records-v3.priority=100\""
print " - \"traefik.http.routers.feedback-records-v3.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
print " - \"traefik.http.routers.feedback-records-sdk.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
print " - \"traefik.http.routers.feedback-records-sdk.entrypoints=websecure\""
print " - \"traefik.http.routers.feedback-records-sdk.tls=true\""
print " - \"traefik.http.routers.feedback-records-sdk.tls.certresolver=default\""
print " - \"traefik.http.routers.feedback-records-sdk.service=feedback-records-hub\""
print " - \"traefik.http.routers.feedback-records-sdk.priority=100\""
print " - \"traefik.http.routers.feedback-records-sdk.middlewares=feedback-records-auth,feedback-records-hub-headers\""
if (hsts_enabled != "y") {
print " - \"traefik.http.routers.feedback-records-v3-http.rule=Host(`" domain_name "`) && PathPrefix(`/api/v3/feedbackRecords`)\""
print " - \"traefik.http.routers.feedback-records-v3-http.entrypoints=web\""
print " - \"traefik.http.routers.feedback-records-v3-http.service=feedback-records-hub\""
print " - \"traefik.http.routers.feedback-records-v3-http.priority=100\""
print " - \"traefik.http.routers.feedback-records-v3-http.middlewares=feedback-records-auth,feedback-records-v3-rewrite,feedback-records-hub-headers\""
print " - \"traefik.http.routers.feedback-records-sdk-http.rule=Host(`" domain_name "`) && PathPrefix(`/v1/feedback-records`)\""
print " - \"traefik.http.routers.feedback-records-sdk-http.entrypoints=web\""
print " - \"traefik.http.routers.feedback-records-sdk-http.service=feedback-records-hub\""
print " - \"traefik.http.routers.feedback-records-sdk-http.priority=100\""
print " - \"traefik.http.routers.feedback-records-sdk-http.middlewares=feedback-records-auth,feedback-records-hub-headers\""
}
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.address=http://formbricks:3000/api/traefik-auth/feedback-records\""
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.forwardbody=true\""
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.maxbodysize=1048576\""
print " - \"traefik.http.middlewares.feedback-records-auth.forwardauth.preserverequestmethod=true\""
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.regex=^/api/v3/feedbackRecords(.*)\""
print " - \"traefik.http.middlewares.feedback-records-v3-rewrite.replacepathregex.replacement=/v1/feedback-records$${1}\""
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Authorization=Bearer ${HUB_API_KEY}\""
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.X-API-Key=\""
print " - \"traefik.http.middlewares.feedback-records-hub-headers.headers.customrequestheaders.Cookie=\""
inserted = 1
}
print
}
' docker-compose.yml >tmp.yml && mv tmp.yml docker-compose.yml
# Step 2: Ensure formbricks waits for minio-init to complete successfully (mapping depends_on)
@@ -511,11 +572,12 @@ EOF
if [[ $insert_traefik == "y" ]]; then
cat >> "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.11.31"
image: "traefik:v3.6.4"
restart: always
container_name: "traefik"
depends_on:
- formbricks
- hub
- minio
ports:
- "80:80"
@@ -540,11 +602,12 @@ EOF
cat > "$services_snippet_file" << EOF
traefik:
image: "traefik:v2.11.31"
image: "traefik:v3.6.4"
restart: always
container_name: "traefik"
depends_on:
- formbricks
- hub
ports:
- "80:80"
- "443:443"
@@ -0,0 +1,27 @@
---
title: "API Gateway"
description: "Gateway auth architecture for proxied service APIs"
icon: "route"
---
### Gateway Model
Formbricks gateway auth is split into three layers:
- A shared gateway-auth core authenticates the caller, normalizes the original request, and dispatches to a
service authorizer.
- Provider adapters translate ingress-specific auth requests into the shared shape. Envoy uses
`/api/envoy-auth/[...path]`; Traefik uses `/api/traefik-auth/[...path]`.
- Service authorizers own service-specific authorization. FeedbackRecords is the first registered service authorizer.
### Tokens
Session-authenticated browser callers should use `/api/v3/gateway/token` with
`{ "service": "feedbackRecords" }`. The token identifies the user for the gateway only; every proxied request
still runs through gateway authorization. `/api/v3/feedbackRecords/token` remains a compatibility alias.
### Provider Adapters
Envoy and Traefik do not send auth subrequests in the same format, so they stay as thin adapters. Envoy derives the
original path from the auth request path. Traefik derives it from `X-Forwarded-Method` and `X-Forwarded-Uri`.
Both adapters reuse the same gateway-auth core and FeedbackRecords authorizer.
+1
View File
@@ -309,6 +309,7 @@
"group": "Technical Handbook",
"pages": [
"development/technical-handbook/overview",
"development/technical-handbook/api-gateway",
"development/technical-handbook/background-job-processing",
"development/technical-handbook/cube-tenant-isolation",
"development/technical-handbook/database-model",
@@ -109,7 +109,7 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
<<: *environment
traefik:
image: "traefik:v2.11.31"
image: "traefik:v3.6.4"
restart: always
container_name: "traefik"
depends_on:
@@ -146,4 +146,4 @@ Modify the configuration to enforce SSL. The rest of the configuration should re
This setup ensures that Formbricks securely communicates using your own SSL certificate. 🚀
If you have any questions or require help, feel free to reach out to us on [**GitHub Discussions**](https://github.com/formbricks/formbricks/discussions). 😃[
](https://formbricks.com/docs/developer-docs/rest-api)
](https://formbricks.com/docs/developer-docs/rest-api)
+7
View File
@@ -151,6 +151,13 @@ Make sure Docker and Docker Compose are installed on your system. These are usua
enabled, Cube.js is internal too. The app reaches them through `http://hub:8080` and `http://cube:4000`.
</Note>
<Info>
If you use the one-click Traefik setup, FeedbackRecords are available on the Formbricks origin at
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Custom Docker reverse proxies need equivalent wiring:
run gateway auth against the Formbricks app, rewrite `/api/v3/feedbackRecords` to Hub's
`/v1/feedback-records`, and inject `Authorization: Bearer <HUB_API_KEY>` only on the Hub-bound hop.
</Info>
## Update
Please take a look at our [migration guide](/self-hosting/advanced/migration) for version specific steps to update Formbricks.
+7
View File
@@ -40,6 +40,13 @@ curl -fsSL https://raw.githubusercontent.com/formbricks/formbricks/stable/docker
finishes, add it manually. `HUB_API_URL` should normally stay at `http://hub:8080`.
</Info>
<Info>
The v5 one-click Traefik setup also exposes Hub-backed FeedbackRecords through Formbricks at
`/api/v3/feedbackRecords` and `/v1/feedback-records`. Traefik calls the internal Formbricks gateway auth
endpoint first, then forwards allowed requests to Hub with the generated `HUB_API_KEY`. Browser callers should
request short-lived gateway tokens from `/api/v3/gateway/token` with `{ "service": "feedbackRecords" }`.
</Info>
### Script Prompts
During installation, the script will prompt you to provide some details: