Shaheer Sarfaraz 2962e0c2ae
Fix password manager autofill for pipeline auth (#92) (#127)
* Fix basic auth flow to support password manager autofill

* fix orchestrator CI typecheck in api client

* clear basic auth fields when prompt closes

* update basic auth dialog description copy
2026-02-10 18:05:47 +00:00

162 lines
4.4 KiB
TypeScript

/**
* Express app factory (useful for tests).
*/
import { readFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { unauthorized } from "@infra/errors";
import {
apiErrorHandler,
fail,
legacyApiResponseShim,
notFoundApiHandler,
requestContextMiddleware,
} from "@infra/http";
import { logger } from "@infra/logger";
import cors from "cors";
import express from "express";
import { apiRouter } from "./api/index";
import { getDataDir } from "./config/dataDir";
import { isDemoMode } from "./config/demo";
const __dirname = dirname(fileURLToPath(import.meta.url));
function createBasicAuthGuard() {
function getAuthConfig() {
const user = process.env.BASIC_AUTH_USER || "";
const pass = process.env.BASIC_AUTH_PASSWORD || "";
return {
user,
pass,
enabled: user.length > 0 && pass.length > 0,
};
}
function isAuthorized(req: express.Request): boolean {
const { user: authUser, pass: authPass, enabled } = getAuthConfig();
if (!enabled) return false;
const authHeader = req.headers.authorization || "";
if (!authHeader.startsWith("Basic ")) return false;
const encoded = authHeader.slice("Basic ".length).trim();
let decoded = "";
try {
decoded = Buffer.from(encoded, "base64").toString("utf-8");
} catch {
return false;
}
const separatorIndex = decoded.indexOf(":");
if (separatorIndex === -1) return false;
const user = decoded.slice(0, separatorIndex);
const pass = decoded.slice(separatorIndex + 1);
return user === authUser && pass === authPass;
}
function isPublicReadOnlyRoute(method: string, path: string): boolean {
const normalizedMethod = method.toUpperCase();
const normalizedPath = path.split("?")[0] || path;
if (
normalizedMethod === "POST" &&
normalizedPath === "/api/visa-sponsors/search"
)
return true;
return false;
}
function requiresAuth(method: string, path: string): boolean {
if (isPublicReadOnlyRoute(method, path)) return false;
return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase());
}
const middleware = (
req: express.Request,
res: express.Response,
next: express.NextFunction,
) => {
const { enabled } = getAuthConfig();
if (!enabled || !requiresAuth(req.method, req.path)) return next();
if (isAuthorized(req)) return next();
fail(res, unauthorized("Authentication required"));
};
return {
middleware,
isAuthorized,
basicAuthEnabled: getAuthConfig().enabled,
};
}
export function createApp() {
const app = express();
const authGuard = createBasicAuthGuard();
app.use(cors());
app.use(requestContextMiddleware());
app.use(express.json({ limit: "5mb" }));
app.use(legacyApiResponseShim());
// Logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on("finish", () => {
const duration = Date.now() - start;
logger.info("HTTP request completed", {
method: req.method,
path: req.path,
status: res.statusCode,
durationMs: duration,
});
});
next();
});
// Optional Basic Auth for write access (read-only by default)
app.use(authGuard.middleware);
// API routes
app.use("/api", apiRouter);
app.use(notFoundApiHandler());
// Serve static files for generated PDFs
const pdfDir = join(getDataDir(), "pdfs");
if (isDemoMode()) {
const demoPdfPath = join(pdfDir, "demo.pdf");
app.get("/pdfs/*", (_req, res) => {
res.sendFile(demoPdfPath, (error) => {
if (error) res.status(404).end();
});
});
}
app.use("/pdfs", express.static(pdfDir));
// Health check
app.get("/health", (_req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// Serve client app in production
if (process.env.NODE_ENV === "production") {
const clientDir = join(__dirname, "../../dist/client");
app.use(express.static(clientDir));
// SPA fallback
const indexPath = join(clientDir, "index.html");
let cachedIndexHtml: string | null = null;
app.get("*", async (req, res) => {
if (!req.accepts("html")) {
res.status(404).end();
return;
}
if (!cachedIndexHtml) {
cachedIndexHtml = await readFile(indexPath, "utf-8");
}
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.send(cachedIndexHtml);
});
}
app.use(apiErrorHandler);
return app;
}