From d25181118c7e8a0c1d7a18064314a14d9ce59a28 Mon Sep 17 00:00:00 2001 From: Milo Schwartz Date: Wed, 21 Oct 2026 18:07:31 -0400 Subject: [PATCH] make auth page custom domains available in ee (#3957) --- .../private/lib/traefik/getTraefikConfig.ts | 22 +++++-- .../routers/loginPage/createLoginPage.ts | 66 +++++++++++-------- .../routers/loginPage/loadLoginPage.ts | 46 +++++++++++-- .../loginPage/upsertLoginPageBranding.ts | 9 +-- .../orgIdp/requireOrgIdentityProviderMode.ts | 4 +- server/routers/badger/verifySession.ts | 17 ++--- .../settings/general/auth-page/page.tsx | 13 +++- src/app/auth/login/page.tsx | 5 +- src/app/auth/org/page.tsx | 37 +++++------ src/app/auth/resource/[resourceGuid]/page.tsx | 14 +++- src/components/AuthPageBrandingForm.tsx | 2 +- src/components/AuthPageSettings.tsx | 2 +- src/components/OrgLoginPage.tsx | 6 ++ 13 files changed, 163 insertions(+), 80 deletions(-) diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 77b099c2b..73fc9c067 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -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(); 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 = {}; + 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( diff --git a/server/private/routers/loginPage/createLoginPage.ts b/server/private/routers/loginPage/createLoginPage.ts index 6336d8e76..5dd609cf6 100644 --- a/server/private/routers/loginPage/createLoginPage.ts +++ b/server/private/routers/loginPage/createLoginPage.ts @@ -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(); diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 3b9e3e014..d90189592 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -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().int().positive().optional(), - idpId: z.coerce.number().int().positive().optional(), - orgId: z.string().min(1).optional(), - fullDomain: z.string().min(1) -}); +const querySchema = z + .object({ + resourceId: z.coerce.number().int().positive().optional(), + idpId: z.coerce.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() diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index 373f9b3c6..c5d3ffe2b 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -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; } diff --git a/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts index 7942af123..61e4dc012 100644 --- a/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts +++ b/server/private/routers/orgIdp/requireOrgIdentityProviderMode.ts @@ -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, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 748164dc8..a981715f6 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -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); } } diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx index 7712334f1..40de99a0c 100644 --- a/src/app/[orgId]/settings/general/auth-page/page.tsx +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -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>( `/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 ( - {build === "saas" && } + {orgLoginPagesEnabled && } ); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index dcf90d826..b6d6f2467 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -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}`; } diff --git a/src/app/auth/org/page.tsx b/src/app/auth/org/page.tsx index 77aec68d9..ed88d14c3 100644 --- a/src/app/auth/org/page.tsx +++ b/src/app/auth/org/page.tsx @@ -133,30 +133,25 @@ export default async function OrgAuthPage(props: { return ; } - let loginIdps: LoginFormIDP[] = []; - if (build === "saas") { - const idpsRes = await priv.get>( - `/org/${loginPage.orgId}/idp` - ); + const idpsRes = await priv.get>( + `/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 - >(`/login-page-branding?orgId=${loginPage.orgId}`); - if (res.status === 200) { - branding = res.data.data; - } - } catch (error) {} - } + try { + const res = await priv.get< + AxiosResponse + >(`/login-page-branding?orgId=${loginPage.orgId}`); + if (res.status === 200) { + branding = res.data.data; + } + } catch (error) {} return ( ); } @@ -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 diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index b46699311..e2cbf6d6d 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -465,7 +465,7 @@ export default function AuthPageBrandingForm({ !isPaidUser(tierMatrix.loginPageBranding) } > - {t("saveAuthPageBranding")} + {t("saveSettings")} diff --git a/src/components/AuthPageSettings.tsx b/src/components/AuthPageSettings.tsx index d5c75d69e..f9b6d6b68 100644 --- a/src/components/AuthPageSettings.tsx +++ b/src/components/AuthPageSettings.tsx @@ -458,7 +458,7 @@ function AuthPageSettings({ !isPaidUser(tierMatrix.loginPageDomain) } > - {t("saveAuthPageDomain")} + {t("saveSettings")} diff --git a/src/components/OrgLoginPage.tsx b/src/components/OrgLoginPage.tsx index d70c278cf..6b696023a 100644 --- a/src/components/OrgLoginPage.tsx +++ b/src/components/OrgLoginPage.tsx @@ -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 ( @@ -88,6 +93,7 @@ export default async function OrgLoginPage({ ) : (