diff --git a/.gitignore b/.gitignore index 1bfc0f2f..6d7250da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ node_modules +# macOS Finder metadata +.DS_Store + # Build output dist coverage diff --git a/README.md b/README.md index 064f6508..0018c561 100644 --- a/README.md +++ b/README.md @@ -50,11 +50,22 @@ You will need to make port 4311 remotely accessible via HTTPS and give the publi The securest way to open the port for remote access is by putting all devices involved in a private VPN. Tailscale is a free option that works. -Doing this with Tailscale is as simple as installing Tailscale on your phone, computer, etc., and running this command on the device hosting the Farfield server: +For an extra guardrail, set a token before starting the server: + +```bash +export FARFIELD_AUTH_TOKEN="$(openssl rand -hex 32)" +export FARFIELD_CORS_ORIGIN="https://farfield.app" +npx -y @farfield/server@latest +``` + +Then install Tailscale on your phone, computer, etc., and run this command on the device hosting the Farfield server: + ```bash tailscale serve --https=443 http://127.0.0.1:4311 ``` +In farfield.app **Settings**, paste your Tailscale HTTPS URL into **Server** and paste the same token into **Auth token**. + We are working on easier options. Stay tuned! ## Running from source @@ -99,7 +110,7 @@ bun run dev:remote # exposes frontend + backend on you bun run dev:remote -- --agents=opencode # remote mode with OpenCode only ``` -> **Warning:** `dev:remote` exposes Farfield with no authentication. Only use on trusted networks. +> **Warning:** `dev:remote` exposes Farfield on your network. Set `FARFIELD_AUTH_TOKEN` and `FARFIELD_CORS_ORIGIN` when using it outside a trusted local network. ## Production Mode (No Extra Proxy) @@ -180,13 +191,16 @@ You still need to run the server locally so it can talk to your coding agent. ### 1) Start the Farfield server on your machine ```bash +export FARFIELD_AUTH_TOKEN="$(openssl rand -hex 32)" +export FARFIELD_CORS_ORIGIN="https://farfield.app" HOST=0.0.0.0 PORT=4311 bun run --filter @farfield/server dev ``` Quick local check: ```bash -curl http://127.0.0.1:4311/api/health +curl -H "Authorization: Bearer $FARFIELD_AUTH_TOKEN" \ + http://127.0.0.1:4311/api/health ``` ### 2) Put Tailscale HTTPS in front of port 4311 @@ -207,7 +221,8 @@ https://..ts.net Check it from a device on your tailnet: ```bash -curl https://..ts.net/api/health +curl -H "Authorization: Bearer $FARFIELD_AUTH_TOKEN" \ + https://..ts.net/api/health ``` ### 3) Pair farfield.app to your server @@ -221,16 +236,19 @@ https://..ts.net (note: no port) ``` -4. Click **Save**. +4. In **Auth token**, paste the value of `FARFIELD_AUTH_TOKEN`. +5. Click **Save**. -Farfield stores this in browser storage and uses it for API calls and live event stream. +Farfield stores this in browser storage and uses it for API calls and realtime websocket auth. ### Notes - Do not use raw tailnet IPs with `https://` (for example `https://100.x.x.x:4311`) in the browser; this won't work. - If you use `tailscale serve --https=443`, do not include `:4311` in the URL you enter in Settings. - **Use automatic** in Settings clears the saved server URL and returns to built-in default behavior. +- `FARFIELD_AUTH_TOKEN` accepts `Authorization: Bearer ` and `X-Farfield-Token: `. +- If you self-host the frontend, set `FARFIELD_CORS_ORIGIN` to your frontend origin instead of `https://farfield.app`. ## License -MIT \ No newline at end of file +MIT diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 00000000..2f5a263c --- /dev/null +++ b/apps/server/src/auth.ts @@ -0,0 +1,84 @@ +import type { IncomingHttpHeaders, IncomingMessage } from "node:http"; + +export interface FarfieldAuthConfig { + token: string; + corsOrigin: string; +} + +export function resolveFarfieldAuthConfig( + env: NodeJS.ProcessEnv = process.env, +): FarfieldAuthConfig { + const token = env["FARFIELD_AUTH_TOKEN"]?.trim() ?? ""; + const corsOrigin = + env["FARFIELD_CORS_ORIGIN"]?.trim() ?? + (token.length > 0 ? "https://farfield.app" : "*"); + + return { + token, + corsOrigin, + }; +} + +function readBearerToken(value: string | undefined): string | null { + if (!value) { + return null; + } + + const [scheme, token] = value.split(/\s+/, 2); + if (scheme?.toLowerCase() !== "bearer" || !token) { + return null; + } + return token; +} + +function tokenMatches(value: unknown, expectedToken: string): boolean { + return ( + typeof value === "string" && + expectedToken.length > 0 && + value === expectedToken + ); +} + +function directHeaderToken(headers: IncomingHttpHeaders): string | null { + const directHeader = headers["x-farfield-token"]; + if (Array.isArray(directHeader)) { + return directHeader.find((value) => value.length > 0) ?? null; + } + return directHeader ?? null; +} + +export function requestToken(req: IncomingMessage): string | null { + return directHeaderToken(req.headers) ?? readBearerToken(req.headers.authorization); +} + +export function isHttpRequestAuthorized( + req: IncomingMessage, + config: FarfieldAuthConfig, +): boolean { + if (config.token.length === 0) { + return true; + } + return tokenMatches(requestToken(req), config.token); +} + +export function isSocketAuthorized( + auth: unknown, + headers: IncomingHttpHeaders, + config: FarfieldAuthConfig, +): boolean { + if (config.token.length === 0) { + return true; + } + + if (auth && typeof auth === "object" && "token" in auth) { + const token = (auth as { token?: unknown }).token; + if (tokenMatches(token, config.token)) { + return true; + } + } + + return ( + tokenMatches(directHeaderToken(headers), config.token) || + tokenMatches(readBearerToken(headers.authorization), config.token) + ); +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 82e6ddfe..f783f056 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -34,6 +34,11 @@ import { TraceMarkBodySchema, TraceStartBodySchema, } from "./http-schemas.js"; +import { + isHttpRequestAuthorized, + isSocketAuthorized, + resolveFarfieldAuthConfig, +} from "./auth.js"; import { logger } from "./logger.js"; import { parseServerCliOptions, @@ -62,6 +67,7 @@ import { const HOST = process.env["HOST"] ?? "127.0.0.1"; const PORT = Number(process.env["PORT"] ?? 4311); +const AUTH_CONFIG = resolveFarfieldAuthConfig(); const HISTORY_LIMIT = 2_000; const USER_AGENT = "farfield/0.2.5"; const IPC_RECONNECT_DELAY_MS = 1_000; @@ -230,8 +236,9 @@ function jsonResponse( res.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8", "Content-Length": encoded.length, - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "content-type", + "Access-Control-Allow-Origin": AUTH_CONFIG.corsOrigin, + "Access-Control-Allow-Headers": + "content-type, authorization, x-farfield-token", "Access-Control-Allow-Methods": "GET,POST,OPTIONS", }); res.end(encoded); @@ -1327,6 +1334,21 @@ const server = http.createServer(async (req, res) => { const pathname = url.pathname; const segments = pathname.split("/").filter(Boolean); + if ( + pathname.startsWith("/api/") && + !isHttpRequestAuthorized(req, AUTH_CONFIG) + ) { + jsonResponse(res, 401, { + ok: false, + error: { + code: "unauthorized", + message: + "Farfield authentication failed. Set Authorization: Bearer or X-Farfield-Token.", + }, + }); + return; + } + if (req.method === "GET" && pathname === "/api/health") { jsonResponse(res, 200, { ok: true, @@ -1762,7 +1784,7 @@ const server = http.createServer(async (req, res) => { "Content-Type": "application/x-ndjson", "Content-Length": data.length, "Content-Disposition": `attachment; filename="${trace.id}.ndjson"`, - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": AUTH_CONFIG.corsOrigin, }); res.end(data); return; @@ -1819,11 +1841,25 @@ const io = new SocketServer(server, { path: "/api/unified/ws", transports: ["websocket"], cors: { - origin: "*", + origin: AUTH_CONFIG.corsOrigin, methods: ["GET", "POST"], }, }); +io.use((socket, next) => { + if ( + isSocketAuthorized( + socket.handshake.auth, + socket.handshake.headers, + AUTH_CONFIG, + ) + ) { + next(); + return; + } + next(new Error("Unauthorized")); +}); + const realtimeCoordinator = new RealtimeCoordinator({ io, buildCoreState: () => buildRealtimeCoreState(), diff --git a/apps/server/test/auth.test.ts b/apps/server/test/auth.test.ts new file mode 100644 index 00000000..809fdb8a --- /dev/null +++ b/apps/server/test/auth.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import type { IncomingMessage } from "node:http"; +import { + isHttpRequestAuthorized, + isSocketAuthorized, + requestToken, + resolveFarfieldAuthConfig, +} from "../src/auth.js"; + +function requestWithHeaders( + headers: IncomingMessage["headers"], +): IncomingMessage { + return { headers } as IncomingMessage; +} + +describe("farfield auth", () => { + it("is disabled when FARFIELD_AUTH_TOKEN is not set", () => { + const config = resolveFarfieldAuthConfig({}); + + expect(config.token).toBe(""); + expect(config.corsOrigin).toBe("*"); + expect(isHttpRequestAuthorized(requestWithHeaders({}), config)).toBe(true); + }); + + it("uses farfield.app as the default cors origin when auth is enabled", () => { + const config = resolveFarfieldAuthConfig({ + FARFIELD_AUTH_TOKEN: "secret", + }); + + expect(config.corsOrigin).toBe("https://farfield.app"); + }); + + it("uses configured cors origin when provided", () => { + const config = resolveFarfieldAuthConfig({ + FARFIELD_AUTH_TOKEN: "secret", + FARFIELD_CORS_ORIGIN: "https://phone.example.com", + }); + + expect(config.corsOrigin).toBe("https://phone.example.com"); + }); + + it("authorizes bearer tokens", () => { + const config = resolveFarfieldAuthConfig({ + FARFIELD_AUTH_TOKEN: "secret", + }); + + expect( + isHttpRequestAuthorized( + requestWithHeaders({ authorization: "Bearer secret" }), + config, + ), + ).toBe(true); + }); + + it("authorizes x-farfield-token headers", () => { + const config = resolveFarfieldAuthConfig({ + FARFIELD_AUTH_TOKEN: "secret", + }); + + expect( + isHttpRequestAuthorized( + requestWithHeaders({ "x-farfield-token": "secret" }), + config, + ), + ).toBe(true); + }); + + it("rejects missing or incorrect tokens", () => { + const config = resolveFarfieldAuthConfig({ + FARFIELD_AUTH_TOKEN: "secret", + }); + + expect(isHttpRequestAuthorized(requestWithHeaders({}), config)).toBe(false); + expect( + isHttpRequestAuthorized( + requestWithHeaders({ authorization: "Bearer wrong" }), + config, + ), + ).toBe(false); + }); + + it("extracts x-farfield-token before bearer auth", () => { + expect( + requestToken( + requestWithHeaders({ + authorization: "Bearer wrong", + "x-farfield-token": "secret", + }), + ), + ).toBe("secret"); + }); + + it("authorizes socket auth payloads", () => { + const config = resolveFarfieldAuthConfig({ + FARFIELD_AUTH_TOKEN: "secret", + }); + + expect(isSocketAuthorized({ token: "secret" }, {}, config)).toBe(true); + expect(isSocketAuthorized({ token: "wrong" }, {}, config)).toBe(false); + }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index df332842..a398e477 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -39,6 +39,7 @@ import { getPendingApprovalRequests, getPendingThreadRequests, getPendingUserInputRequests, + getSavedServerAuthToken, getSavedServerBaseUrl, getServerBaseUrl, getStreamEvents, @@ -1342,6 +1343,7 @@ export function App(): React.JSX.Element { () => getSavedServerBaseUrl() !== null, [], ); + const initialServerAuthToken = useMemo(() => getSavedServerAuthToken(), []); const initialSnapshot = ENABLE_VIEW_SNAPSHOT_CACHE ? appViewSnapshotCache : null; @@ -1410,8 +1412,12 @@ export function App(): React.JSX.Element { ); const [serverBaseUrl, setServerBaseUrlState] = useState(initialServerBaseUrl); + const [serverAuthToken, setServerAuthToken] = + useState(initialServerAuthToken); const [serverBaseUrlDraft, setServerBaseUrlDraft] = useState(initialServerBaseUrl); + const [serverAuthTokenDraft, setServerAuthTokenDraft] = + useState(initialServerAuthToken); const [hasSavedServerTarget, setHasSavedServerTarget] = useState( initialHasSavedServerBaseUrl, ); @@ -1529,7 +1535,8 @@ export function App(): React.JSX.Element { const selectedAgentLabel = selectedAgentDescriptor?.label ?? "Agent"; const reversedHistory = useMemo(() => history.slice().reverse(), [history]); const hasServerBaseUrlDraftChanges = - serverBaseUrlDraft.trim() !== serverBaseUrl; + serverBaseUrlDraft.trim() !== serverBaseUrl || + serverAuthTokenDraft.trim() !== serverAuthToken; const unifiedWebSocketUrl = useMemo( () => getUnifiedWebSocketUrl(serverBaseUrl), [serverBaseUrl], @@ -2721,9 +2728,13 @@ export function App(): React.JSX.Element { const saveServerTarget = useCallback(async () => { try { setError(""); - const normalizedBaseUrl = setServerBaseUrl(serverBaseUrlDraft); + const normalizedBaseUrl = setServerBaseUrl( + serverBaseUrlDraft, + serverAuthTokenDraft, + ); setServerBaseUrlState(normalizedBaseUrl); setServerBaseUrlDraft(normalizedBaseUrl); + setServerAuthToken(serverAuthTokenDraft.trim()); setHasSavedServerTarget(true); agentCacheRef.current = null; providerCatalogCacheRef.current.clear(); @@ -2731,7 +2742,7 @@ export function App(): React.JSX.Element { } catch (e) { setError(toErrorMessage(e)); } - }, [refreshAll, serverBaseUrlDraft]); + }, [refreshAll, serverAuthTokenDraft, serverBaseUrlDraft]); const useDefaultServerTarget = useCallback(async () => { try { @@ -2740,6 +2751,8 @@ export function App(): React.JSX.Element { const defaultBaseUrl = getDefaultServerBaseUrl(); setServerBaseUrlState(defaultBaseUrl); setServerBaseUrlDraft(defaultBaseUrl); + setServerAuthToken(""); + setServerAuthTokenDraft(""); setHasSavedServerTarget(false); agentCacheRef.current = null; providerCatalogCacheRef.current.clear(); @@ -3192,6 +3205,7 @@ export function App(): React.JSX.Element { useEffect(() => { const socket = createUnifiedRealtimeSocket({ socketUrl: unifiedWebSocketUrl, + authToken: serverAuthToken, onConnect: () => { socket.send({ kind: "hello", @@ -3248,7 +3262,7 @@ export function App(): React.JSX.Element { window.removeEventListener("pageshow", onPageShow); disconnectSocket(); }; - }, [handleRealtimeMessage, unifiedWebSocketUrl]); + }, [handleRealtimeMessage, serverAuthToken, unifiedWebSocketUrl]); useEffect(() => { if (!activeRequest) { @@ -5471,6 +5485,27 @@ export function App(): React.JSX.Element { /> +
+ +
+ Optional. Must match FARFIELD_AUTH_TOKEN when your server + enables token auth. +
+ setServerAuthTokenDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + void saveServerTarget(); + } + }} + placeholder="Paste FARFIELD_AUTH_TOKEN" + className="h-9 text-sm" + /> +
+