147 lines
5.3 KiB
TypeScript
147 lines
5.3 KiB
TypeScript
import { expect, test } from "bun:test";
|
|
import { createContext } from "@wrnexus/core";
|
|
import {
|
|
getOriginalRequestMethod,
|
|
getOriginalRequestOrigin,
|
|
getOriginalRequestPath,
|
|
getOriginalRequestUrl,
|
|
redirectToLogin,
|
|
stableStringify,
|
|
} 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("keeps the protected app URL when the SSO verifier crosses the gateway again", () => {
|
|
const ctx = context("http://sso.localhost:3000/api/verify", {
|
|
"x-forwarded-host": "sso.localhost:3000",
|
|
"x-forwarded-proto": "http",
|
|
"x-original-host": "admin.localhost:3000",
|
|
"x-original-proto": "http",
|
|
"x-original-method": "GET",
|
|
"x-original-uri": "/settings?tab=security",
|
|
});
|
|
|
|
const response = redirectToLogin(ctx, "/login", {
|
|
allowedHosts: ["admin.localhost:3000"],
|
|
});
|
|
const location = new URL(response.headers.get("location")!);
|
|
|
|
expect(location.origin).toBe("http://sso.localhost:3000");
|
|
expect(location.pathname).toBe("/login");
|
|
expect(location.searchParams.get("returnTo")).toBe(
|
|
"http://admin.localhost:3000/settings?tab=security",
|
|
);
|
|
});
|
|
|
|
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("relative login redirects use the configured public app origin", () => {
|
|
const previous = process.env.WRNEXUS_APP_ORIGIN;
|
|
process.env.WRNEXUS_APP_ORIGIN = "https://sso.example.test";
|
|
try {
|
|
const ctx = context("http://127.0.0.1:3002/api/verify", {
|
|
"x-original-host": "admin.example.test",
|
|
"x-original-proto": "https",
|
|
"x-original-uri": "/settings",
|
|
});
|
|
const response = redirectToLogin(ctx, "/sign-in", {
|
|
allowedHosts: ["admin.example.test"],
|
|
});
|
|
const location = new URL(response.headers.get("location")!);
|
|
|
|
expect(location.origin).toBe("https://sso.example.test");
|
|
expect(location.pathname).toBe("/sign-in");
|
|
expect(location.searchParams.get("returnTo")).toBe("https://admin.example.test/settings");
|
|
} finally {
|
|
if (previous === undefined) delete process.env.WRNEXUS_APP_ORIGIN;
|
|
else process.env.WRNEXUS_APP_ORIGIN = previous;
|
|
}
|
|
});
|
|
|
|
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/");
|
|
});
|
|
|
|
test("stableStringify permits repeated references but rejects cycles", () => {
|
|
const shared = { value: 1 };
|
|
expect(stableStringify({ second: shared, first: shared })).toBe(
|
|
'{"first":{"value":1},"second":{"value":1}}',
|
|
);
|
|
const circular: { self?: unknown } = {};
|
|
circular.self = circular;
|
|
expect(() => stableStringify(circular)).toThrow("circular");
|
|
});
|