add post auth url

This commit is contained in:
miloschwartz
2026-02-12 14:21:43 -08:00
parent 94e70219cf
commit f527c30923
9 changed files with 47 additions and 9 deletions

View File

@@ -15,7 +15,7 @@
"dev:check": "npx tsc --noEmit && npm run format:check", "dev:check": "npx tsc --noEmit && npm run format:check",
"dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push", "dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push",
"db:generate": "drizzle-kit generate --config=./drizzle.config.ts", "db:generate": "drizzle-kit generate --config=./drizzle.config.ts",
"db:push": "npx tsx server/db/pg/migrate.ts", "db:push": "npx tsx server/db/migrate.ts",
"db:studio": "drizzle-kit studio --config=./drizzle.config.ts", "db:studio": "drizzle-kit studio --config=./drizzle.config.ts",
"db:clear-migrations": "rm -rf server/migrations", "db:clear-migrations": "rm -rf server/migrations",
"set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json", "set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json",

View File

@@ -142,7 +142,8 @@ export const resources = pgTable("resources", {
}).default("forced"), // "forced" = always show, "automatic" = only when down }).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"), maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"), maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime") maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
}); });
export const targets = pgTable("targets", { export const targets = pgTable("targets", {

View File

@@ -162,7 +162,8 @@ export const resources = sqliteTable("resources", {
}).default("forced"), // "forced" = always show, "automatic" = only when down }).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"), maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"), maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime") maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath")
}); });
export const targets = sqliteTable("targets", { export const targets = sqliteTable("targets", {

View File

@@ -0,0 +1,18 @@
/**
* Normalizes a post-authentication path for safe use when building redirect URLs.
* Returns a path that starts with / and does not allow open redirects (no //, no :).
*/
export function normalizePostAuthPath(path: string | null | undefined): string | null {
if (path == null || typeof path !== "string") {
return null;
}
const trimmed = path.trim();
if (trimmed === "") {
return null;
}
// Reject protocol-relative (//) or scheme (:) to avoid open redirect
if (trimmed.includes("//") || trimmed.includes(":")) {
return null;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}

View File

@@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke
import config from "@server/lib/config"; import config from "@server/lib/config";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
const authWithAccessTokenBodySchema = z.strictObject({ const authWithAccessTokenBodySchema = z.strictObject({
accessToken: z.string(), accessToken: z.string(),
@@ -164,10 +165,16 @@ export async function authWithAccessToken(
requestIp: req.ip requestIp: req.ip
}); });
let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
const postAuthPath = normalizePostAuthPath(resource.postAuthPath);
if (postAuthPath) {
redirectUrl = redirectUrl + postAuthPath;
}
return response<AuthWithAccessTokenResponse>(res, { return response<AuthWithAccessTokenResponse>(res, {
data: { data: {
session: token, session: token,
redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` redirectUrl
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -36,7 +36,8 @@ const createHttpResourceSchema = z
http: z.boolean(), http: z.boolean(),
protocol: z.enum(["tcp", "udp"]), protocol: z.enum(["tcp", "udp"]),
domainId: z.string(), domainId: z.string(),
stickySession: z.boolean().optional() stickySession: z.boolean().optional(),
postAuthPath: z.string().nullable().optional()
}) })
.refine( .refine(
(data) => { (data) => {
@@ -188,7 +189,7 @@ async function createHttpResource(
); );
} }
const { name, domainId } = parsedBody.data; const { name, domainId, postAuthPath } = parsedBody.data;
const subdomain = parsedBody.data.subdomain; const subdomain = parsedBody.data.subdomain;
const stickySession = parsedBody.data.stickySession; const stickySession = parsedBody.data.stickySession;
@@ -255,7 +256,8 @@ async function createHttpResource(
http: true, http: true,
protocol: "tcp", protocol: "tcp",
ssl: true, ssl: true,
stickySession: stickySession stickySession: stickySession,
postAuthPath: postAuthPath
}) })
.returning(); .returning();

View File

@@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = {
whitelist: boolean; whitelist: boolean;
skipToIdpId: number | null; skipToIdpId: number | null;
orgId: string; orgId: string;
postAuthPath: string | null;
}; };
export async function getResourceAuthInfo( export async function getResourceAuthInfo(
@@ -147,7 +148,8 @@ export async function getResourceAuthInfo(
url, url,
whitelist: resource.emailWhitelistEnabled, whitelist: resource.emailWhitelistEnabled,
skipToIdpId: resource.skipToIdpId, skipToIdpId: resource.skipToIdpId,
orgId: resource.orgId orgId: resource.orgId,
postAuthPath: resource.postAuthPath ?? null
}, },
success: true, success: true,
error: false, error: false,

View File

@@ -55,7 +55,8 @@ const updateHttpResourceBodySchema = z
maintenanceModeType: z.enum(["forced", "automatic"]).optional(), maintenanceModeType: z.enum(["forced", "automatic"]).optional(),
maintenanceTitle: z.string().max(255).nullable().optional(), maintenanceTitle: z.string().max(255).nullable().optional(),
maintenanceMessage: z.string().max(2000).nullable().optional(), maintenanceMessage: z.string().max(2000).nullable().optional(),
maintenanceEstimatedTime: z.string().max(100).nullable().optional() maintenanceEstimatedTime: z.string().max(100).nullable().optional(),
postAuthPath: z.string().nullable().optional()
}) })
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
error: "At least one field must be provided for update" error: "At least one field must be provided for update"

View File

@@ -26,6 +26,7 @@ import type {
import { CheckOrgUserAccessResponse } from "@server/routers/org"; import { CheckOrgUserAccessResponse } from "@server/routers/org";
import OrgPolicyRequired from "@app/components/OrgPolicyRequired"; import OrgPolicyRequired from "@app/components/OrgPolicyRequired";
import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed";
import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -108,6 +109,11 @@ export default async function ResourceAuthPage(props: {
} catch (e) {} } catch (e) {}
} }
const normalizedPostAuthPath = normalizePostAuthPath(authInfo.postAuthPath);
if (normalizedPostAuthPath) {
redirectUrl = new URL(authInfo.url).origin + normalizedPostAuthPath;
}
const hasAuth = const hasAuth =
authInfo.password || authInfo.password ||
authInfo.pincode || authInfo.pincode ||