160 lines
5.2 KiB
TypeScript
160 lines
5.2 KiB
TypeScript
/**
|
|
* @wrnexus/helpers — safe conveniences for common WrNexus application flows.
|
|
*
|
|
* Helpers stay small and composable. They accept the standard WrNexus Context
|
|
* and return web-platform values such as URL and Response.
|
|
*/
|
|
|
|
import type { Context } from "@wrnexus/core";
|
|
import { currentAppOrigin } from "./workspace.ts";
|
|
|
|
export type RequestContext = Pick<Context, "req" | "url">;
|
|
|
|
export type AllowedHosts =
|
|
readonly string[] | ReadonlySet<string> | ((host: string, ctx: RequestContext) => boolean);
|
|
|
|
export interface OriginalRequestOptions {
|
|
/**
|
|
* Hosts that the application permits as redirect destinations. This is
|
|
* required when a proxy supplied X-Forwarded-Host is present.
|
|
*/
|
|
allowedHosts?: AllowedHosts;
|
|
}
|
|
|
|
export interface LoginRedirectOptions extends OriginalRequestOptions {
|
|
/** Query parameter that receives the original absolute URL. */
|
|
returnToParam?: string;
|
|
/** Browser redirect status. Defaults to 302. */
|
|
status?: 301 | 302 | 303 | 307 | 308;
|
|
}
|
|
|
|
function forwardedValue(ctx: RequestContext, name: string): string | null {
|
|
const value = ctx.req.headers.get(name)?.trim();
|
|
if (!value) return null;
|
|
if (value.includes(",")) {
|
|
throw new TypeError(`${name} must contain exactly one value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function hostIsAllowed(host: string, ctx: RequestContext, allowedHosts?: AllowedHosts): boolean {
|
|
if (!allowedHosts) return false;
|
|
if (typeof allowedHosts === "function") return allowedHosts(host, ctx);
|
|
|
|
const normalized = host.toLowerCase();
|
|
for (const allowed of allowedHosts) {
|
|
if (allowed.toLowerCase() === normalized) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** Get the original path and query string seen by the gateway. */
|
|
export function getOriginalRequestPath(ctx: RequestContext): string {
|
|
const value = forwardedValue(ctx, "x-original-uri") ?? `${ctx.url.pathname}${ctx.url.search}`;
|
|
if (
|
|
!value.startsWith("/") ||
|
|
value.startsWith("//") ||
|
|
value.includes("\\") ||
|
|
value.includes("#")
|
|
) {
|
|
throw new TypeError("x-original-uri must be an absolute request path");
|
|
}
|
|
return value;
|
|
}
|
|
|
|
/** Get the original HTTP method seen by the gateway. */
|
|
export function getOriginalRequestMethod(ctx: RequestContext): string {
|
|
const method = forwardedValue(ctx, "x-original-method") ?? ctx.req.method;
|
|
if (!/^[A-Za-z]+$/.test(method)) throw new TypeError("x-original-method is invalid");
|
|
return method.toUpperCase();
|
|
}
|
|
|
|
/**
|
|
* Reconstruct the absolute URL that reached the gateway.
|
|
*
|
|
* Forwarded hosts are never trusted implicitly: pass allowedHosts when this is
|
|
* used behind the WrNexus gateway. Direct requests fall back to ctx.url.
|
|
*/
|
|
export function getOriginalRequestUrl(
|
|
ctx: RequestContext,
|
|
options: OriginalRequestOptions = {},
|
|
): URL {
|
|
// A forward-auth verifier can itself sit behind the same gateway. In that
|
|
// nested hop X-Forwarded-Host correctly describes the verifier (SSO), while
|
|
// X-Original-Host keeps the protected application's host (for returnTo).
|
|
const host = forwardedValue(ctx, "x-original-host") ?? forwardedValue(ctx, "x-forwarded-host");
|
|
const path = getOriginalRequestPath(ctx);
|
|
|
|
if (!host) return new URL(path, ctx.url.origin);
|
|
if (!hostIsAllowed(host, ctx, options.allowedHosts)) {
|
|
throw new TypeError(`Untrusted forwarded host: ${host}`);
|
|
}
|
|
|
|
const protocol = (
|
|
forwardedValue(ctx, "x-original-proto") ??
|
|
forwardedValue(ctx, "x-forwarded-proto") ??
|
|
ctx.url.protocol
|
|
).replace(/:$/, "");
|
|
if (protocol !== "http" && protocol !== "https") {
|
|
throw new TypeError(`Unsupported forwarded protocol: ${protocol}`);
|
|
}
|
|
|
|
const origin = new URL(`${protocol}://${host}`);
|
|
if (origin.username || origin.password || origin.pathname !== "/") {
|
|
throw new TypeError(`Invalid forwarded host: ${host}`);
|
|
}
|
|
|
|
const original = new URL(path, origin);
|
|
if (original.origin !== origin.origin) {
|
|
throw new TypeError("Original request URL must stay on the forwarded origin");
|
|
}
|
|
return original;
|
|
}
|
|
|
|
/** Get the original request origin, for example http://admin.localhost:3000. */
|
|
export function getOriginalRequestOrigin(
|
|
ctx: RequestContext,
|
|
options: OriginalRequestOptions = {},
|
|
): string {
|
|
return getOriginalRequestUrl(ctx, options).origin;
|
|
}
|
|
|
|
/**
|
|
* Redirect to a login page with the original absolute URL encoded as returnTo.
|
|
* Relative login URLs resolve against the current app (normally the SSO app).
|
|
*/
|
|
export function redirectToLogin(
|
|
ctx: RequestContext,
|
|
loginUrl: string | URL,
|
|
options: LoginRedirectOptions = {},
|
|
): Response {
|
|
const target = new URL(loginUrl, `${currentAppOrigin() ?? ctx.url.origin}/`);
|
|
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
|
throw new TypeError("Login URL must use http or https");
|
|
}
|
|
|
|
const original = getOriginalRequestUrl(ctx, options);
|
|
target.searchParams.set(options.returnToParam ?? "returnTo", original.href);
|
|
return Response.redirect(target, options.status ?? 302);
|
|
}
|
|
|
|
export {
|
|
appOrigin,
|
|
appUrl,
|
|
currentAppName,
|
|
currentAppOrigin,
|
|
workspaceAppOrigins,
|
|
workspaceRootDomain,
|
|
} from "./workspace.ts";
|
|
export {
|
|
backoffDelay,
|
|
sleep,
|
|
retry,
|
|
withTimeout,
|
|
stableStringify,
|
|
safeJsonParse,
|
|
clamp,
|
|
once,
|
|
} from "./resilience.ts";
|
|
export type { RetryOptions } from "./resilience.ts";
|