/* 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));