Freestyle Docs

Freestyle / Guides

Gate A Sandbox With WorkOS

Put many preview VMs behind one WorkOS AuthKit sign-in using forward auth, with a single registered callback and a per-host session handoff.

WorkOS AuthKit hosts sign-in and issues sessions; forward auth decides at the Freestyle edge whether each request reaches your VM. This gates a fleet of preview hosts behind one WorkOS sign-in. Read Protect A Sandbox With Forward Auth first; this is the cross-origin handoff.

The important constraint: WorkOS redirect URIs are exact matches, with no wildcards. You can not register a callback per preview host or per customer domain. So register one callback on a central auth origin, and after WorkOS returns, hand the session off to the specific preview host yourself with a one-time code. That final leg is your own code, so any number of hosts and custom domains work, and each gets its own __Host- cookie (a cookie shared across a parent domain would leak between tenants).

Install The SDK

pnpm add freestyle @workos-inc/node
bun add freestyle @workos-inc/node
npm install freestyle @workos-inc/node
export FREESTYLE_API_KEY="your-freestyle-key"
export WORKOS_API_KEY="sk_live_…"
export WORKOS_CLIENT_ID="client_…"
# 32+ byte secret that seals the session cookie:
#   node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
export WORKOS_COOKIE_PASSWORD="…"

In WorkOS, register exactly one redirect URI: https://auth.example.com/workos/callback.

Two Services, One Store

  • A central auth origin (auth.example.com), a normal web app the browser reaches directly. It runs WorkOS and is the one URI WorkOS knows.
  • A per-host authorizer Freestyle calls for every request to a preview host.

They share a short-lived, single-use code store (Redis, KV, a table): code -> { sealedSession, host }, a couple of minutes TTL. Since preview hosts are created on the fly, the rule-to-host map is also a store you write to as you create each rule, not a hardcoded object.

import { WorkOS } from "@workos-inc/node";

const CLIENT_ID = process.env.WORKOS_CLIENT_ID!;
// Pass clientId: loadSealedSession(...).authenticate() needs it to fetch the
// JWKS and verify the session, and throws "Missing client ID" without it.
const workos = new WorkOS(process.env.WORKOS_API_KEY!, { clientId: CLIENT_ID });
const COOKIE_PASSWORD = process.env.WORKOS_COOKIE_PASSWORD!;
const SERVICE_SECRET = process.env.FORWARD_AUTH_SECRET!;
const CALLBACK_URI = "https://auth.example.com/workos/callback";
const SESSION_COOKIE = "__Host-workos-session";

// Freestyle authenticates itself with the header on the forwardAuth config.
const trustedEdge = (r: Request) =>
  r.headers.get("authorization") === `Bearer ${SERVICE_SECRET}`;

// Resolve the request's host from the trusted TLS rule id, never
// x-forwarded-host. See /vms/forward-auth#behind-a-reverse-proxy.
declare function lookupRule(ruleId: string): Promise<{ host: string } | null>;
// The single-use handoff store, and a signed carrier for the target host.
declare const store: {
  put(code: string, v: { sealedSession: string; host: string }, o: { ttlMs: number }): Promise<void>;
  take(code: string): Promise<{ sealedSession: string; host: string } | null>;
};
declare function sign(v: object): string;
declare function verify(token: string): { host: string; returnTo: string };

function readCookie(request: Request, name: string): string | null {
  for (const part of (request.headers.get("cookie") ?? "").split(";")) {
    const [k, ...v] = part.trim().split("=");
    if (k === name) return decodeURIComponent(v.join("="));
  }
  return null;
}

The Central Auth Origin

The browser hits /start (sent there by a preview host below), signs in on WorkOS, and returns to the one registered /workos/callback. That route exchanges the code and hands the session to the target preview host through a one-time code.

// GET https://auth.example.com/start?host=<previewHost>&return=<path>
export function start(request: Request): Response {
  const url = new URL(request.url);
  const state = sign({ host: url.searchParams.get("host")!, returnTo: url.searchParams.get("return") ?? "/" });
  return Response.redirect(
    workos.userManagement.getAuthorizationUrl({
      provider: "authkit",
      clientId: CLIENT_ID,
      redirectUri: CALLBACK_URI,
      state,
    }),
    302,
  );
}

