Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
node_modules

# macOS Finder metadata
.DS_Store

# Build output
dist
coverage
Expand Down
32 changes: 25 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -207,7 +221,8 @@ https://<machine>.<tailnet>.ts.net
Check it from a device on your tailnet:

```bash
curl https://<machine>.<tailnet>.ts.net/api/health
curl -H "Authorization: Bearer $FARFIELD_AUTH_TOKEN" \
https://<machine>.<tailnet>.ts.net/api/health
```

### 3) Pair farfield.app to your server
Expand All @@ -221,16 +236,19 @@ https://<machine>.<tailnet>.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 <token>` and `X-Farfield-Token: <token>`.
- If you self-host the frontend, set `FARFIELD_CORS_ORIGIN` to your frontend origin instead of `https://farfield.app`.

## License

MIT
MIT
84 changes: 84 additions & 0 deletions apps/server/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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)
);
}
44 changes: 40 additions & 4 deletions apps/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 <token> or X-Farfield-Token.",
},
});
return;
}

if (req.method === "GET" && pathname === "/api/health") {
jsonResponse(res, 200, {
ok: true,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand Down
101 changes: 101 additions & 0 deletions apps/server/test/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading