feat: Adds SAML SSO auth using boxyHQ jackson for self-hosters (#4799)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com> Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
@@ -130,6 +130,9 @@ AZUREAD_TENANT_ID=
|
||||
# OIDC_DISPLAY_NAME=
|
||||
# OIDC_SIGNING_ALGORITHM=
|
||||
|
||||
# Configure SAML SSO
|
||||
# SAML_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks-saml
|
||||
|
||||
# Configure this when you want to ship JS & CSS files from a complete URL instead of the current domain
|
||||
# ASSET_PREFIX_URL=
|
||||
|
||||
|
||||
3
apps/web/.gitignore
vendored
@@ -48,3 +48,6 @@ uploads/
|
||||
|
||||
# Sentry Config File
|
||||
.sentryclirc
|
||||
|
||||
# SAML Preloaded Connections
|
||||
saml-connection/
|
||||
@@ -107,6 +107,11 @@ ENV HOSTNAME "0.0.0.0"
|
||||
RUN mkdir -p /home/nextjs/apps/web/uploads/
|
||||
VOLUME /home/nextjs/apps/web/uploads/
|
||||
|
||||
# Prepare volume for SAML preloaded connection
|
||||
RUN mkdir -p /home/nextjs/apps/web/saml-connection
|
||||
VOLUME /home/nextjs/apps/web/saml-connection
|
||||
|
||||
CMD supercronic -quiet /app/docker/cronjobs & \
|
||||
(cd packages/database && npm run db:migrate:deploy) && \
|
||||
(cd packages/database && npm run db:create-saml-database:deploy) && \
|
||||
exec node apps/web/server.js
|
||||
|
||||
3
apps/web/app/api/auth/saml/authorize/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/authorize/route";
|
||||
|
||||
export { GET };
|
||||
3
apps/web/app/api/auth/saml/callback/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/callback/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/token/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { POST } from "@/modules/ee/auth/saml/api/token/route";
|
||||
|
||||
export { POST };
|
||||
3
apps/web/app/api/auth/saml/userinfo/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { GET } from "@/modules/ee/auth/saml/api/userinfo/route";
|
||||
|
||||
export { GET };
|
||||
@@ -39,7 +39,10 @@ interface LoginFormProps {
|
||||
oidcOAuthEnabled: boolean;
|
||||
oidcDisplayName?: string;
|
||||
isMultiOrgEnabled: boolean;
|
||||
isSSOEnabled: boolean;
|
||||
isSsoEnabled: boolean;
|
||||
samlSsoEnabled: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const LoginForm = ({
|
||||
@@ -52,7 +55,10 @@ export const LoginForm = ({
|
||||
oidcOAuthEnabled,
|
||||
oidcDisplayName,
|
||||
isMultiOrgEnabled,
|
||||
isSSOEnabled,
|
||||
isSsoEnabled,
|
||||
samlSsoEnabled,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
}: LoginFormProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -239,13 +245,16 @@ export const LoginForm = ({
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
{isSSOEnabled && (
|
||||
{isSsoEnabled && (
|
||||
<SSOOptions
|
||||
googleOAuthEnabled={googleOAuthEnabled}
|
||||
githubOAuthEnabled={githubOAuthEnabled}
|
||||
azureOAuthEnabled={azureOAuthEnabled}
|
||||
oidcOAuthEnabled={oidcOAuthEnabled}
|
||||
oidcDisplayName={oidcDisplayName}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
samlTenant={samlTenant}
|
||||
samlProduct={samlProduct}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { Testimonial } from "@/modules/auth/components/testimonial";
|
||||
import { getIsMultiOrgEnabled, getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
AZURE_OAUTH_ENABLED,
|
||||
@@ -10,6 +14,9 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
PASSWORD_RESET_DISABLED,
|
||||
SAML_OAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
SIGNUP_ENABLED,
|
||||
} from "@formbricks/lib/constants";
|
||||
import { LoginForm } from "./components/login-form";
|
||||
@@ -20,7 +27,13 @@ export const metadata: Metadata = {
|
||||
};
|
||||
|
||||
export const LoginPage = async () => {
|
||||
const [isMultiOrgEnabled, isSSOEnabled] = await Promise.all([getIsMultiOrgEnabled(), getIsSSOEnabled()]);
|
||||
const [isMultiOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getisSsoEnabled(),
|
||||
getIsSamlSsoEnabled(),
|
||||
]);
|
||||
|
||||
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
|
||||
return (
|
||||
<div className="grid min-h-screen w-full bg-gradient-to-tr from-slate-100 to-slate-50 lg:grid-cols-5">
|
||||
<div className="col-span-2 hidden lg:flex">
|
||||
@@ -38,7 +51,10 @@ export const LoginPage = async () => {
|
||||
oidcOAuthEnabled={OIDC_OAUTH_ENABLED}
|
||||
oidcDisplayName={OIDC_DISPLAY_NAME}
|
||||
isMultiOrgEnabled={isMultiOrgEnabled}
|
||||
isSSOEnabled={isSSOEnabled}
|
||||
isSsoEnabled={isSsoEnabled}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
@@ -53,8 +53,11 @@ interface SignupFormProps {
|
||||
emailVerificationDisabled: boolean;
|
||||
defaultOrganizationId?: string;
|
||||
defaultOrganizationRole?: TOrganizationRole;
|
||||
isSSOEnabled: boolean;
|
||||
isSsoEnabled: boolean;
|
||||
samlSsoEnabled: boolean;
|
||||
isTurnstileConfigured: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const SignupForm = ({
|
||||
@@ -72,8 +75,11 @@ export const SignupForm = ({
|
||||
emailVerificationDisabled,
|
||||
defaultOrganizationId,
|
||||
defaultOrganizationRole,
|
||||
isSSOEnabled,
|
||||
isSsoEnabled,
|
||||
samlSsoEnabled,
|
||||
isTurnstileConfigured,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
}: SignupFormProps) => {
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
@@ -266,13 +272,16 @@ export const SignupForm = ({
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
{isSSOEnabled && (
|
||||
{isSsoEnabled && (
|
||||
<SSOOptions
|
||||
googleOAuthEnabled={googleOAuthEnabled}
|
||||
githubOAuthEnabled={githubOAuthEnabled}
|
||||
azureOAuthEnabled={azureOAuthEnabled}
|
||||
oidcOAuthEnabled={oidcOAuthEnabled}
|
||||
oidcDisplayName={oidcDisplayName}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
samlTenant={samlTenant}
|
||||
samlProduct={samlProduct}
|
||||
callbackUrl={callbackUrl}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { FormWrapper } from "@/modules/auth/components/form-wrapper";
|
||||
import { Testimonial } from "@/modules/auth/components/testimonial";
|
||||
import { getIsMultiOrgEnabled, getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getIsMultiOrgEnabled,
|
||||
getIsSamlSsoEnabled,
|
||||
getisSsoEnabled,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
AZURE_OAUTH_ENABLED,
|
||||
@@ -14,6 +18,9 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
PRIVACY_URL,
|
||||
SAML_OAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
SIGNUP_ENABLED,
|
||||
TERMS_URL,
|
||||
WEBAPP_URL,
|
||||
@@ -24,7 +31,14 @@ import { SignupForm } from "./components/signup-form";
|
||||
export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
const searchParams = await searchParamsProps;
|
||||
const inviteToken = searchParams["inviteToken"] ?? null;
|
||||
const [isMultOrgEnabled, isSSOEnabled] = await Promise.all([getIsMultiOrgEnabled(), getIsSSOEnabled()]);
|
||||
const [isMultOrgEnabled, isSsoEnabled, isSamlSsoEnabled] = await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getisSsoEnabled(),
|
||||
getIsSamlSsoEnabled(),
|
||||
]);
|
||||
|
||||
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
|
||||
|
||||
const locale = await findMatchingLocale();
|
||||
if (!inviteToken && (!SIGNUP_ENABLED || !isMultOrgEnabled)) {
|
||||
notFound();
|
||||
@@ -53,8 +67,11 @@ export const SignupPage = async ({ searchParams: searchParamsProps }) => {
|
||||
emailFromSearchParams={emailFromSearchParams}
|
||||
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
|
||||
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
|
||||
isSSOEnabled={isSSOEnabled}
|
||||
isSsoEnabled={isSsoEnabled}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
/>
|
||||
</FormWrapper>
|
||||
</div>
|
||||
|
||||
33
apps/web/modules/ee/auth/saml/api/authorize/route.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import type { OAuthReq } from "@boxyhq/saml-jackson";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = async (req: NextRequest) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
const searchParams = Object.fromEntries(req.nextUrl.searchParams);
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
|
||||
if (!isSamlSsoEnabled) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
|
||||
try {
|
||||
const { redirect_url } = await oauthController.authorize(searchParams as OAuthReq);
|
||||
|
||||
if (!redirect_url) {
|
||||
return responses.internalServerErrorResponse("Failed to get redirect URL");
|
||||
}
|
||||
|
||||
return NextResponse.redirect(redirect_url);
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : "An unknown error occurred";
|
||||
|
||||
return responses.internalServerErrorResponse(errorMessage);
|
||||
}
|
||||
};
|
||||
32
apps/web/modules/ee/auth/saml/api/callback/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface SAMLCallbackBody {
|
||||
RelayState: string;
|
||||
SAMLResponse: string;
|
||||
}
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
|
||||
const formData = await req.formData();
|
||||
const body = Object.fromEntries(formData.entries());
|
||||
|
||||
const { RelayState, SAMLResponse } = body as unknown as SAMLCallbackBody;
|
||||
|
||||
const { redirect_url } = await oauthController.samlResponse({
|
||||
RelayState,
|
||||
SAMLResponse,
|
||||
});
|
||||
|
||||
if (!redirect_url) {
|
||||
return responses.internalServerErrorResponse("Failed to get redirect URL");
|
||||
}
|
||||
|
||||
return redirect(redirect_url);
|
||||
};
|
||||
18
apps/web/modules/ee/auth/saml/api/token/route.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { OAuthTokenReq } from "@boxyhq/saml-jackson";
|
||||
|
||||
export const POST = async (req: Request) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
|
||||
const body = await req.formData();
|
||||
const formData = Object.fromEntries(body.entries());
|
||||
|
||||
const response = await oauthController.token(formData as unknown as OAuthTokenReq);
|
||||
|
||||
return Response.json(response);
|
||||
};
|
||||
87
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { extractAuthToken } from "./utils";
|
||||
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
unauthorizedResponse: vi.fn().mockReturnValue(new Error("Unauthorized")),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("extractAuthToken", () => {
|
||||
test("extracts token from Authorization header with Bearer prefix", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "Bearer token123",
|
||||
},
|
||||
});
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("token123");
|
||||
});
|
||||
|
||||
test("extracts token from Authorization header with other prefix", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "Custom token123",
|
||||
},
|
||||
});
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("token123");
|
||||
});
|
||||
|
||||
test("extracts token from query parameter", () => {
|
||||
const mockRequest = new Request("https://example.com?access_token=token123");
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("token123");
|
||||
});
|
||||
|
||||
test("prioritizes Authorization header over query parameter", () => {
|
||||
const mockRequest = new Request("https://example.com?access_token=queryToken", {
|
||||
headers: {
|
||||
authorization: "Bearer headerToken",
|
||||
},
|
||||
});
|
||||
|
||||
const token = extractAuthToken(mockRequest);
|
||||
expect(token).toBe("headerToken");
|
||||
});
|
||||
|
||||
test("throws unauthorized error when no token is found", () => {
|
||||
const mockRequest = new Request("https://example.com");
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws unauthorized error when Authorization header is empty", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws unauthorized error when query parameter is empty", () => {
|
||||
const mockRequest = new Request("https://example.com?access_token=");
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles Authorization header with only prefix", () => {
|
||||
const mockRequest = new Request("https://example.com", {
|
||||
headers: {
|
||||
authorization: "Bearer ",
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => extractAuthToken(mockRequest)).toThrow("Unauthorized");
|
||||
expect(responses.unauthorizedResponse).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
14
apps/web/modules/ee/auth/saml/api/userinfo/lib/utils.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
|
||||
export const extractAuthToken = (req: Request) => {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
const parts = (authHeader || "").split(" ");
|
||||
if (parts.length > 1) return parts[1];
|
||||
|
||||
// check for query param
|
||||
const params = new URL(req.url).searchParams;
|
||||
const accessToken = params.get("access_token");
|
||||
if (accessToken) return accessToken;
|
||||
|
||||
throw responses.unauthorizedResponse();
|
||||
};
|
||||
16
apps/web/modules/ee/auth/saml/api/userinfo/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { extractAuthToken } from "@/modules/ee/auth/saml/api/userinfo/lib/utils";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
const jacksonInstance = await jackson();
|
||||
if (!jacksonInstance) {
|
||||
return responses.forbiddenResponse("SAML SSO is not enabled in your Formbricks license");
|
||||
}
|
||||
const { oauthController } = jacksonInstance;
|
||||
const token = extractAuthToken(req);
|
||||
|
||||
const user = await oauthController.userInfo(token);
|
||||
|
||||
return Response.json(user);
|
||||
};
|
||||
43
apps/web/modules/ee/auth/saml/lib/jackson.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
"use server";
|
||||
|
||||
import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection";
|
||||
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import type { IConnectionAPIController, IOAuthController, JacksonOption } from "@boxyhq/saml-jackson";
|
||||
import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
const opts: JacksonOption = {
|
||||
externalUrl: WEBAPP_URL,
|
||||
samlAudience: SAML_AUDIENCE,
|
||||
samlPath: SAML_PATH,
|
||||
db: {
|
||||
engine: "sql",
|
||||
type: "postgres",
|
||||
url: SAML_DATABASE_URL,
|
||||
},
|
||||
};
|
||||
|
||||
declare global {
|
||||
var oauthController: IOAuthController | undefined;
|
||||
var connectionController: IConnectionAPIController | undefined;
|
||||
}
|
||||
|
||||
const g = global;
|
||||
|
||||
export default async function init() {
|
||||
if (!g.oauthController || !g.connectionController) {
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
if (!isSamlSsoEnabled) return;
|
||||
|
||||
const ret = await (await import("@boxyhq/saml-jackson")).controllers(opts);
|
||||
|
||||
await preloadConnection(ret.connectionAPIController);
|
||||
|
||||
g.oauthController = ret.oauthController;
|
||||
g.connectionController = ret.connectionAPIController;
|
||||
}
|
||||
|
||||
return {
|
||||
oauthController: g.oauthController,
|
||||
connectionController: g.connectionController,
|
||||
};
|
||||
}
|
||||
73
apps/web/modules/ee/auth/saml/lib/preload-connection.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { SAMLSSOConnectionWithEncodedMetadata, SAMLSSORecord } from "@boxyhq/saml-jackson";
|
||||
import { ConnectionAPIController } from "@boxyhq/saml-jackson/dist/controller/api";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
|
||||
const getPreloadedConnectionFile = async () => {
|
||||
const preloadedConnections = await fs.readdir(path.join(SAML_XML_DIR));
|
||||
const xmlFiles = preloadedConnections.filter((file) => file.endsWith(".xml"));
|
||||
if (xmlFiles.length === 0) {
|
||||
throw new Error("No preloaded connection file found");
|
||||
}
|
||||
return xmlFiles[0];
|
||||
};
|
||||
|
||||
const getPreloadedConnectionMetadata = async () => {
|
||||
const preloadedConnectionFile = await getPreloadedConnectionFile();
|
||||
|
||||
const preloadedConnectionMetadata = await fs.readFile(
|
||||
path.join(SAML_XML_DIR, preloadedConnectionFile),
|
||||
"utf8"
|
||||
);
|
||||
return preloadedConnectionMetadata;
|
||||
};
|
||||
|
||||
const getConnectionPayload = (metadata: string): SAMLSSOConnectionWithEncodedMetadata => {
|
||||
const encodedRawMetadata = Buffer.from(metadata, "utf8").toString("base64");
|
||||
|
||||
return {
|
||||
name: "SAML SSO",
|
||||
defaultRedirectUrl: `${WEBAPP_URL}/auth/login`,
|
||||
redirectUrl: [`${WEBAPP_URL}/*`],
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
encodedRawMetadata,
|
||||
};
|
||||
};
|
||||
|
||||
export const preloadConnection = async (connectionController: ConnectionAPIController) => {
|
||||
try {
|
||||
const preloadedConnectionMetadata = await getPreloadedConnectionMetadata();
|
||||
|
||||
if (!preloadedConnectionMetadata) {
|
||||
console.log("No preloaded connection metadata found");
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = await connectionController.getConnections({
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
});
|
||||
|
||||
const existingConnection = connections[0];
|
||||
|
||||
const connection = getConnectionPayload(preloadedConnectionMetadata);
|
||||
let newConnection: SAMLSSORecord;
|
||||
try {
|
||||
newConnection = await connectionController.createSAMLConnection(connection);
|
||||
} catch (error) {
|
||||
throw new Error(`Metadata is not valid\n${error.message}`);
|
||||
}
|
||||
if (newConnection && existingConnection && newConnection.clientID !== existingConnection.clientID) {
|
||||
await connectionController.deleteConnections({
|
||||
clientID: existingConnection.clientID,
|
||||
clientSecret: existingConnection.clientSecret,
|
||||
product: existingConnection.product,
|
||||
tenant: existingConnection.tenant,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error preloading connection:", error.message);
|
||||
}
|
||||
};
|
||||
110
apps/web/modules/ee/auth/saml/lib/tests/jackson.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import { preloadConnection } from "@/modules/ee/auth/saml/lib/preload-connection";
|
||||
import { getIsSamlSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { controllers } from "@boxyhq/saml-jackson";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SAML_AUDIENCE, SAML_DATABASE_URL, SAML_PATH, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import init from "../jackson";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
SAML_AUDIENCE: "test-audience",
|
||||
SAML_DATABASE_URL: "test-db-url",
|
||||
SAML_PATH: "/test-path",
|
||||
WEBAPP_URL: "https://test-webapp-url.com",
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsSamlSsoEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/auth/saml/lib/preload-connection", () => ({
|
||||
preloadConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml-jackson", () => ({
|
||||
controllers: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SAML Jackson Initialization", () => {
|
||||
const mockOAuthController = { name: "mockOAuthController" };
|
||||
const mockConnectionController = { name: "mockConnectionController" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
global.oauthController = undefined;
|
||||
global.connectionController = undefined;
|
||||
|
||||
vi.mocked(controllers).mockResolvedValue({
|
||||
oauthController: mockOAuthController,
|
||||
connectionAPIController: mockConnectionController,
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("initialize controllers when SAML SSO is enabled", async () => {
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(true);
|
||||
|
||||
const result = await init();
|
||||
|
||||
expect(getIsSamlSsoEnabled).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(controllers).toHaveBeenCalledWith({
|
||||
externalUrl: WEBAPP_URL,
|
||||
samlAudience: SAML_AUDIENCE,
|
||||
samlPath: SAML_PATH,
|
||||
db: {
|
||||
engine: "sql",
|
||||
type: "postgres",
|
||||
url: SAML_DATABASE_URL,
|
||||
},
|
||||
});
|
||||
|
||||
expect(preloadConnection).toHaveBeenCalledWith(mockConnectionController);
|
||||
|
||||
expect(global.oauthController).toBe(mockOAuthController);
|
||||
expect(global.connectionController).toBe(mockConnectionController);
|
||||
|
||||
expect(result).toEqual({
|
||||
oauthController: mockOAuthController,
|
||||
connectionController: mockConnectionController,
|
||||
});
|
||||
});
|
||||
|
||||
test("return early when SAML SSO is disabled", async () => {
|
||||
vi.mocked(getIsSamlSsoEnabled).mockResolvedValue(false);
|
||||
|
||||
const result = await init();
|
||||
|
||||
expect(getIsSamlSsoEnabled).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(controllers).not.toHaveBeenCalled();
|
||||
|
||||
expect(preloadConnection).not.toHaveBeenCalled();
|
||||
|
||||
expect(global.oauthController).toBeUndefined();
|
||||
expect(global.connectionController).toBeUndefined();
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reuse existing controllers if already initialized", async () => {
|
||||
global.oauthController = mockOAuthController as any;
|
||||
global.connectionController = mockConnectionController as any;
|
||||
|
||||
const result = await init();
|
||||
|
||||
expect(getIsSamlSsoEnabled).not.toHaveBeenCalled();
|
||||
|
||||
expect(controllers).not.toHaveBeenCalled();
|
||||
|
||||
expect(preloadConnection).not.toHaveBeenCalled();
|
||||
|
||||
expect(result).toEqual({
|
||||
oauthController: mockOAuthController,
|
||||
connectionController: mockConnectionController,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SAML_PRODUCT, SAML_TENANT, SAML_XML_DIR, WEBAPP_URL } from "@formbricks/lib/constants";
|
||||
import { preloadConnection } from "../preload-connection";
|
||||
|
||||
vi.mock("@formbricks/lib/constants", () => ({
|
||||
SAML_PRODUCT: "test-product",
|
||||
SAML_TENANT: "test-tenant",
|
||||
SAML_XML_DIR: "test-xml-dir",
|
||||
WEBAPP_URL: "https://test-webapp-url.com",
|
||||
}));
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
default: {
|
||||
readdir: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("path", () => ({
|
||||
default: {
|
||||
join: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml-jackson", () => ({
|
||||
SAMLSSOConnectionWithEncodedMetadata: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@boxyhq/saml-jackson/dist/controller/api", () => ({
|
||||
ConnectionAPIController: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("SAML Preload Connection", () => {
|
||||
const mockConnectionController = {
|
||||
getConnections: vi.fn(),
|
||||
createSAMLConnection: vi.fn(),
|
||||
deleteConnections: vi.fn(),
|
||||
};
|
||||
|
||||
const mockMetadata = "<EntityDescriptor>SAML Metadata</EntityDescriptor>";
|
||||
const mockEncodedMetadata = Buffer.from(mockMetadata, "utf8").toString("base64");
|
||||
|
||||
const mockExistingConnection = {
|
||||
clientID: "existing-client-id",
|
||||
clientSecret: "existing-client-secret",
|
||||
product: SAML_PRODUCT,
|
||||
tenant: SAML_TENANT,
|
||||
};
|
||||
|
||||
const mockNewConnection = {
|
||||
clientID: "new-client-id",
|
||||
clientSecret: "new-client-secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(path.join).mockImplementation((...args) => args.join("/"));
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValue(["metadata.xml", "other-file.txt"] as any);
|
||||
|
||||
vi.mocked(fs.readFile).mockResolvedValue(mockMetadata as any);
|
||||
|
||||
mockConnectionController.getConnections.mockResolvedValue([mockExistingConnection]);
|
||||
|
||||
mockConnectionController.createSAMLConnection.mockResolvedValue(mockNewConnection);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("preload connection from XML file", async () => {
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(fs.readdir).toHaveBeenCalledWith(path.join(SAML_XML_DIR));
|
||||
|
||||
expect(fs.readFile).toHaveBeenCalledWith(path.join(SAML_XML_DIR, "metadata.xml"), "utf8");
|
||||
|
||||
expect(mockConnectionController.getConnections).toHaveBeenCalledWith({
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
});
|
||||
|
||||
expect(mockConnectionController.createSAMLConnection).toHaveBeenCalledWith({
|
||||
name: "SAML SSO",
|
||||
defaultRedirectUrl: `${WEBAPP_URL}/auth/login`,
|
||||
redirectUrl: [`${WEBAPP_URL}/*`],
|
||||
tenant: SAML_TENANT,
|
||||
product: SAML_PRODUCT,
|
||||
encodedRawMetadata: mockEncodedMetadata,
|
||||
});
|
||||
|
||||
expect(mockConnectionController.deleteConnections).toHaveBeenCalledWith({
|
||||
clientID: mockExistingConnection.clientID,
|
||||
clientSecret: mockExistingConnection.clientSecret,
|
||||
product: mockExistingConnection.product,
|
||||
tenant: mockExistingConnection.tenant,
|
||||
});
|
||||
});
|
||||
|
||||
test("not delete existing connection if client IDs match", async () => {
|
||||
mockConnectionController.createSAMLConnection.mockResolvedValue({
|
||||
clientID: mockExistingConnection.clientID,
|
||||
});
|
||||
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(mockConnectionController.deleteConnections).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handle case when no XML files are found", async () => {
|
||||
vi.mocked(fs.readdir).mockResolvedValue(["other-file.txt"] as any);
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error preloading connection:",
|
||||
expect.stringContaining("No preloaded connection file found")
|
||||
);
|
||||
|
||||
expect(mockConnectionController.createSAMLConnection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handle invalid metadata", async () => {
|
||||
const errorMessage = "Invalid metadata";
|
||||
mockConnectionController.createSAMLConnection.mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
const consoleErrorSpy = vi.spyOn(console, "error");
|
||||
|
||||
await preloadConnection(mockConnectionController as any);
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
"Error preloading connection:",
|
||||
expect.stringContaining(errorMessage)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -90,6 +90,7 @@ const fetchLicenseForE2ETesting = async (): Promise<{
|
||||
whitelabel: true,
|
||||
removeBranding: true,
|
||||
ai: true,
|
||||
saml: true,
|
||||
},
|
||||
lastChecked: currentTime,
|
||||
};
|
||||
@@ -156,6 +157,7 @@ export const getEnterpriseLicense = async (): Promise<{
|
||||
removeBranding: false,
|
||||
contacts: false,
|
||||
ai: false,
|
||||
saml: false,
|
||||
},
|
||||
lastChecked: new Date(),
|
||||
};
|
||||
@@ -361,7 +363,7 @@ export const getIsTwoFactorAuthEnabled = async (): Promise<boolean> => {
|
||||
return licenseFeatures.twoFactorAuth;
|
||||
};
|
||||
|
||||
export const getIsSSOEnabled = async (): Promise<boolean> => {
|
||||
export const getisSsoEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features ? previousResult.features.sso : false;
|
||||
@@ -371,6 +373,21 @@ export const getIsSSOEnabled = async (): Promise<boolean> => {
|
||||
return licenseFeatures.sso;
|
||||
};
|
||||
|
||||
export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
|
||||
if (E2E_TESTING) {
|
||||
const previousResult = await fetchLicenseForE2ETesting();
|
||||
return previousResult && previousResult.features
|
||||
? previousResult.features.sso && previousResult.features.saml
|
||||
: false;
|
||||
}
|
||||
if (IS_FORMBRICKS_CLOUD) {
|
||||
return false;
|
||||
}
|
||||
const licenseFeatures = await getLicenseFeatures();
|
||||
if (!licenseFeatures) return false;
|
||||
return licenseFeatures.sso && licenseFeatures.saml;
|
||||
};
|
||||
|
||||
export const getIsOrganizationAIReady = async (billingPlan: Organization["billing"]["plan"]) => {
|
||||
if (!IS_AI_CONFIGURED) return false;
|
||||
if (E2E_TESTING) {
|
||||
|
||||
@@ -12,6 +12,7 @@ const ZEnterpriseLicenseFeatures = z.object({
|
||||
removeBranding: z.boolean(),
|
||||
twoFactorAuth: z.boolean(),
|
||||
sso: z.boolean(),
|
||||
saml: z.boolean(),
|
||||
ai: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
21
apps/web/modules/ee/sso/actions.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
"use server";
|
||||
|
||||
import { actionClient } from "@/lib/utils/action-client";
|
||||
import jackson from "@/modules/ee/auth/saml/lib/jackson";
|
||||
import { SAML_PRODUCT, SAML_TENANT } from "@formbricks/lib/constants";
|
||||
|
||||
export const doesSamlConnectionExistAction = actionClient.action(async () => {
|
||||
const jacksonInstance = await jackson();
|
||||
|
||||
if (!jacksonInstance) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { connectionController } = jacksonInstance;
|
||||
const connection = await connectionController.getConnections({
|
||||
product: SAML_PRODUCT,
|
||||
tenant: SAML_TENANT,
|
||||
});
|
||||
|
||||
return connection.length === 1;
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface AzureButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
directRedirect?: boolean;
|
||||
lastUsed?: boolean;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { signIn } from "next-auth/react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface GithubButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { signIn } from "next-auth/react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface GoogleButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useCallback, useEffect } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface OpenIdButtonProps {
|
||||
inviteUrl?: string | null;
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
directRedirect?: boolean;
|
||||
text?: string;
|
||||
|
||||
61
apps/web/modules/ee/sso/components/saml-button.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { doesSamlConnectionExistAction } from "@/modules/ee/sso/actions";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { LockIcon } from "lucide-react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
|
||||
interface SamlButtonProps {
|
||||
inviteUrl?: string;
|
||||
lastUsed?: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const SamlButton = ({ inviteUrl, lastUsed, samlTenant, samlProduct }: SamlButtonProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(FORMBRICKS_LOGGED_IN_WITH_LS, "Saml");
|
||||
}
|
||||
setIsLoading(true);
|
||||
const doesSamlConnectionExist = await doesSamlConnectionExistAction();
|
||||
if (!doesSamlConnectionExist?.data) {
|
||||
toast.error(t("auth.saml_connection_error"));
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
signIn(
|
||||
"saml",
|
||||
{
|
||||
redirect: true,
|
||||
callbackUrl: inviteUrl ? inviteUrl : "/", // redirect after login to /
|
||||
},
|
||||
{
|
||||
tenant: samlTenant,
|
||||
product: samlProduct,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleLogin}
|
||||
variant="secondary"
|
||||
className="relative w-full justify-center"
|
||||
loading={isLoading}>
|
||||
{t("auth.continue_with_saml")}
|
||||
|
||||
<LockIcon />
|
||||
{lastUsed && <span className="absolute right-3 text-xs opacity-50">{t("auth.last_used")}</span>}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,10 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FORMBRICKS_LOGGED_IN_WITH_LS } from "@formbricks/lib/localStorage";
|
||||
import { AzureButton } from "./azure-button";
|
||||
import { GithubButton } from "./github-button";
|
||||
import { GoogleButton } from "./google-button";
|
||||
import { OpenIdButton } from "./open-id-button";
|
||||
import { SamlButton } from "./saml-button";
|
||||
|
||||
interface SSOOptionsProps {
|
||||
googleOAuthEnabled: boolean;
|
||||
@@ -13,6 +16,9 @@ interface SSOOptionsProps {
|
||||
oidcOAuthEnabled: boolean;
|
||||
oidcDisplayName?: string;
|
||||
callbackUrl: string;
|
||||
samlSsoEnabled: boolean;
|
||||
samlTenant: string;
|
||||
samlProduct: string;
|
||||
}
|
||||
|
||||
export const SSOOptions = ({
|
||||
@@ -22,16 +28,42 @@ export const SSOOptions = ({
|
||||
oidcOAuthEnabled,
|
||||
oidcDisplayName,
|
||||
callbackUrl,
|
||||
samlSsoEnabled,
|
||||
samlTenant,
|
||||
samlProduct,
|
||||
}: SSOOptionsProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [lastLoggedInWith, setLastLoggedInWith] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
setLastLoggedInWith(localStorage.getItem(FORMBRICKS_LOGGED_IN_WITH_LS) || "");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{googleOAuthEnabled && <GoogleButton inviteUrl={callbackUrl} />}
|
||||
{githubOAuthEnabled && <GithubButton inviteUrl={callbackUrl} />}
|
||||
{azureOAuthEnabled && <AzureButton inviteUrl={callbackUrl} />}
|
||||
{googleOAuthEnabled && (
|
||||
<GoogleButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Google"} />
|
||||
)}
|
||||
{githubOAuthEnabled && (
|
||||
<GithubButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Github"} />
|
||||
)}
|
||||
{azureOAuthEnabled && <AzureButton inviteUrl={callbackUrl} lastUsed={lastLoggedInWith === "Azure"} />}
|
||||
{oidcOAuthEnabled && (
|
||||
<OpenIdButton inviteUrl={callbackUrl} text={t("auth.continue_with_oidc", { oidcDisplayName })} />
|
||||
<OpenIdButton
|
||||
inviteUrl={callbackUrl}
|
||||
lastUsed={lastLoggedInWith === "OpenID"}
|
||||
text={t("auth.continue_with_oidc", { oidcDisplayName })}
|
||||
/>
|
||||
)}
|
||||
{samlSsoEnabled && (
|
||||
<SamlButton
|
||||
inviteUrl={callbackUrl}
|
||||
lastUsed={lastLoggedInWith === "Saml"}
|
||||
samlTenant={samlTenant}
|
||||
samlProduct={samlProduct}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_ISSUER,
|
||||
OIDC_SIGNING_ALGORITHM,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
|
||||
export const getSSOProviders = () => [
|
||||
@@ -54,6 +55,36 @@ export const getSSOProviders = () => [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "saml",
|
||||
name: "BoxyHQ SAML",
|
||||
type: "oauth" as const,
|
||||
version: "2.0",
|
||||
checks: ["pkce" as const, "state" as const],
|
||||
authorization: {
|
||||
url: `${WEBAPP_URL}/api/auth/saml/authorize`,
|
||||
params: {
|
||||
scope: "",
|
||||
response_type: "code",
|
||||
provider: "saml",
|
||||
},
|
||||
},
|
||||
token: `${WEBAPP_URL}/api/auth/saml/token`,
|
||||
userinfo: `${WEBAPP_URL}/api/auth/saml/userinfo`,
|
||||
profile(profile) {
|
||||
return {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
name: [profile.firstName, profile.lastName].filter(Boolean).join(" "),
|
||||
image: null,
|
||||
};
|
||||
},
|
||||
options: {
|
||||
clientId: "dummy",
|
||||
clientSecret: "dummy",
|
||||
},
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
},
|
||||
];
|
||||
|
||||
export type { IdentityProvider };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createBrevoCustomer } from "@/modules/auth/lib/brevo";
|
||||
import { getUserByEmail, updateUser } from "@/modules/auth/lib/user";
|
||||
import { createUser } from "@/modules/auth/lib/user";
|
||||
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import type { IdentityProvider } from "@prisma/client";
|
||||
import type { Account } from "next-auth";
|
||||
import { prisma } from "@formbricks/database";
|
||||
@@ -12,12 +13,25 @@ import { findMatchingLocale } from "@formbricks/lib/utils/locale";
|
||||
import type { TUser, TUserNotificationSettings } from "@formbricks/types/user";
|
||||
|
||||
export const handleSSOCallback = async ({ user, account }: { user: TUser; account: Account }) => {
|
||||
const isSsoEnabled = await getisSsoEnabled();
|
||||
if (!isSsoEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user.email || account.type !== "oauth") {
|
||||
return false;
|
||||
}
|
||||
|
||||
let provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
|
||||
|
||||
if (provider === "saml") {
|
||||
const isSamlSsoEnabled = await getIsSamlSsoEnabled();
|
||||
if (!isSamlSsoEnabled) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (account.provider) {
|
||||
const provider = account.provider.toLowerCase().replace("-", "") as IdentityProvider;
|
||||
// check if accounts for this provider / account Id already exists
|
||||
const existingUserWithAccount = await prisma.user.findFirst({
|
||||
include: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SignupForm } from "@/modules/auth/signup/components/signup-form";
|
||||
import { getIsSSOEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getIsSamlSsoEnabled, getisSsoEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { getTranslate } from "@/tolgee/server";
|
||||
import { Metadata } from "next";
|
||||
import {
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
OIDC_DISPLAY_NAME,
|
||||
OIDC_OAUTH_ENABLED,
|
||||
PRIVACY_URL,
|
||||
SAML_OAUTH_ENABLED,
|
||||
SAML_PRODUCT,
|
||||
SAML_TENANT,
|
||||
TERMS_URL,
|
||||
WEBAPP_URL,
|
||||
} from "@formbricks/lib/constants";
|
||||
@@ -26,7 +29,11 @@ export const metadata: Metadata = {
|
||||
|
||||
export const SignupPage = async () => {
|
||||
const locale = await findMatchingLocale();
|
||||
const isSSOEnabled = await getIsSSOEnabled();
|
||||
|
||||
const [isSsoEnabled, isSamlSsoEnabled] = await Promise.all([getisSsoEnabled(), getIsSamlSsoEnabled()]);
|
||||
|
||||
const samlSsoEnabled = isSamlSsoEnabled && SAML_OAUTH_ENABLED;
|
||||
|
||||
const t = await getTranslate();
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -47,8 +54,11 @@ export const SignupPage = async () => {
|
||||
userLocale={locale}
|
||||
defaultOrganizationId={DEFAULT_ORGANIZATION_ID}
|
||||
defaultOrganizationRole={DEFAULT_ORGANIZATION_ROLE}
|
||||
isSSOEnabled={isSSOEnabled}
|
||||
isSsoEnabled={isSsoEnabled}
|
||||
samlSsoEnabled={samlSsoEnabled}
|
||||
isTurnstileConfigured={IS_TURNSTILE_CONFIGURED}
|
||||
samlTenant={SAML_TENANT}
|
||||
samlProduct={SAML_PRODUCT}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/azure": "1.1.9",
|
||||
"@boxyhq/saml-jackson": "1.37.1",
|
||||
"@dnd-kit/core": "6.3.1",
|
||||
"@dnd-kit/modifiers": "9.0.0",
|
||||
"@dnd-kit/sortable": "10.0.0",
|
||||
|
||||
@@ -111,6 +111,9 @@ x-environment: &environment
|
||||
# OIDC_DISPLAY_NAME:
|
||||
# OIDC_SIGNING_ALGORITHM:
|
||||
|
||||
# Set the below to SAML Provider if you want to enable SAML
|
||||
# SAML_DATABASE_URL: "postgresql://postgres:postgres@postgres:5432/formbricks-saml?sslmode=disable"
|
||||
|
||||
########################################## OPTIONAL (THIRD PARTY INTEGRATIONS) ###########################################
|
||||
|
||||
# Oauth credentials for Notion Integration
|
||||
@@ -185,9 +188,11 @@ services:
|
||||
- 3000:3000
|
||||
volumes:
|
||||
- uploads:/home/nextjs/apps/web/uploads/
|
||||
- ./saml-connection:/home/nextjs/apps/web/saml-connection
|
||||
<<: *environment
|
||||
|
||||
volumes:
|
||||
postgres:
|
||||
driver: local
|
||||
uploads:
|
||||
driver: local
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
title: "Setup SAML with Identity Providers"
|
||||
description: "This guide explains the settings you need to use to configure SAML with your Identity Provider. Once configured, obtain an XML metadata file and upload it on your Formbricks instance."
|
||||
description: "This guide explains the settings you need to use to configure SAML with your Identity Provider. Once configured, obtain an XML metadata file and use it to configure SAML in Formbricks."
|
||||
---
|
||||
|
||||
### SAML Registration with Identity Providers
|
||||
|
||||
This guide explains the settings you need to use to configure SAML with your Identity Provider. Once configured, obtain an XML metadata file and upload it on your Formbricks instance.
|
||||
This guide explains the settings you need to use to configure SAML with your Identity Provider. Once configured, obtain an XML metadata file and use it to configure SAML in Formbricks.
|
||||
|
||||
> **Note:** Please do not add a trailing slash at the end of the URLs. Create them exactly as shown below.
|
||||
|
||||
@@ -23,13 +23,13 @@ This guide explains the settings you need to use to configure SAML with your Ide
|
||||
|
||||
**Mapping Attributes / Attribute Statements:**
|
||||
|
||||
* [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier) -> id
|
||||
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier) -> id
|
||||
|
||||
* [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress) -> email
|
||||
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress) -> email
|
||||
|
||||
* [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname) -> firstName
|
||||
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname) -> firstName
|
||||
|
||||
* [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname) -> lastName
|
||||
- [http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname](http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname) -> lastName
|
||||
|
||||
### SAML With Okta
|
||||
|
||||
@@ -38,57 +38,47 @@ This guide explains the settings you need to use to configure SAML with your Ide
|
||||
For example, in Okta, once you create an account, you can click on Applications on the sidebar menu:
|
||||
|
||||
<img src="/images/development/guides/auth-and-provision/okta/okta-applications.webp" />
|
||||
</Step>
|
||||
|
||||
</Step>
|
||||
<Step title="Click on Create App Integration">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/create-app-integration.webp" />
|
||||
<img src="/images/development/guides/auth-and-provision/okta/create-app-integration.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Select SAML 2.0 in the modal form, and click Next">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/select-saml-2.0.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Fill the general settings as shown and click Next">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/general-settings.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Enter the SAML Integration Settings as shown and click Next">
|
||||
- **Single Sign-On URL**: `https://<your-formbricks-instance>/api/auth/saml/callback` or `http://localhost:3000/api/auth/saml/callback` (if you are running Formbricks locally)
|
||||
- **Audience URI (SP Entity ID)**: `https://saml.formbricks.com`
|
||||
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
|
||||
</Step>
|
||||
<Step title="Fill the fields mapping as shown and click Next">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/fields-mapping.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Enter the SAML Integration Settings as shown and click Next">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/saml-integration-settings.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Check the internal app checkbox and click Finish">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/internal-app.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Check that the app is created successfully">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/app-created.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Click on the app and head over to the Assignments tab">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/assignments-tab.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Click on Assign button and select Assign to People">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/assign-to-people.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Select the users you want to assign the app to and click Assign">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/select-users.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Head over to the Sign On tab and scroll to the bottom to get the metadata, click on the Actions button">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/actions-button.webp" />
|
||||
<Step title="Head over to the Sign On tab and click on the 'view SAML setup instructions' button">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/view-saml-instructions.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Click on View IdP metadata">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/view-idp-metadata.webp" />
|
||||
<Step title="Scroll to the bottom and copy the IDP metadata">
|
||||
<img src="/images/development/guides/auth-and-provision/okta/idp-metadata.webp" />
|
||||
</Step>
|
||||
|
||||
<Step title="Copy the metadata and paste it in the Formbricks SAML configuration" />
|
||||
<Step title="Copy the IDP metadata and paste it in the `connection.xml` file in the `formbricks/saml-connection` directory" />
|
||||
</Steps>
|
||||
|
||||
That's it. Now when you try to login with SSO, your application on Okta will handle the authentication.
|
||||
That's it. Now when you try to login with SSO, your application on Okta will handle the authentication.
|
||||
|
||||
|
Before Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 330 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -17,7 +17,7 @@ To learn more about SAML Jackson, please refer to the [BoxyHQ SAML Jackson docum
|
||||
SAML (Security Assertion Markup Language) is an XML-based standard for exchanging authentication and authorization data between an Identity Provider (IdP) and Formbricks. Here's how the integration works with BoxyHQ Jackson embedded into the flow:
|
||||
|
||||
1. **Login Initiation:**
|
||||
The user clicks “Sign in with SAML” on Formbricks.
|
||||
The user clicks `Continue with SAML SSO` on Formbricks.
|
||||
|
||||
2. **Configuration Retrieval via BoxyHQ:**
|
||||
Formbricks requests the SAML connection details from BoxyHQ Jackson. BoxyHQ securely stores and manages the IdP configuration, including endpoints, certificates, and other metadata.
|
||||
@@ -50,7 +50,7 @@ sequenceDiagram
|
||||
|
||||
Note over FB,BHQ: (Setup phase, done beforehand)<br/>1. Admin configures SAML metadata in Formbricks<br/>2. BoxyHQ stores & manages SAML connection details
|
||||
|
||||
U->>FB: Clicks “Sign in with SAML”
|
||||
U->>FB: Clicks “Continue with SAML SSO"
|
||||
FB->>BHQ: Request SAML connection details
|
||||
BHQ->>FB: Returns SAML configuration (IdP info)
|
||||
FB->>OK: Redirect user to Okta (SAML Auth Request)
|
||||
@@ -67,10 +67,14 @@ sequenceDiagram
|
||||
|
||||
To configure SAML SSO in Formbricks, follow these steps:
|
||||
|
||||
1. **Database Setup:** Configure a dedicated database for SAML by setting the `SAML_DATABASE_URL` environment variable (e.g., `postgresql://postgres:@localhost:5432/formbricks-saml`). If you're using a self-signed certificate for Postgres, include the `sslmode=no-verify` parameter.
|
||||
2. **Admin Configuration:** Define the SAML administrators by setting `SAML_ADMINS` as a comma-separated list of admin emails.
|
||||
3. **IdP Application:** Create a SAML application in your IdP by following your provider's instructions([SAML Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers))
|
||||
4. **User Provisioning:** Provision users in your IdP and configure access to the IdP SAML app for all your users (who need access to Formbricks).
|
||||
5. **Metadata:** Keep the XML metadata from your IdP handy for the next step.
|
||||
6. **Metadata Upload:** Log in with one of the SAML admin accounts and navigate to **Organization Settings -> Single Sign-On** in Formbricks. Paste the XML metadata from your IdP into the SAML configuration section.
|
||||
7. **Finalize Setup:** Save your configuration. Provisioned users can now use SAML SSO to access Formbricks.
|
||||
1. **Database Setup:** Configure a dedicated database for SAML by setting the `SAML_DATABASE_URL` environment variable in your `docker-compose.yml` file (e.g., `postgres://postgres:postgres@postgres:5432/formbricks-saml`). If you're using a self-signed certificate for Postgres, include the `sslmode=disable` parameter.
|
||||
2. **IdP Application:** Create a SAML application in your IdP by following your provider's instructions([SAML Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers))
|
||||
3. **User Provisioning:** Provision users in your IdP and configure access to the IdP SAML app for all your users (who need access to Formbricks).
|
||||
4. **Metadata:** Keep the XML metadata from your IdP handy for the next step.
|
||||
5. **Metadata Setup:** Create a file called `connection.xml` in your self-hosted Formbricks instance's `formbricks/saml-connection` directory and paste the XML metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
|
||||
6. **Restart Formbricks:** Restart Formbricks to apply the changes. You can do this by running `docker compose down` and then `docker compose up -d`.
|
||||
|
||||
<Note>
|
||||
We don't support multiple SAML connections yet. You can only have one SAML connection at a time. If you
|
||||
change the `connection.xml` file, your existing SAML connection will be overwritten.
|
||||
</Note>
|
||||
|
||||
@@ -22,6 +22,7 @@ These variables are present inside your machine’s docker-compose file. Restart
|
||||
| S3_REGION | Region for S3. | optional | (resolved by the AWS SDK) |
|
||||
| S3_BUCKET_NAME | S3 bucket name for data storage. Formbricks enables S3 storage when this is set. | optional (required if S3 is enabled) | |
|
||||
| S3_ENDPOINT_URL | Endpoint for S3. | optional | (resolved by the AWS SDK) |
|
||||
| SAML_DATABASE_URL | Database URL for SAML. | optional | postgres://postgres:@localhost:5432/formbricks-saml |
|
||||
| PRIVACY_URL | URL for privacy policy. | optional | |
|
||||
| TERMS_URL | URL for terms of service. | optional | |
|
||||
| IMPRINT_URL | URL for imprint. | optional | |
|
||||
@@ -60,4 +61,3 @@ These variables are present inside your machine’s docker-compose file. Restart
|
||||
| CUSTOM_CACHE_DISABLED | Disables custom cache handler if set to 1 (required for deployment on Vercel) | optional | |
|
||||
|
||||
Note: If you want to configure something that is not possible via above, please open an issue on our GitHub repo here or reach out to us on Github Discussions and we’ll try our best to work out a solution with you.
|
||||
|
||||
|
||||
@@ -13,30 +13,31 @@ Formbricks supports Security Assertion Markup Language (SAML) SSO. We prioritize
|
||||
### Setting up SAML login
|
||||
|
||||
<Steps>
|
||||
<Step title='Create a SAML application with your Identity Provider (IdP)'>
|
||||
Follow the instructions here - [SAML Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers)
|
||||
<Step title="Create a SAML application with your Identity Provider (IdP)">
|
||||
Follow the instructions here - [SAML
|
||||
Setup](/development/guides/auth-and-provision/setup-saml-with-identity-providers)
|
||||
</Step>
|
||||
<Step title='Configure access to the IdP SAML app'>
|
||||
Ensure that all users who need access to Formbricks have access to the IdP SAML
|
||||
app.
|
||||
<Step title="Configure access to the IdP SAML app">
|
||||
Ensure that all users who need access to Formbricks have access to the IdP SAML app.
|
||||
</Step>
|
||||
<Step title='Retrieve XML metadata from your IdP'>
|
||||
<Step title="Retrieve XML metadata from your IdP">
|
||||
Keep the XML metadata from your IdP accessible, as you will need it later.
|
||||
</Step>
|
||||
<Step title='Log in to your Organization Admin account'>
|
||||
Visit `environments/<ENVIRONMENT_ID>/settings/sso`.
|
||||
<img src='/images/xm-and-surveys/core-features/saml-sso/sso-settings.webp' />
|
||||
<Step title="Set the SAML_DATABASE_URL environment variable">
|
||||
Set the `SAML_DATABASE_URL` environment variable in your `.env` file to a dedicated database for
|
||||
SAML(e.g., `postgresql://postgres:@localhost:5432/formbricks-saml`). If you're using a self-signed
|
||||
certificate for Postgres, include the `sslmode=disable` parameter.
|
||||
</Step>
|
||||
<Step title='Configure SSO with SAML'>
|
||||
Click on the `Configure` button for `SSO with SAML`.
|
||||
<img src='/images/xm-and-surveys/core-features/saml-sso/sso-settings-configure.webp' />
|
||||
<Step title="Set the metadata">
|
||||
Create a file called `connection.xml` in the `apps/web/saml-connection` directory and paste the XML
|
||||
metadata from your IdP into it. Please create the directory if it doesn't exist. Your metadata file should start with a tag like this: `<?xml version="1.0" encoding="UTF-8"?><...>` or `<md:EntityDescriptor entityID="...">`. Please remove any extra text from the metadata.
|
||||
</Step>
|
||||
<Step title='Paste the XML metadata and Save'>
|
||||
In the SAML configuration section, copy and paste the XML metadata from step
|
||||
3 and click on Save.
|
||||
<img src='/images/xm-and-surveys/core-features/saml-sso/sso-settings-modal.webp' />
|
||||
</Step>
|
||||
<Step title='Your users can now log into Formbricks using SAML'>
|
||||
Once setup is complete, provisioned users can log into Formbricks using SAML.
|
||||
<Step title="Your users can now log into Formbricks using SAML">
|
||||
Once setup is complete, please restart the Formbricks server and your users can log into Formbricks using SAML.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
We don't support multiple SAML connections yet. You can only have one SAML connection at a time. If you
|
||||
change the `connection.xml` file, your existing SAML connection will be overwritten.
|
||||
</Note>
|
||||
|
||||
@@ -44,7 +44,7 @@ Formbricks offers an intuitive No-Code interface that allows you to configure ac
|
||||
|
||||
<Steps>
|
||||
<Step title="Visit the Actions tab via the main navigation">
|
||||

|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Click on 'Add Action' in the top right corner to see the following:">
|
||||
@@ -55,7 +55,7 @@ Formbricks offers an intuitive No-Code interface that allows you to configure ac
|
||||
There are four types of No-Code actions:
|
||||
|
||||
### **1. Click Action**
|
||||

|
||||

|
||||
|
||||
A Click Action is triggered when a user clicks on a specific element within your application. You can define the element's inner text, CSS selector or both to trigger the survey.
|
||||
|
||||
@@ -66,17 +66,17 @@ A Click Action is triggered when a user clicks on a specific element within your
|
||||
* **Both**: Only if both is true, the action is triggered
|
||||
|
||||
### **2. Page View Action**
|
||||

|
||||

|
||||
|
||||
This action is triggered when a user visits a page within your application.
|
||||
|
||||
### **3. Exit Intent Action**
|
||||

|
||||

|
||||
|
||||
This action is triggered when a user is about to leave your application. It helps capture user feedback before they exit, providing valuable insights into user experiences and potential improvements.
|
||||
|
||||
### **4. 50% Scroll Action**
|
||||

|
||||

|
||||
|
||||
This action is triggered when a user scrolls through 50% of a page within your application. It helps capture user feedback at a specific point in their journey, enabling you to gather insights based on user interactions.
|
||||
|
||||
@@ -108,7 +108,7 @@ For more granular control, you can implement actions directly in your code:
|
||||
<Step title="Configure action in Formbricks">
|
||||
First, add the action via the Formbricks web interface to make it available for survey configuration:
|
||||
|
||||

|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ Advanced Targeting helps you achieve a number of goals:
|
||||
<Step title="Create Segment">
|
||||
To get started, go to the Contacts tab and create a new Segment:
|
||||
|
||||

|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
@@ -35,11 +35,11 @@ Advanced Targeting helps you achieve a number of goals:
|
||||
<Step title="Create a survey of type Website & App">
|
||||
Create a new survey and go to Settings to change it to Website & App survey:
|
||||
|
||||

|
||||

|
||||
</Step>
|
||||
|
||||
<Step title="Choose Segment in Targeting options">
|
||||

|
||||

|
||||
|
||||
</Step>
|
||||
|
||||
@@ -52,16 +52,16 @@ There are three means to move Contacts in or out of Segments: **Attributes**, ot
|
||||
|
||||
1. **Attributes**: If the value of a specific attribute matches, the user becomes part of the Segment.
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
2. **Segments**: You can nest Segments meaning that if a user is or is not part of another Segment, they can be included or excluded
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
3. **Devices**: If a user uses a Phone or Desktop, you can include or exclude them
|
||||
|
||||

|
||||

|
||||
|
||||
4. **Filter Groups:** You can group any of the above conditions in group and connect them logically with `AND` or `OR`. This allows for maximum granularity.
|
||||
@@ -24,7 +24,7 @@ To target specific segments of your audience or manage survey exposure, Formbric
|
||||
<Step title="Set percentage">
|
||||
Enter the desired percentage (from 0.01% to 100%) of users to whom the survey will be shown
|
||||
|
||||

|
||||

|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "IdentityProvider" ADD VALUE 'saml';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Response" ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
|
||||
@@ -10,9 +10,11 @@
|
||||
"clean": "rimraf .turbo node_modules",
|
||||
"db:migrate:deploy": "env DATABASE_URL=\"${MIGRATE_DATABASE_URL:-$DATABASE_URL}\" tsx ./src/scripts/apply-migrations.ts",
|
||||
"db:migrate:dev": "dotenv -e ../../.env -- sh -c \"pnpm prisma generate && tsx ./src/scripts/apply-migrations.ts\"",
|
||||
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" tsx ./src/scripts/create-saml-database.ts",
|
||||
"db:create-saml-database:dev": "dotenv -e ../../.env -- tsx ./src/scripts/create-saml-database.ts",
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
"db:up": "docker compose up -d",
|
||||
"db:setup": "pnpm db:up && pnpm db:migrate:dev",
|
||||
"db:setup": "pnpm db:up && pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
|
||||
"db:start": "pnpm db:setup",
|
||||
"db:down": "docker compose down",
|
||||
"format": "prisma format",
|
||||
|
||||
@@ -737,6 +737,7 @@ enum IdentityProvider {
|
||||
google
|
||||
azuread
|
||||
openid
|
||||
saml
|
||||
}
|
||||
|
||||
/// Stores third-party authentication account information.
|
||||
|
||||
50
packages/database/src/scripts/create-saml-database.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const createSamlDatabase = async (): Promise<void> => {
|
||||
const samlDatabaseUrl = process.env.SAML_DATABASE_URL;
|
||||
|
||||
if (!samlDatabaseUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const urlRegex =
|
||||
/^(?<protocol>postgresql|postgres):\/\/(?<username>[^:]+):(?<password>[^@]+)@(?<host>[^:/]+):(?<port>\d+)\/(?<database>[^?]+)(?<temp1>\?(?<query>.*))?$/;
|
||||
const urlMatch = urlRegex.exec(samlDatabaseUrl);
|
||||
const dbName = urlMatch?.groups?.database;
|
||||
|
||||
if (!dbName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a Prisma client to connect to the default database
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
// Check if the database exists
|
||||
const result = await prisma.$queryRaw`
|
||||
SELECT 1 FROM pg_database WHERE datname = ${dbName}
|
||||
`;
|
||||
|
||||
// If the database exists, the query will return a result
|
||||
if (Array.isArray(result) && result.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$executeRawUnsafe(`CREATE DATABASE "${dbName}"`);
|
||||
|
||||
console.log(`Database '${dbName}' created successfully.`);
|
||||
} catch (error) {
|
||||
console.error(`Error creating database '${dbName}':`, error);
|
||||
return;
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
createSamlDatabase()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error("Error creating SAML database:", error);
|
||||
});
|
||||
@@ -30,6 +30,8 @@ export const AZURE_OAUTH_ENABLED =
|
||||
env.AZUREAD_CLIENT_ID && env.AZUREAD_CLIENT_SECRET && env.AZUREAD_TENANT_ID ? true : false;
|
||||
export const OIDC_OAUTH_ENABLED =
|
||||
env.OIDC_CLIENT_ID && env.OIDC_CLIENT_SECRET && env.OIDC_ISSUER ? true : false;
|
||||
export const SAML_OAUTH_ENABLED = env.SAML_DATABASE_URL ? true : false;
|
||||
export const SAML_XML_DIR = "./saml-connection";
|
||||
|
||||
export const GITHUB_ID = env.GITHUB_ID;
|
||||
export const GITHUB_SECRET = env.GITHUB_SECRET;
|
||||
@@ -46,6 +48,12 @@ export const OIDC_ISSUER = env.OIDC_ISSUER;
|
||||
export const OIDC_DISPLAY_NAME = env.OIDC_DISPLAY_NAME;
|
||||
export const OIDC_SIGNING_ALGORITHM = env.OIDC_SIGNING_ALGORITHM;
|
||||
|
||||
export const SAML_DATABASE_URL = env.SAML_DATABASE_URL;
|
||||
export const SAML_TENANT = "formbricks.com";
|
||||
export const SAML_PRODUCT = "formbricks";
|
||||
export const SAML_AUDIENCE = "https://saml.formbricks.com";
|
||||
export const SAML_PATH = "/api/auth/saml/callback";
|
||||
|
||||
export const SIGNUP_ENABLED = env.SIGNUP_DISABLED !== "1";
|
||||
export const EMAIL_AUTH_ENABLED = env.EMAIL_AUTH_DISABLED !== "1";
|
||||
export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
|
||||
|
||||
@@ -73,6 +73,7 @@ export const env = createEnv({
|
||||
S3_SECRET_KEY: z.string().optional(),
|
||||
S3_ENDPOINT_URL: z.string().optional(),
|
||||
S3_FORCE_PATH_STYLE: z.enum(["1", "0"]).optional(),
|
||||
SAML_DATABASE_URL: z.string().optional(),
|
||||
SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
SLACK_CLIENT_ID: z.string().optional(),
|
||||
SLACK_CLIENT_SECRET: z.string().optional(),
|
||||
@@ -196,6 +197,7 @@ export const env = createEnv({
|
||||
S3_SECRET_KEY: process.env.S3_SECRET_KEY,
|
||||
S3_ENDPOINT_URL: process.env.S3_ENDPOINT_URL,
|
||||
S3_FORCE_PATH_STYLE: process.env.S3_FORCE_PATH_STYLE,
|
||||
SAML_DATABASE_URL: process.env.SAML_DATABASE_URL,
|
||||
SIGNUP_DISABLED: process.env.SIGNUP_DISABLED,
|
||||
SLACK_CLIENT_ID: process.env.SLACK_CLIENT_ID,
|
||||
SLACK_CLIENT_SECRET: process.env.SLACK_CLIENT_SECRET,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"continue_with_google": "Login mit Google",
|
||||
"continue_with_oidc": "Weiter mit {oidcDisplayName}",
|
||||
"continue_with_openid": "Login mit OpenID",
|
||||
"continue_with_saml": "Login mit SAML SSO",
|
||||
"forgot-password": {
|
||||
"back_to_login": "Zurück zum Login",
|
||||
"email-sent": {
|
||||
@@ -52,6 +53,7 @@
|
||||
"new_to_formbricks": "Neu bei Formbricks?",
|
||||
"use_a_backup_code": "Einen Backup-Code verwenden"
|
||||
},
|
||||
"saml_connection_error": "Etwas ist schiefgelaufen. Bitte überprüfe die App-Konsole für weitere Details.",
|
||||
"signup": {
|
||||
"captcha_failed": "reCAPTCHA fehlgeschlagen",
|
||||
"have_an_account": "Hast Du ein Konto?",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"continue_with_google": "Continue with Google",
|
||||
"continue_with_oidc": "Continue with {oidcDisplayName}",
|
||||
"continue_with_openid": "Continue with OpenID",
|
||||
"continue_with_saml": "Continue with SAML SSO",
|
||||
"forgot-password": {
|
||||
"back_to_login": "Back to login",
|
||||
"email-sent": {
|
||||
@@ -52,6 +53,7 @@
|
||||
"new_to_formbricks": "New to Formbricks?",
|
||||
"use_a_backup_code": "Use a backup code"
|
||||
},
|
||||
"saml_connection_error": "Something went wrong. Please check your app console for more details.",
|
||||
"signup": {
|
||||
"captcha_failed": "Captcha failed",
|
||||
"have_an_account": "Have an account?",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"continue_with_google": "Continuer avec Google",
|
||||
"continue_with_oidc": "Continuer avec {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuer avec OpenID",
|
||||
"continue_with_saml": "Continuer avec SAML SSO",
|
||||
"forgot-password": {
|
||||
"back_to_login": "Retour à la connexion",
|
||||
"email-sent": {
|
||||
@@ -52,6 +53,7 @@
|
||||
"new_to_formbricks": "Nouveau sur Formbricks ?",
|
||||
"use_a_backup_code": "Utiliser un code de secours"
|
||||
},
|
||||
"saml_connection_error": "Quelque chose s'est mal passé. Veuillez vérifier la console de votre application pour plus de détails.",
|
||||
"signup": {
|
||||
"captcha_failed": "Captcha échoué",
|
||||
"have_an_account": "Avez-vous un compte ?",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"continue_with_google": "Continuar com o Google",
|
||||
"continue_with_oidc": "Continuar com {oidcDisplayName}",
|
||||
"continue_with_openid": "Continuar com OpenID",
|
||||
"continue_with_saml": "Continuar com SAML SSO",
|
||||
"forgot-password": {
|
||||
"back_to_login": "Voltar para o login",
|
||||
"email-sent": {
|
||||
@@ -52,6 +53,7 @@
|
||||
"new_to_formbricks": "Novo no Formbricks?",
|
||||
"use_a_backup_code": "Usar um código de backup"
|
||||
},
|
||||
"saml_connection_error": "Algo deu errado. Por favor, verifica o console do app para mais detalhes.",
|
||||
"signup": {
|
||||
"captcha_failed": "reCAPTCHA falhou",
|
||||
"have_an_account": "Já tem uma conta?",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"continue_with_google": "使用 Google 繼續",
|
||||
"continue_with_oidc": "使用 '{'oidcDisplayName'}' 繼續",
|
||||
"continue_with_openid": "使用 OpenID 繼續",
|
||||
"continue_with_saml": "使用 SAML SSO 繼續",
|
||||
"forgot-password": {
|
||||
"back_to_login": "返回登入",
|
||||
"email-sent": {
|
||||
@@ -52,6 +53,7 @@
|
||||
"new_to_formbricks": "初次使用 Formbricks?",
|
||||
"use_a_backup_code": "使用備份碼"
|
||||
},
|
||||
"saml_connection_error": "發生錯誤。請檢查您的 app 主控台以取得更多詳細資料。",
|
||||
"signup": {
|
||||
"captcha_failed": "驗證碼失敗",
|
||||
"have_an_account": "已有帳戶?",
|
||||
|
||||
@@ -41,6 +41,8 @@ export type TUserPassword = z.infer<typeof ZUserPassword>;
|
||||
|
||||
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;
|
||||
|
||||
const ZUserIdentityProvider = z.enum(["email", "google", "github", "azuread", "openid", "saml"]);
|
||||
|
||||
export const ZUser = z.object({
|
||||
id: z.string(),
|
||||
name: ZUserName,
|
||||
@@ -48,7 +50,7 @@ export const ZUser = z.object({
|
||||
emailVerified: z.date().nullable(),
|
||||
imageUrl: z.string().url().nullable(),
|
||||
twoFactorEnabled: z.boolean(),
|
||||
identityProvider: z.enum(["email", "google", "github", "azuread", "openid"]),
|
||||
identityProvider: ZUserIdentityProvider,
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
role: ZRole.nullable(),
|
||||
@@ -80,7 +82,7 @@ export const ZUserCreateInput = z.object({
|
||||
emailVerified: z.date().optional(),
|
||||
role: ZRole.optional(),
|
||||
objective: ZUserObjective.nullish(),
|
||||
identityProvider: z.enum(["email", "google", "github", "azuread", "openid"]).optional(),
|
||||
identityProvider: ZUserIdentityProvider.optional(),
|
||||
identityProviderAccountId: z.string().optional(),
|
||||
locale: ZUserLocale.optional(),
|
||||
});
|
||||
|
||||
1736
pnpm-lock.yaml
generated
@@ -158,6 +158,7 @@
|
||||
"S3_FORCE_PATH_STYLE",
|
||||
"S3_REGION",
|
||||
"S3_SECRET_KEY",
|
||||
"SAML_DATABASE_URL",
|
||||
"SENTRY_DSN",
|
||||
"SIGNUP_DISABLED",
|
||||
"SLACK_CLIENT_ID",
|
||||
|
||||