mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-22 04:29:05 +02:00
make auth page custom domains available in ee (#3957)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -465,7 +465,7 @@ export default function AuthPageBrandingForm({
|
||||
!isPaidUser(tierMatrix.loginPageBranding)
|
||||
}
|
||||
>
|
||||
{t("saveAuthPageBranding")}
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -458,7 +458,7 @@ function AuthPageSettings({
|
||||
!isPaidUser(tierMatrix.loginPageDomain)
|
||||
}
|
||||
>
|
||||
{t("saveAuthPageDomain")}
|
||||
{t("saveSettings")}
|
||||
</Button>
|
||||
</SettingsSectionFooter>
|
||||
</SettingsSection>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user