make auth page custom domains available in ee (#3957)

This commit is contained in:
Milo Schwartz
2026-10-21 18:07:31 -04:00
committed by GitHub
parent 0f3924a568
commit d25181118c
13 changed files with 163 additions and 80 deletions
+17 -5
View File
@@ -1250,23 +1250,30 @@ export async function getTraefikConfig(
}
}
const usePangolinDns =
privateConfig.getRawPrivateConfig().flags.use_pangolin_dns;
if (generateLoginPageRouters) {
const exitNodeLoginPages = await db
.select({
loginPageId: loginPage.loginPageId,
fullDomain: loginPage.fullDomain,
subdomain: loginPage.subdomain,
exitNodeId: exitNodes.exitNodeId,
domainId: loginPage.domainId
domainId: loginPage.domainId,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert
})
.from(loginPage)
.innerJoin(
exitNodes,
eq(exitNodes.exitNodeId, loginPage.exitNodeId)
)
.leftJoin(domains, eq(domains.domainId, loginPage.domainId))
.where(eq(exitNodes.exitNodeId, exitNodeId));
let validCertsLoginPages: CertificateResult[] = [];
if (pangolinCertModeEnabled) {
if (usePangolinDns) {
// create a list of all domains to get certs for
const domains = new Set<string>();
for (const lp of exitNodeLoginPages) {
@@ -1310,9 +1317,14 @@ export async function getTraefikConfig(
continue;
}
const tls = {};
if (!pangolinCertModeEnabled) {
// TODO: we need to add the wildcard logic here too
let tls: Record<string, any> = {};
if (!usePangolinDns) {
tls = buildWildcardTls({
fullDomain: lp.fullDomain,
hasSubdomain: !!lp.subdomain,
domainCertResolver: lp.domainCertResolver,
preferWildcardCert: lp.preferWildcardCert
});
} else {
// find a cert that matches the full domain, if not continue
const matchingCert = validCertsLoginPages.find(
@@ -30,6 +30,9 @@ import { fromError } from "zod-validation-error";
import { eq, and } from "drizzle-orm";
import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { createCertificate } from "@server/routers/certificates/createCertificate";
import { build } from "@server/build";
import privateConfig from "#private/lib/config";
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
import { CreateLoginPageResponse } from "@server/routers/loginPage/types";
@@ -134,42 +137,53 @@ export async function createLoginPage(
let returned: LoginPage | undefined;
await db.transaction(async (trx) => {
const orgSites = await trx
.select()
.from(sites)
.innerJoin(
exitNodes,
eq(exitNodes.exitNodeId, sites.exitNodeId)
)
.where(
and(
eq(sites.orgId, orgId),
eq(exitNodes.type, "gerbil"),
eq(exitNodes.online, true)
)
)
.limit(10);
let exitNodeId: number | undefined;
let exitNodesList = orgSites.map((s) => s.exitNodes);
if (exitNodesList.length === 0) {
exitNodesList = await trx
if (
build === "enterprise" &&
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
) {
exitNodeId = await getCurrentExitNodeId();
} else {
const orgSites = await trx
.select()
.from(exitNodes)
.from(sites)
.innerJoin(
exitNodes,
eq(exitNodes.exitNodeId, sites.exitNodeId)
)
.where(
and(
eq(sites.orgId, orgId),
eq(exitNodes.type, "gerbil"),
eq(exitNodes.online, true)
)
)
.limit(10);
let exitNodesList = orgSites.map((s) => s.exitNodes);
if (exitNodesList.length === 0) {
exitNodesList = await trx
.select()
.from(exitNodes)
.where(
and(
eq(exitNodes.type, "gerbil"),
eq(exitNodes.online, true)
)
)
.limit(10);
}
const randomExitNode =
exitNodesList[
Math.floor(Math.random() * exitNodesList.length)
];
exitNodeId = randomExitNode?.exitNodeId;
}
// select a random exit node
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
if (!randomExitNode) {
if (!exitNodeId) {
throw new Error("No exit nodes available");
}
@@ -179,7 +193,7 @@ export async function createLoginPage(
subdomain: finalSubdomain,
fullDomain,
domainId,
exitNodeId: randomExitNode.exitNodeId
exitNodeId
})
.returning();
@@ -22,14 +22,46 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { LoadLoginPageResponse } from "@server/routers/loginPage/types";
const querySchema = z.object({
resourceId: z.coerce.number<number>().int().positive().optional(),
idpId: z.coerce.number<number>().int().positive().optional(),
orgId: z.string().min(1).optional(),
fullDomain: z.string().min(1)
});
const querySchema = z
.object({
resourceId: z.coerce.number<number>().int().positive().optional(),
idpId: z.coerce.number<number>().int().positive().optional(),
orgId: z.string().min(1).optional(),
fullDomain: z.string().min(1).optional()
})
.refine((data) => Boolean(data.fullDomain || data.orgId), {
error: "orgId or fullDomain is required"
});
async function query(
orgId: string | undefined,
fullDomain: string | undefined
) {
if (orgId && !fullDomain) {
const [res] = await db
.select()
.from(loginPageOrg)
.where(eq(loginPageOrg.orgId, orgId))
.innerJoin(
loginPage,
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
)
.limit(1);
if (!res) {
return null;
}
return {
...res.loginPage,
orgId: res.loginPageOrg.orgId
};
}
if (!fullDomain) {
return null;
}
async function query(orgId: string | undefined, fullDomain: string) {
if (!orgId) {
const [res] = await db
.select()
@@ -86,10 +86,11 @@ export async function upsertLoginPageBranding(
// Empty strings are transformed to null by the schema, which will clear the logo URL in the database
// We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates
if (
build !== "saas" &&
!config.getRawPrivateConfig().flags.use_org_only_idp
) {
const orgIdpMode =
config.getRawPrivateConfig().app.identity_provider_mode === "org" ||
config.getRawPrivateConfig().flags.use_org_only_idp === true;
if (build !== "saas" && !orgIdpMode) {
const { orgTitle, orgSubtitle, ...rest } = updateData;
updateData = rest;
}
@@ -21,7 +21,9 @@ export function requireOrgIdentityProviderMode(
_res: Response,
next: NextFunction
): void {
if (privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org") {
if (
privateConfig.getRawPrivateConfig().app.identity_provider_mode !== "org"
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
+9 -8
View File
@@ -69,7 +69,7 @@ import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { REGIONS } from "@server/db/regions";
import { localCache } from "#dynamic/lib/cache";
import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { eq } from "drizzle-orm";
import type ResponseT from "@server/types/MessageResponse";
@@ -859,7 +859,9 @@ export async function verifyResourceSession(
res,
forwardedUserData(
resource,
whitelistEmail ? { email: whitelistEmail } : undefined
whitelistEmail
? { email: whitelistEmail }
: undefined
),
dontStripSession
);
@@ -1131,12 +1133,11 @@ async function notAllowed(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(
// this is fine because the org login page is only a saas feature
const allowed = await isLicensedOrSubscribed(
orgId,
tierMatrix.loginPageDomain
);
if (subscribed) {
if (allowed) {
loginPage = await getOrgLoginPage(orgId);
}
}
@@ -1445,11 +1446,11 @@ async function headerAuthChallenged(
) {
let loginPage: LoginPage | null = null;
if (orgId) {
const subscribed = await isSubscribed(
const allowed = await isLicensedOrSubscribed(
orgId,
tierMatrix.loginPageDomain
); // this is fine because the org login page is only a saas feature
if (subscribed) {
);
if (allowed) {
loginPage = await getOrgLoginPage(orgId);
}
}
@@ -4,6 +4,7 @@ import { SettingsContainer } from "@app/components/Settings";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedSubscription } from "@app/lib/api/getCachedSubscription";
import { pullEnv } from "@app/lib/pullEnv";
import { build } from "@server/build";
import type { GetOrgTierResponse } from "@server/routers/billing/types";
import {
@@ -27,16 +28,22 @@ export default async function AuthPage(props: AuthPageProps) {
subscriptionStatus = subRes.data.data;
} catch {}
const env = pullEnv();
const orgLoginPagesEnabled =
(build === "enterprise" && env.app.identityProviderMode === "org") ||
build === "saas" ||
(build === "oss" && !env.flags.disableEnterpriseFeatures);
let loginPage: GetLoginPageResponse | null = null;
try {
if (build === "saas") {
if (orgLoginPagesEnabled) {
const res = await internal.get<AxiosResponse<GetLoginPageResponse>>(
`/org/${orgId}/login-page`,
await authCookieHeader()
);
if (res.status === 200) {
loginPage = res.data.data;
}
loginPage = res.data.data;
}
} catch (error) {}
@@ -52,7 +59,7 @@ export default async function AuthPage(props: AuthPageProps) {
return (
<SettingsContainer>
{build === "saas" && <AuthPageSettings loginPage={loginPage} />}
{orgLoginPagesEnabled && <AuthPageSettings loginPage={loginPage} />}
<AuthPageBrandingForm orgId={orgId} branding={loginPageBranding} />
</SettingsContainer>
);
+4 -1
View File
@@ -68,7 +68,10 @@ export default async function Page(props: {
if (loginPageDomain) {
const redirectUrl = searchParams.redirect as string | undefined;
let url = `https://${loginPageDomain}/auth/org`;
const scheme = env.app.dashboardUrl.startsWith("https")
? "https"
: "http";
let url = `${scheme}://${loginPageDomain}/auth/org`;
if (redirectUrl) {
url += `?redirect=${redirectUrl}`;
}
+16 -21
View File
@@ -133,30 +133,25 @@ export default async function OrgAuthPage(props: {
return <OrgSelectionForm />;
}
let loginIdps: LoginFormIDP[] = [];
if (build === "saas") {
const idpsRes = await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${loginPage.orgId}/idp`
);
const idpsRes = await priv.get<AxiosResponse<ListOrgIdpsResponse>>(
`/org/${loginPage.orgId}/idp`
);
loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
}
const loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId,
name: idp.name,
variant: idp.variant
})) as LoginFormIDP[];
let branding: LoadLoginPageBrandingResponse | null = null;
if (build === "saas") {
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${loginPage.orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
}
try {
const res = await priv.get<
AxiosResponse<LoadLoginPageBrandingResponse>
>(`/login-page-branding?orgId=${loginPage.orgId}`);
if (res.status === 200) {
branding = res.data.data;
}
} catch (error) {}
return (
<OrgLoginPage
+12 -2
View File
@@ -311,7 +311,12 @@ export default async function ResourceAuthPage(props: {
resourceId={authInfo.resourceId}
skipToIdpId={authInfo.skipToIdpId}
redirectUrl={postAuthRedirect}
orgId={build === "saas" ? authInfo.orgId : undefined}
orgId={
build === "saas" ||
env.app.identityProviderMode === "org"
? authInfo.orgId
: undefined
}
/>
);
}
@@ -350,7 +355,12 @@ export default async function ResourceAuthPage(props: {
}}
redirect={postAuthRedirect}
idps={loginIdps}
orgId={build === "saas" ? authInfo.orgId : undefined}
orgId={
build === "saas" ||
env.app.identityProviderMode === "org"
? authInfo.orgId
: undefined
}
branding={
!branding || build === "oss"
? undefined
+1 -1
View File
@@ -465,7 +465,7 @@ export default function AuthPageBrandingForm({
!isPaidUser(tierMatrix.loginPageBranding)
}
>
{t("saveAuthPageBranding")}
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
+1 -1
View File
@@ -458,7 +458,7 @@ function AuthPageSettings({
!isPaidUser(tierMatrix.loginPageDomain)
}
>
{t("saveAuthPageDomain")}
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
+6
View File
@@ -17,6 +17,7 @@ import { replacePlaceholder } from "@app/lib/replacePlaceholder";
import PoweredByPangolin from "@app/components/PoweredByPangolin";
import BrandedAuthSurface from "@app/components/BrandedAuthSurface";
import { getTranslations } from "next-intl/server";
import { headers } from "next/headers";
import { pullEnv } from "@app/lib/pullEnv";
type OrgLoginPageProps = {
@@ -52,6 +53,10 @@ export default async function OrgLoginPage({
}: OrgLoginPageProps) {
const env = pullEnv();
const t = await getTranslations();
const host = (await headers()).get("host");
const dashboardHost = env.app.dashboardUrl.split("//")[1];
const onDashboardHost = host === dashboardHost;
return (
<BrandedAuthSurface primaryColor={branding?.primaryColor ?? null}>
<PoweredByPangolin />
@@ -88,6 +93,7 @@ export default async function OrgLoginPage({
<IdpLoginButtons
idps={loginIdps}
orgId={loginPage?.orgId}
passOrgIdToOidcUrl={!onDashboardHost}
redirect={searchParams.redirect}
/>
) : (