344b1701dd
Uniform stock management for healthcare linen rooms. Licensed under the GNU AGPL v3.
89 lines
5.0 KiB
JavaScript
89 lines
5.0 KiB
JavaScript
/* A stand-in for the BoxyHQ Jackson broker, for scripts/e2e-sso.sh.
|
|
*
|
|
* node scripts/mock-jackson.cjs [port] (default 3199)
|
|
*
|
|
* Speaks just enough of Jackson's two surfaces for the product to complete a login:
|
|
* management POST/GET/DELETE /api/v1/sso (Api-Key checked; connections kept in memory)
|
|
* front door GET /api/oauth/authorize → 302 straight back to redirect_uri with ?code&state
|
|
* POST /api/oauth/token → { access_token }
|
|
* GET /api/oauth/userinfo → the profile for that token
|
|
* Who "signs in" is whatever the test last told it: POST /__mock/identity {email,name}. The code
|
|
* and token are single-use and bound to that identity, so a stale code fails as it would for real.
|
|
* Nothing here is a broker; it exists so the product's own routes can be exercised end to end. */
|
|
const http = require("http");
|
|
const crypto = require("crypto");
|
|
|
|
const PORT = Number(process.argv[2] || 3199);
|
|
const API_KEY = process.env.MOCK_JACKSON_KEY || "e2e-jackson-key";
|
|
const conns = new Map(); // tenant → { tenant, product, name, idpMetadata }
|
|
const codes = new Map(); // code → identity
|
|
const tokens = new Map(); // token → identity
|
|
let identity = { email: "", name: "" };
|
|
|
|
function send(res, status, body, headers = {}) {
|
|
res.writeHead(status, { "content-type": "application/json", ...headers });
|
|
res.end(body === undefined ? "" : JSON.stringify(body));
|
|
}
|
|
function readBody(req) {
|
|
return new Promise((resolve) => { let d = ""; req.on("data", (c) => (d += c)); req.on("end", () => resolve(d)); });
|
|
}
|
|
|
|
http.createServer(async (req, res) => {
|
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
|
const p = url.pathname;
|
|
|
|
if (p === "/__mock/identity" && req.method === "POST") {
|
|
identity = JSON.parse((await readBody(req)) || "{}");
|
|
return send(res, 200, { ok: true, identity });
|
|
}
|
|
if (p === "/__mock/connections") return send(res, 200, [...conns.values()]);
|
|
|
|
if (p === "/api/v1/sso") {
|
|
if (req.headers.authorization !== `Api-Key ${API_KEY}`) return send(res, 401, { error: { message: "Unauthorized" } });
|
|
if (req.method === "POST") {
|
|
const form = new URLSearchParams(await readBody(req));
|
|
const tenant = form.get("tenant"), product = form.get("product");
|
|
if (!tenant || !product) return send(res, 400, { error: { message: "tenant and product required" } });
|
|
if (!form.get("metadataUrl") && !form.get("encodedRawMetadata")) return send(res, 400, { error: { message: "Please provide metadata" } });
|
|
if (form.get("encodedRawMetadata") && !Buffer.from(form.get("encodedRawMetadata"), "base64").toString().includes("EntityDescriptor")) return send(res, 400, { error: { message: "Invalid IdP metadata" } });
|
|
const c = { tenant, product, name: form.get("name"), idpMetadata: { entityID: "https://idp.example/saml", provider: "idp.example" }, redirectUrl: JSON.parse(form.get("redirectUrl") || "[]") };
|
|
conns.set(tenant, c);
|
|
return send(res, 200, c);
|
|
}
|
|
const tenant = url.searchParams.get("tenant");
|
|
if (req.method === "GET") return send(res, 200, conns.has(tenant) ? [conns.get(tenant)] : []);
|
|
if (req.method === "DELETE") { const had = conns.delete(tenant); return send(res, had ? 200 : 404, had ? { ok: true } : { error: { message: "not found" } }); }
|
|
}
|
|
|
|
if (p === "/api/oauth/authorize") {
|
|
const tenant = url.searchParams.get("tenant");
|
|
const redirect = url.searchParams.get("redirect_uri"), state = url.searchParams.get("state");
|
|
const c = conns.get(tenant);
|
|
if (!c) return send(res, 404, { error: "no connection for tenant" });
|
|
if (!c.redirectUrl.includes(redirect)) return send(res, 400, { error: "redirect_uri not registered" });
|
|
const code = crypto.randomBytes(16).toString("hex");
|
|
codes.set(code, { ...identity });
|
|
// The product registers its PUBLIC callback (NEXT_PUBLIC_SITE_URL); the browser in these tests
|
|
// is talking to the local dev server, so the mock sends it back there on the registered path.
|
|
const back = (process.env.MOCK_CALLBACK_BASE || "http://127.0.0.1:3111") + new URL(redirect).pathname;
|
|
res.writeHead(302, { location: `${back}?code=${code}&state=${encodeURIComponent(state || "")}` });
|
|
return res.end();
|
|
}
|
|
if (p === "/api/oauth/token" && req.method === "POST") {
|
|
const form = new URLSearchParams(await readBody(req));
|
|
const id = codes.get(form.get("code"));
|
|
codes.delete(form.get("code"));
|
|
if (!id) return send(res, 400, { error: "invalid_grant" });
|
|
const t = crypto.randomBytes(16).toString("hex");
|
|
tokens.set(t, id);
|
|
return send(res, 200, { access_token: t, token_type: "bearer" });
|
|
}
|
|
if (p === "/api/oauth/userinfo") {
|
|
const t = (req.headers.authorization || "").replace(/^Bearer /, "");
|
|
const id = tokens.get(t);
|
|
if (!id) return send(res, 401, { error: "invalid_token" });
|
|
return send(res, 200, { email: id.email, name: id.name, id: id.email });
|
|
}
|
|
send(res, 404, { error: "not found" });
|
|
}).listen(PORT, "127.0.0.1", () => console.log("mock jackson on", PORT));
|