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
+13
View File
@@ -176,6 +176,19 @@ interface GatewaySecurity {
}
```
Forward auth is a verification hook, not a login page. Configure `forward.url` with a
dedicated endpoint such as `http://sso.localhost:3000/api/verify`. The gateway forwards
the request's `Cookie` and `Authorization` headers plus `X-Forwarded-Host`,
`X-Forwarded-Proto`, `X-Original-Method`, and `X-Original-Uri` (including its query
string). The verifier must return 2xx only for an authenticated session and 401/403
otherwise. Pointing forward auth at an SSO home page that always returns 200 allows
every request and does not implement SSO.
For browser SSO, the verifier may return a `302`/`303`/`307`/`308` with a `Location`
header pointing to its login page. The gateway passes that redirect to the browser. The
login flow should validate a signed `returnTo` value before redirecting back; API clients
should receive `401`/`403` instead of an HTML login redirect.
Open the gateway URL (normally `http://127.0.0.1:3000`), not an app's internal
port. The gateway exposes `/__gateway/health` (JSON list of routed apps) and returns a
`RunningGateway` (`{ port, url, stop() }`). Use `--host=0.0.0.0` when other devices need
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/dev-server",
"version": "0.2.16",
"version": "0.2.17",
"type": "module",
"main": "src/index.ts",
"exports": {
+31 -8
View File
@@ -103,6 +103,35 @@ function timingSafeEqual(a: string, b: string): boolean {
return diff === 0;
}
/** Preserve an intentional verifier redirect while keeping other failures opaque. */
export function forwardAuthFailure(res: Response, verifierUrl: string): Response {
const location = res.headers.get("location");
if (res.status >= 300 && res.status < 400 && location) {
try {
const redirect = new URL(location, verifierUrl);
if (redirect.protocol === "http:" || redirect.protocol === "https:") {
return new Response(null, { status: res.status, headers: { location: redirect.href } });
}
} catch {
// Malformed or unsafe redirects fail closed below.
}
}
return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
}
/** Describe the original gateway request to a forward-auth verifier. */
export function forwardAuthHeaders(req: Request): Headers {
const original = new URL(req.url);
return new Headers({
cookie: req.headers.get("cookie") ?? "",
authorization: req.headers.get("authorization") ?? "",
"x-forwarded-host": req.headers.get("host") ?? original.host,
"x-forwarded-proto": original.protocol.replace(":", ""),
"x-original-method": req.method,
"x-original-uri": `${original.pathname}${original.search}`,
});
}
/**
* Enforce a per-app auth policy. Returns a Response to block, or null to allow.
* `ip` is the client address (for the IP allowlist).
@@ -140,16 +169,10 @@ async function checkAuth(
if (auth.forward) {
try {
const res = await fetch(auth.forward.url, {
headers: {
cookie: req.headers.get("cookie") ?? "",
authorization: req.headers.get("authorization") ?? "",
"x-forwarded-host": req.headers.get("host") ?? "",
"x-original-uri": new URL(req.url).pathname,
},
headers: forwardAuthHeaders(req),
redirect: "manual",
});
if (!res.ok)
return new Response("Unauthorized", { status: res.status === 200 ? 401 : res.status });
if (!res.ok) return forwardAuthFailure(res, auth.forward.url);
} catch {
return new Response("Auth service unavailable", { status: 503 });
}
+40 -1
View File
@@ -1,5 +1,10 @@
import { expect, test } from "bun:test";
import { defaultGatewayHostname, gatewayProxyHeaders } from "../src/gateway.ts";
import {
defaultGatewayHostname,
forwardAuthFailure,
forwardAuthHeaders,
gatewayProxyHeaders,
} from "../src/gateway.ts";
test("gateway uses platform-safe hostname defaults", () => {
expect(defaultGatewayHostname("development")).toBe("127.0.0.1");
@@ -17,3 +22,37 @@ test("gateway disables compression for its internal proxy hop", () => {
expect(headers.get("x-forwarded-proto")).toBe("http");
expect(headers.get("x-forwarded-for")).toBe("127.0.0.1");
});
test("forward auth preserves intentional verifier redirects", () => {
const redirected = forwardAuthFailure(
new Response(null, { status: 302, headers: { location: "/login?returnTo=%2Fadmin" } }),
"http://sso.localhost:3000/api/verify",
);
const denied = forwardAuthFailure(new Response(null, { status: 401 }), "http://sso.localhost");
expect(redirected.status).toBe(302);
expect(redirected.headers.get("location")).toBe(
"http://sso.localhost:3000/login?returnTo=%2Fadmin",
);
expect(denied.status).toBe(401);
expect(denied.headers.has("location")).toBe(false);
});
test("forward auth describes the original gateway request", () => {
const headers = forwardAuthHeaders(
new Request("https://admin.example.test/settings?tab=security", {
headers: {
host: "admin.example.test",
cookie: "session=abc",
authorization: "Bearer token",
},
}),
);
expect(headers.get("x-forwarded-host")).toBe("admin.example.test");
expect(headers.get("x-forwarded-proto")).toBe("https");
expect(headers.get("x-original-method")).toBe("GET");
expect(headers.get("x-original-uri")).toBe("/settings?tab=security");
expect(headers.get("cookie")).toBe("session=abc");
expect(headers.get("authorization")).toBe("Bearer token");
});