Nicolas Cantu 0af507143a Add smart-ide-global API layer, SSO delegates proxy, .logs access logs
- New smart-ide-global-api (127.0.0.1:37149): internal bearer, upstream proxy, X-OIDC forward
- SSO gateway calls global API with GLOBAL_API_INTERNAL_TOKEN; logs to .logs/sso-gateway/
- Aggregated config example, docs, VERSION 0.0.2, claw proxy local URL hint
2026-04-03 23:08:52 +02:00

272 lines
7.8 KiB
TypeScript

import * as http from "node:http";
import type { JWTPayload } from "jose";
import { appendSsoAccessLog } from "./accessLog.js";
import { discoverJwksUri, createVerify, type VerifyFn } from "./oidc.js";
import { listUpstreamKeys } from "./upstreams.js";
const HOST = process.env.SSO_GATEWAY_HOST ?? "127.0.0.1";
const PORT = Number(process.env.SSO_GATEWAY_PORT ?? "37148");
const MAX_BODY_BYTES = Number(process.env.SSO_GATEWAY_MAX_BODY_BYTES ?? "33554432");
const CORS_ORIGIN = process.env.SSO_CORS_ORIGIN?.trim() ?? "";
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
const globalApiBase = (): string =>
trimSlash(process.env.GLOBAL_API_URL ?? "http://127.0.0.1:37149");
const globalApiToken = (): string => process.env.GLOBAL_API_INTERNAL_TOKEN?.trim() ?? "";
const corsHeaders = (): Record<string, string> => {
if (!CORS_ORIGIN) {
return {};
}
return {
"Access-Control-Allow-Origin": CORS_ORIGIN,
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Authorization, Content-Type",
"Access-Control-Max-Age": "86400",
};
};
const applyCors = (res: http.ServerResponse): void => {
const h = corsHeaders();
for (const [k, v] of Object.entries(h)) {
res.setHeader(k, v);
}
};
const json = (res: http.ServerResponse, status: number, body: unknown): void => {
applyCors(res);
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body));
};
const readBearer = (req: http.IncomingMessage): string | null => {
const raw = req.headers.authorization ?? "";
const m = /^Bearer\s+(.+)$/i.exec(raw);
return m?.[1]?.trim() ?? null;
};
const readBodyBuffer = async (req: http.IncomingMessage): Promise<Buffer> => {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const b = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
total += b.length;
if (total > MAX_BODY_BYTES) {
throw new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`);
}
chunks.push(b);
}
return Buffer.concat(chunks);
};
const hopByHop = new Set([
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
]);
const buildForwardHeadersToGlobalApi = (
req: http.IncomingMessage,
payload: JWTPayload,
): Headers => {
const out = new Headers();
for (const [k, v] of Object.entries(req.headers)) {
if (!v) {
continue;
}
const lk = k.toLowerCase();
if (hopByHop.has(lk)) {
continue;
}
if (lk === "authorization") {
continue;
}
out.set(k, Array.isArray(v) ? v.join(", ") : v);
}
const gToken = globalApiToken();
if (gToken) {
out.set("Authorization", `Bearer ${gToken}`);
}
const sub = payload.sub;
if (typeof sub === "string" && sub.length > 0) {
out.set("X-OIDC-Sub", sub);
}
const email = payload.email;
if (typeof email === "string" && email.length > 0) {
out.set("X-OIDC-Email", email);
}
return out;
};
const responseHopByHop = new Set([
"connection",
"keep-alive",
"transfer-encoding",
"content-encoding",
]);
const proxyToGlobalApi = async (
req: http.IncomingMessage,
res: http.ServerResponse,
targetUrl: string,
headers: Headers,
body: Buffer,
): Promise<number> => {
const method = req.method ?? "GET";
const init: RequestInit = {
method,
headers,
redirect: "manual",
};
if (method !== "GET" && method !== "HEAD" && body.length > 0) {
init.body = new Uint8Array(body);
}
const out = await fetch(targetUrl, init);
applyCors(res);
res.statusCode = out.status;
for (const [k, v] of out.headers) {
if (responseHopByHop.has(k.toLowerCase())) {
continue;
}
res.setHeader(k, v);
}
const buf = Buffer.from(await out.arrayBuffer());
res.end(buf);
return out.status;
};
const publicClaims = (payload: JWTPayload): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const k of ["sub", "iss", "aud", "exp", "iat", "email", "name", "preferred_username"]) {
if (payload[k] !== undefined) {
out[k] = payload[k];
}
}
return out;
};
const main = async (): Promise<void> => {
const issuer = process.env.OIDC_ISSUER?.trim();
if (!issuer) {
console.error("smart-ide-sso-gateway: set OIDC_ISSUER (docv / Enso IdP issuer URL).");
process.exit(1);
}
if (!globalApiToken()) {
console.error(
"smart-ide-sso-gateway: set GLOBAL_API_INTERNAL_TOKEN (must match smart-ide-global-api).",
);
process.exit(1);
}
const audience = process.env.OIDC_AUDIENCE?.trim();
const jwksUri = await discoverJwksUri(issuer);
console.error(`smart-ide-sso-gateway: JWKS URI ${jwksUri}`);
const verify: VerifyFn = createVerify(jwksUri, issuer, audience || undefined);
const server = http.createServer((req, res) => {
void (async () => {
const started = Date.now();
const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", `http://${HOST}`);
const pathname = url.pathname;
let logPath = pathname;
let upstreamKey = "";
let status = 0;
let oidcSub: string | undefined;
try {
if (method === "OPTIONS") {
res.writeHead(204, corsHeaders());
res.end();
return;
}
if (method === "GET" && (pathname === "/health" || pathname === "/health/")) {
status = 200;
json(res, status, { status: "ok", service: "smart-ide-sso-gateway" });
return;
}
const token = readBearer(req);
if (!token) {
status = 401;
json(res, status, { error: "Missing Authorization: Bearer <access_token>" });
return;
}
let payload: JWTPayload;
try {
payload = await verify(token);
} catch {
status = 401;
json(res, status, { error: "Invalid or expired token" });
return;
}
if (typeof payload.sub === "string") {
oidcSub = payload.sub;
}
if (method === "GET" && pathname === "/v1/token/verify") {
status = 200;
json(res, status, { valid: true, claims: publicClaims(payload) });
return;
}
if (method === "GET" && pathname === "/v1/upstreams") {
status = 200;
json(res, status, { upstreams: listUpstreamKeys() });
return;
}
const proxyMatch = /^\/proxy\/([^/]+)(\/.*)?$/.exec(pathname);
if (!proxyMatch || !method) {
status = 404;
json(res, status, { error: "Not found" });
return;
}
upstreamKey = proxyMatch[1];
const rest = proxyMatch[2] ?? "/";
logPath = pathname;
const targetUrl = `${globalApiBase()}/v1/upstream/${upstreamKey}${rest}${url.search}`;
const body = await readBodyBuffer(req);
const headers = buildForwardHeadersToGlobalApi(req, payload);
status = await proxyToGlobalApi(req, res, targetUrl, headers, body);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
if (!res.headersSent) {
status = 400;
json(res, status, { error: msg });
} else if (status === 0) {
status = 500;
}
} finally {
const skipLog =
method === "OPTIONS" ||
(method === "GET" && (pathname === "/health" || pathname === "/health/"));
if (!skipLog) {
void appendSsoAccessLog({
method,
path: logPath,
upstream: upstreamKey || undefined,
status,
durationMs: Date.now() - started,
oidcSub,
});
}
}
})();
});
server.listen(PORT, HOST, () => {
console.error(`smart-ide-sso-gateway listening on http://${HOST}:${PORT}`);
});
};
void main();