- 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
272 lines
7.8 KiB
TypeScript
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();
|