From f527c3092370ca01cdd367bf7af3d8cf50a41a16 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 12 Feb 2026 14:21:43 -0800 Subject: [PATCH] add post auth url --- package.json | 2 +- server/db/pg/schema/schema.ts | 3 ++- server/db/sqlite/schema/schema.ts | 3 ++- server/lib/normalizePostAuthPath.ts | 18 ++++++++++++++++++ server/routers/resource/authWithAccessToken.ts | 9 ++++++++- server/routers/resource/createResource.ts | 8 +++++--- server/routers/resource/getResourceAuthInfo.ts | 4 +++- server/routers/resource/updateResource.ts | 3 ++- src/app/auth/resource/[resourceGuid]/page.tsx | 6 ++++++ 9 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 server/lib/normalizePostAuthPath.ts diff --git a/package.json b/package.json index 61dc023e..5cb205ce 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "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", "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: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", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 3c957470..dc6f3758 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -142,7 +142,8 @@ export const resources = pgTable("resources", { }).default("forced"), // "forced" = always show, "automatic" = only when down maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), - maintenanceEstimatedTime: text("maintenanceEstimatedTime") + maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + postAuthPath: text("postAuthPath") }); export const targets = pgTable("targets", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4137db3c..42b2309b 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -162,7 +162,8 @@ export const resources = sqliteTable("resources", { }).default("forced"), // "forced" = always show, "automatic" = only when down maintenanceTitle: text("maintenanceTitle"), maintenanceMessage: text("maintenanceMessage"), - maintenanceEstimatedTime: text("maintenanceEstimatedTime") + maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + postAuthPath: text("postAuthPath") }); export const targets = sqliteTable("targets", { diff --git a/server/lib/normalizePostAuthPath.ts b/server/lib/normalizePostAuthPath.ts new file mode 100644 index 00000000..7291f184 --- /dev/null +++ b/server/lib/normalizePostAuthPath.ts @@ -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}`; +} diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 53f72cb2..a580ee40 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke import config from "@server/lib/config"; import stoi from "@server/lib/stoi"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; +import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath"; const authWithAccessTokenBodySchema = z.strictObject({ accessToken: z.string(), @@ -164,10 +165,16 @@ export async function authWithAccessToken( requestIp: req.ip }); + let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + const postAuthPath = normalizePostAuthPath(resource.postAuthPath); + if (postAuthPath) { + redirectUrl = redirectUrl + postAuthPath; + } + return response(res, { data: { session: token, - redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + redirectUrl }, success: true, error: false, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index ba1fdba2..232cea26 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -36,7 +36,8 @@ const createHttpResourceSchema = z http: z.boolean(), protocol: z.enum(["tcp", "udp"]), domainId: z.string(), - stickySession: z.boolean().optional() + stickySession: z.boolean().optional(), + postAuthPath: z.string().nullable().optional() }) .refine( (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 stickySession = parsedBody.data.stickySession; @@ -255,7 +256,8 @@ async function createHttpResource( http: true, protocol: "tcp", ssl: true, - stickySession: stickySession + stickySession: stickySession, + postAuthPath: postAuthPath }) .returning(); diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 7959bff5..7def75d5 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -35,6 +35,7 @@ export type GetResourceAuthInfoResponse = { whitelist: boolean; skipToIdpId: number | null; orgId: string; + postAuthPath: string | null; }; export async function getResourceAuthInfo( @@ -147,7 +148,8 @@ export async function getResourceAuthInfo( url, whitelist: resource.emailWhitelistEnabled, skipToIdpId: resource.skipToIdpId, - orgId: resource.orgId + orgId: resource.orgId, + postAuthPath: resource.postAuthPath ?? null }, success: true, error: false, diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 79b59a2a..84b4f538 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -55,7 +55,8 @@ const updateHttpResourceBodySchema = z maintenanceModeType: z.enum(["forced", "automatic"]).optional(), maintenanceTitle: z.string().max(255).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, { error: "At least one field must be provided for update" diff --git a/src/app/auth/resource/[resourceGuid]/page.tsx b/src/app/auth/resource/[resourceGuid]/page.tsx index 5bb431a8..919dfbd8 100644 --- a/src/app/auth/resource/[resourceGuid]/page.tsx +++ b/src/app/auth/resource/[resourceGuid]/page.tsx @@ -26,6 +26,7 @@ import type { import { CheckOrgUserAccessResponse } from "@server/routers/org"; import OrgPolicyRequired from "@app/components/OrgPolicyRequired"; import { isOrgSubscribed } from "@app/lib/api/isOrgSubscribed"; +import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath"; export const dynamic = "force-dynamic"; @@ -108,6 +109,11 @@ export default async function ResourceAuthPage(props: { } catch (e) {} } + const normalizedPostAuthPath = normalizePostAuthPath(authInfo.postAuthPath); + if (normalizedPostAuthPath) { + redirectUrl = new URL(authInfo.url).origin + normalizedPostAuthPath; + } + const hasAuth = authInfo.password || authInfo.pincode ||