feat: add helpers and improve workspace auth flows

This commit is contained in:
2026-07-13 13:53:36 +05:30
parent 88e907783a
commit b4e5fade19
74 changed files with 853 additions and 131 deletions
+60
View File
@@ -0,0 +1,60 @@
# @wrnexus/helpers
Safe convenience helpers for common WrNexus application flows. The package uses
standard `Context`, `URL`, and `Response` values and has no runtime dependency beyond
`@wrnexus/core`.
## Installation
```bash
bun add @wrnexus/helpers
```
The package is private, so the machine must be authenticated to the `wrnexus` npm
organization.
## Forward-auth login redirects
The gateway calls an SSO verifier on a different URL from the original application.
These helpers reconstruct the original URL from the gateway headers and safely place it
in the login redirect:
```ts
import type { Context } from "@wrnexus/core";
import { redirectToLogin } from "@wrnexus/helpers";
export const GET = async (ctx: Context) => {
if (await hasValidSession(ctx)) {
return new Response(null, { status: 204 });
}
return redirectToLogin(ctx, "/login", {
allowedHosts: ["admin.localhost:3000", "reports.localhost:3000"],
});
};
```
This creates a response such as:
```text
Location: http://sso.localhost:3000/login?returnTo=http%3A%2F%2Fadmin.localhost%3A3000%2F
```
Always list the application hosts that are valid redirect destinations. Forwarded host
headers are rejected when `allowedHosts` is absent or does not match, preventing an open
redirect. A callback can support dynamic tenant domains:
```ts
allowedHosts: (host) => host.endsWith(".example.test");
```
## API
- `getOriginalRequestUrl(ctx, options): URL` — reconstruct the gateway URL.
- `getOriginalRequestOrigin(ctx, options): string` — return only its origin.
- `getOriginalRequestPath(ctx): string` — return its path and query string.
- `getOriginalRequestMethod(ctx): string` — return its HTTP method.
- `redirectToLogin(ctx, loginUrl, options): Response` — create a login redirect with an
encoded `returnTo` parameter.
For direct requests without gateway headers, URL helpers use `ctx.url`.
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@wrnexus/helpers",
"version": "0.2.17",
"private": true,
"type": "module",
"description": "Safe convenience helpers for WrNexus request contexts and common application flows.",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@wrnexus/core": "workspace:*"
}
}
+131
View File
@@ -0,0 +1,131 @@
/**
* @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";
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 {
const 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-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, 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);
}
+90
View File
@@ -0,0 +1,90 @@
import { expect, test } from "bun:test";
import { createContext } from "@wrnexus/core";
import {
getOriginalRequestMethod,
getOriginalRequestOrigin,
getOriginalRequestPath,
getOriginalRequestUrl,
redirectToLogin,
} from "../src/index.ts";
function context(url: string, headers: HeadersInit = {}) {
const parsed = new URL(url);
return createContext(new Request(parsed, { headers }), parsed);
}
test("uses the direct context URL when no gateway headers exist", () => {
const ctx = context("https://app.example.test/account?tab=security");
expect(getOriginalRequestUrl(ctx).href).toBe("https://app.example.test/account?tab=security");
expect(getOriginalRequestOrigin(ctx)).toBe("https://app.example.test");
expect(getOriginalRequestPath(ctx)).toBe("/account?tab=security");
expect(getOriginalRequestMethod(ctx)).toBe("GET");
});
test("reconstructs an allowed original gateway URL", () => {
const ctx = context("http://sso.localhost:3000/api/verify", {
"x-forwarded-host": "admin.localhost:3000",
"x-forwarded-proto": "http",
"x-original-method": "GET",
"x-original-uri": "/users?page=2",
});
const url = getOriginalRequestUrl(ctx, { allowedHosts: ["admin.localhost:3000"] });
expect(url.href).toBe("http://admin.localhost:3000/users?page=2");
expect(getOriginalRequestMethod(ctx)).toBe("GET");
});
test("rejects untrusted hosts and unsafe request paths", () => {
const untrusted = context("http://sso.localhost/api/verify", {
"x-forwarded-host": "evil.example",
"x-original-uri": "/",
});
const unsafePath = context("http://sso.localhost/api/verify", {
"x-forwarded-host": "admin.localhost",
"x-original-uri": "//evil.example/steal",
});
expect(() => getOriginalRequestUrl(untrusted)).toThrow("Untrusted forwarded host");
expect(() => getOriginalRequestUrl(unsafePath, { allowedHosts: ["admin.localhost"] })).toThrow(
"absolute request path",
);
});
test("creates a safe login redirect with an encoded returnTo URL", () => {
const ctx = context("http://sso.localhost:3000/api/verify", {
"x-forwarded-host": "admin.localhost:3000",
"x-forwarded-proto": "http",
"x-original-uri": "/reports?range=week",
});
const response = redirectToLogin(ctx, "/login", {
allowedHosts: new Set(["admin.localhost:3000"]),
});
const location = new URL(response.headers.get("location")!);
expect(response.status).toBe(302);
expect(location.origin).toBe("http://sso.localhost:3000");
expect(location.pathname).toBe("/login");
expect(location.searchParams.get("returnTo")).toBe(
"http://admin.localhost:3000/reports?range=week",
);
});
test("supports an allowed-host callback and custom response options", () => {
const ctx = context("https://login.example.test/api/verify", {
"x-forwarded-host": "reports.example.test",
"x-forwarded-proto": "https",
"x-original-uri": "/",
});
const response = redirectToLogin(ctx, "https://login.example.test/sign-in?tenant=acme", {
allowedHosts: (host) => host.endsWith(".example.test"),
returnToParam: "next",
status: 303,
});
const location = new URL(response.headers.get("location")!);
expect(response.status).toBe(303);
expect(location.searchParams.get("tenant")).toBe("acme");
expect(location.searchParams.get("next")).toBe("https://reports.example.test/");
});