// GET https://auth.example.com/workos/callback?code&state
export async function workosCallback(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const { host, returnTo } = verify(url.searchParams.get("state")!);

  const { sealedSession } = await workos.userManagement.authenticateWithCode({
    clientId: CLIENT_ID,
    code: url.searchParams.get("code")!,
    session: { sealSession: true, cookiePassword: COOKIE_PASSWORD },
  });

  // Cross to the preview host with a one-time code it will exchange.
  const code = crypto.randomUUID();
  await store.put(code, { sealedSession: sealedSession!, host }, { ttlMs: 120_000 });
  const back = new URL(`https://${host}/_auth/callback`);
  back.searchParams.set("code", code);
  back.searchParams.set("return", returnTo);
  return Response.redirect(back.toString(), 302);
}

The Per-Host Authorizer

Freestyle calls /check for every request. A valid session allows; otherwise send the visitor to the central origin. The /_auth/callback arrives on the preview host (through Freestyle), redeems the one-time code, and sets the cookie.

// The one endpoint Freestyle calls for every request to a protected host, with
// the original request in X-Forwarded-*. The handoff callback arrives here too
// (through Freestyle), so branch on the path.
export async function check(request: Request): Promise<Response> {
  if (!trustedEdge(request)) return new Response(null, { status: 401 });
  const rule = await lookupRule(request.headers.get("x-freestyle-tls-rule-id") ?? "");
  if (!rule) return new Response(null, { status: 404 });
  const forwarded = new URL(request.headers.get("x-forwarded-uri") ?? "/", `https://${rule.host}`);

  // Handoff callback: redeem the host-bound one-time code and set the cookie.
  if (forwarded.pathname === "/_auth/callback") {
    const handoff = await store.take(forwarded.searchParams.get("code") ?? ""); // single-use
    if (!handoff || handoff.host !== rule.host) return new Response(null, { status: 400 });
    const headers = new Headers({ location: safePath(forwarded.searchParams.get("return") ?? "/", rule.host) });
    headers.append(
      "set-cookie",
      `${SESSION_COOKIE}=${encodeURIComponent(handoff.sealedSession)}; Path=/; HttpOnly; Secure; SameSite=Lax`,
    );
    return new Response(null, { status: 302, headers });
  }

  // Otherwise validate the session cookie; a valid session allows the request.
  const cookie = readCookie(request, SESSION_COOKIE);
  if (cookie) {
    try {
      const result = await workos.userManagement
        .loadSealedSession({ sessionData: cookie, cookiePassword: COOKIE_PASSWORD })
        .authenticate();
      if (result.authenticated) {
        return new Response(null, { status: 204, headers: { "x-workos-user-id": result.user.id } });
      }
    } catch {
      // A tampered or truncated cookie throws rather than returning
      // { authenticated: false }; treat it as signed out.
    }
  }

  // No session: hand off to the central origin, keeping the path they wanted.
  const start = new URL("https://auth.example.com/start");
  start.searchParams.set("host", rule.host);
  start.searchParams.set("return", forwarded.pathname + forwarded.search);
  return Response.redirect(start.toString(), 302);
}

function safePath(value: string, host: string): string {
  try {
    const url = new URL(value, `https://${host}`);
    return url.origin === `https://${host}` ? `${url.pathname}${url.search}` : "/";
  } catch {
    return "/";
  }
}

Wire Each Rule

Per preview host, attach one forwardAuth config and record the rule id so lookupRule can resolve it. One config can protect many rules.

const auth = await freestyle.tls.forwardAuth.create({
  url: "https://auth.example.com/check",
  headers: { authorization: `Bearer ${process.env.FORWARD_AUTH_SECRET}` },
  timeoutMs: 1500,
  authResponseHeaders: ["x-workos-user-id"],
  protectedCookies: ["__Host-workos-session"],
});

const rule = await freestyle.tls.rules.create({
  action: "allow",
  domain: previewHost,
  source: { public: true },
  destination: { vmId, port: 3000 },
  forwardAuth: { id: auth.id },
});

await saveRule(rule.id, { host: previewHost }); // what lookupRule reads

Visit any preview host signed out: you go to the central origin, through WorkOS, back to that host with a one-time code, and land signed in. Keep the WorkOS session docs open for exact getAuthorizationUrl, authenticateWithCode, and loadSealedSession signatures.

esc