97 lines · 3.6 KB · TypeScript
RawDownload
/** * Auth, host-allow, and static-route helpers shared between the Bun listener * (serve.ts) and the Node listener used by the desktop sidecar (serve-node.ts). */import { timingSafeEqual } from "node:crypto"; export const DEFAULT_ALLOWED_HOSTS: ReadonlyArray<string> = [  "localhost",  "127.0.0.1",  "[::1]",  "::1",]; const LOOPBACK_BIND_HOSTS = new Set<string>(["localhost", "127.0.0.1", "[::1]", "::1"]); export const normalizeCredential = (value: string | undefined): string | null => {  const normalized = value?.trim();  return normalized && normalized.length > 0 ? normalized : null;}; export const safeEqual = (actual: string, expected: string): boolean => {  const actualBytes = Buffer.from(actual);  const expectedBytes = Buffer.from(expected);  return actualBytes.length === expectedBytes.length && timingSafeEqual(actualBytes, expectedBytes);}; export const isLoopbackBindHost = (hostname: string): boolean =>  LOOPBACK_BIND_HOSTS.has(hostname.trim().toLowerCase()); export const makeIsAllowedHost =  (allowed: ReadonlySet<string>) =>  (request: Request): boolean => {    const host = request.headers.get("host");    if (!host) return true;    const hostname = host.replace(/:\d+$/, "");    return allowed.has(hostname);  }; export const hasBearerToken = (request: Request, token: string): boolean => {  const authorization = request.headers.get("authorization");  const bearer = authorization?.match(/^Bearer\s+(.+)$/i)?.[1]?.trim();  return (    (bearer !== undefined && safeEqual(bearer, token)) ||    safeEqual(request.headers.get("x-executor-token") ?? "", token)  );}; export const hasBasicPassword = (request: Request, password: string): boolean => {  const authorization = request.headers.get("authorization");  const encoded = authorization?.match(/^Basic\s+(.+)$/i)?.[1]?.trim();  if (!encoded) return false;   let decoded: string;  // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Basic auth decoding accepts untrusted header bytes  try {    decoded = Buffer.from(encoded, "base64").toString("utf8");  } catch {    return false;  }   const separator = decoded.indexOf(":");  const actualPassword = separator >= 0 ? decoded.slice(separator + 1) : decoded;  return safeEqual(actualPassword, password);}; export interface AuthCredentials {  readonly token: string | null;  readonly password: string | null;} export const makeIsAuthorized =  (auth: AuthCredentials) =>  (request: Request): boolean =>    (auth.token !== null && hasBearerToken(request, auth.token)) ||    (auth.password !== null && hasBasicPassword(request, auth.password)); export const hasFileExtension = (pathname: string): boolean => {  const lastSegment = pathname.split("/").at(-1) ?? "";  return lastSegment.includes(".");}; /** * OAuth provider callbacks land here from the user's external browser, * which has no way to send our Basic auth header. The `state` parameter * is the cryptographic gate — each in-flight session is server-issued * and validated by the shared `completeOAuth` before any work happens. * Bypassing Basic auth on these paths is safe. * * Matches: * - `/api/oauth/callback` — the shared OAuth API mount point. * - `/api/oauth/await/<sessionId>` — polled by the Electron renderer *   when the user runs the flow in their system browser. The sessionId *   is the cryptographic flow id; results are one-shot, so a leaked *   poll without a matching active flow returns null. */export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean =>  /^\/api\/oauth\/(callback|await)(\/|$)/.test(pathname);