import { timingSafeEqual } from "node:crypto"; import type { IncomingMessage } from "node:http"; export type ResolvedGatewayAuthMode = "none" | "token" | "password"; export type ResolvedGatewayAuth = { mode: ResolvedGatewayAuthMode; token?: string; password?: string; allowTailscale: boolean; }; export type GatewayAuthResult = { ok: boolean; method?: "none" | "token" | "password" | "tailscale"; user?: string; reason?: string; }; type ConnectAuth = { token?: string; password?: string; }; type TailscaleUser = { login: string; name: string; profilePic?: string; }; function safeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; return timingSafeEqual(Buffer.from(a), Buffer.from(b)); } function isLoopbackAddress(ip: string | undefined): boolean { if (!ip) return false; if (ip === "127.0.0.1") return true; if (ip.startsWith("127.")) return true; if (ip === "::1") return true; if (ip.startsWith("::ffff:127.")) return true; return false; } function isLocalDirectRequest(req?: IncomingMessage): boolean { if (!req) return false; const clientIp = req.socket?.remoteAddress ?? ""; if (!isLoopbackAddress(clientIp)) return false; const host = (req.headers.host ?? "").toLowerCase(); const hostIsLocal = host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]"); const hasForwarded = Boolean( req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.headers["x-forwarded-host"], ); return hostIsLocal && !hasForwarded; } function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null { if (!req) return null; const login = req.headers["tailscale-user-login"]; if (typeof login !== "string" || !login.trim()) return null; const nameRaw = req.headers["tailscale-user-name"]; const profilePic = req.headers["tailscale-user-profile-pic"]; const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim(); return { login: login.trim(), name, profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined, }; } function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean { if (!req) return false; return Boolean( req.headers["x-forwarded-for"] && req.headers["x-forwarded-proto"] && req.headers["x-forwarded-host"], ); } function isTailscaleProxyRequest(req?: IncomingMessage): boolean { if (!req) return false; return ( isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req) ); } export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void { if (auth.mode === "token" && !auth.token) { throw new Error( "gateway auth mode is token, but CLAWDIS_GATEWAY_TOKEN is not set", ); } if (auth.mode === "password" && !auth.password) { throw new Error( "gateway auth mode is password, but no password was configured", ); } } export async function authorizeGatewayConnect(params: { auth: ResolvedGatewayAuth; connectAuth?: ConnectAuth | null; req?: IncomingMessage; }): Promise { const { auth, connectAuth, req } = params; const localDirect = isLocalDirectRequest(req); if (auth.mode === "none") { if (auth.allowTailscale && !localDirect) { const tailscaleUser = getTailscaleUser(req); if (!tailscaleUser) { return { ok: false, reason: "unauthorized" }; } if (!isTailscaleProxyRequest(req)) { return { ok: false, reason: "unauthorized" }; } return { ok: true, method: "tailscale", user: tailscaleUser.login, }; } return { ok: true, method: "none" }; } if (auth.mode === "token") { if (auth.token && connectAuth?.token === auth.token) { return { ok: true, method: "token" }; } } if (auth.mode === "password") { const password = connectAuth?.password; if (!password || !auth.password) { return { ok: false, reason: "unauthorized" }; } if (!safeEqual(password, auth.password)) { return { ok: false, reason: "unauthorized" }; } return { ok: true, method: "password" }; } if (auth.allowTailscale) { const tailscaleUser = getTailscaleUser(req); if (tailscaleUser && isTailscaleProxyRequest(req)) { return { ok: true, method: "tailscale", user: tailscaleUser.login, }; } } return { ok: false, reason: "unauthorized" }; }