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 => { 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 => { 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 => { 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 => { const out: Record = {}; 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 => { 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 " }); 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();