All docs pages

token-server.ts

// The two token routes your pages call, each on the server it belongs to, as small Node servers
// you can run and read:
//
//   POST /messenger/token        on your site's server: a visitor's token for the messenger on
//                                your profile pages (contracts/widget-host.md, "Signing in")
//   POST /messenger/inbox-token  on your owner dashboard's server: an owner's token for the inbox
//                                (contracts/inbox-host.md, "Signing in")
//
// They are two servers on purpose. An inbox token opens the owner's conversations, so its route
// lives only on the dashboard's own origin, such as https://pro.spaguide.example, which serves no
// public pages and no third-party scripts (contracts/inbox-host.md, "The dashboard's own
// origin"). A check that the request came from your own origin does not help when that origin is
// shared. The browser tells your server which origin the page that asks is on, never which page.
// On an origin your public pages share, any script on any of them passes that check, and can ask
// for the token of an owner who is browsing there.
//
// Each route answers 200 with the token as plain text for the user its own server's session
// names. When nobody is signed in, it answers 204 with no body, and the browser logs no failed
// request: the messenger reads an empty answer as a guest, and the inbox's page script answers
// `signout` (contracts/widget-host.md and contracts/inbox-host.md, "Signing in"). POST only, no
// CORS headers, and a request a browser marks as coming from another origin is refused. The token
// is never logged.
//
// Run it (Node 22.18 or later, nothing to install):
//
//   MESSENGER_PUBLISHABLE_KEY=pk_test_... \
//   MESSENGER_TOKEN_KID=tk_... \
//   MESSENGER_TOKEN_KEY_FILE=$HOME/.config/messenger/token-key.pem \
//   EXAMPLE_DEMO_USERS=1 \
//   node token-server.ts
//
// Locally, the two servers listen on two ports of this machine, which are two origins:
// http://localhost:8787 for your site (PORT changes it), http://localhost:8788 for your owner
// dashboard (DASHBOARD_PORT). In production, give each its own host. An origin matches only the
// same scheme, host and port, so http://127.0.0.1:8787 is a different origin from
// http://localhost:8787: open your pages at the address you list among the site's allowed origins.
//
// EXAMPLE_DEMO_USERS=1 turns on invented sign-ins for trying it locally, and only with a sandbox
// site's publishable key, pk_test_ (see `DEMO_VISITORS` and `DEMO_OWNERS`). In your own servers,
// replace `currentVisitor` and `currentOwner` with your real session lookups and leave it off.
import {
  createServer,
  type IncomingMessage,
  type ServerResponse,
} from "node:http";
import {
  readSigningKey,
  signUserToken,
  type Audience,
  type SigningKey,
} from "./user-token.ts";

/** Who is signed in, as your own session says. */
type SignedInUser = Readonly<{
  /** Your stable user ID: `sub` in the token, and `owner` in profile sync for an owner. */
  id: string;
  name: string;
}>;

/** An invented visitor, signed in on your site, for trying the site's route locally. */
const DEMO_VISITORS: ReadonlyMap<string, SignedInUser> = new Map([
  ["demo-visitor", { id: "user-1042", name: "Marta" }],
]);

/** An invented owner, signed in to your owner dashboard: the owner the profile lifecycle example sends as `owner`. */
const DEMO_OWNERS: ReadonlyMap<string, SignedInUser> = new Map([
  ["demo-owner", { id: "owner-lena", name: "Lena" }],
]);

/** The value of the cookie `name` on the request. */
function cookie(request: IncomingMessage, name: string): string | undefined {
  for (const part of (request.headers.cookie ?? "").split(";")) {
    const [key, ...value] = part.trim().split("=");
    if (key === name) return value.join("=");
  }
  return undefined;
}

/**
 * Who is signed in on your site. Replace this with your site's session lookup: read its session
 * cookie and find its user. This stand-in knows only the invented visitor, and only with
 * EXAMPLE_DEMO_USERS=1.
 */
function currentVisitor(
  request: IncomingMessage,
  demoUsers: boolean,
): SignedInUser | undefined {
  if (!demoUsers) return undefined;
  const session = cookie(request, "session");
  return session === undefined ? undefined : DEMO_VISITORS.get(session);
}

/**
 * Who is signed in to your owner dashboard. Replace this with the dashboard's own session lookup:
 * only a user signed in to the dashboard, never a session from your public pages. This stand-in
 * knows only the invented owner, and only with EXAMPLE_DEMO_USERS=1.
 */
function currentOwner(
  request: IncomingMessage,
  demoUsers: boolean,
): SignedInUser | undefined {
  if (!demoUsers) return undefined;
  const session = cookie(request, "session");
  return session === undefined ? undefined : DEMO_OWNERS.get(session);
}

/** The one route a server answers, the token it signs, and whose session it trusts. */
type Route = Readonly<{
  label: string;
  path: string;
  audience: Audience;
  currentUser: (
    request: IncomingMessage,
    demoUsers: boolean,
  ) => SignedInUser | undefined;
}>;

const SITE_ROUTE: Route = {
  label: "your site's route",
  path: "/messenger/token",
  audience: "messenger-widget",
  currentUser: currentVisitor,
};

const DASHBOARD_ROUTE: Route = {
  label: "your owner dashboard's route",
  path: "/messenger/inbox-token",
  audience: "messenger-inbox",
  currentUser: currentOwner,
};

type Config = Readonly<{
  publishableKey: string;
  key: SigningKey;
  sitePort: number;
  dashboardPort: number;
  demoUsers: boolean;
}>;

function required(name: string): string {
  const value = process.env[name];
  if (value === undefined || value === "")
    throw new Error(
      `${name} is not set. See the comment at the top of token-server.ts.`,
    );
  return value;
}

function readPort(name: string, fallback: number): number {
  const value = Number.parseInt(process.env[name] ?? String(fallback), 10);
  if (!Number.isInteger(value) || value < 0 || value > 65_535)
    throw new Error(`${name} must be a port number.`);
  return value;
}

function readConfig(): Config {
  const publishableKey = required("MESSENGER_PUBLISHABLE_KEY");
  const demoUsers = process.env["EXAMPLE_DEMO_USERS"] === "1";
  // The invented sign-ins sign anyone in who sends their cookie: never on a live site.
  if (demoUsers && !publishableKey.startsWith("pk_test_"))
    throw new Error(
      "EXAMPLE_DEMO_USERS=1 works only with a sandbox site's publishable key, pk_test_..., since its invented sign-ins let anyone in.",
    );
  return {
    publishableKey,
    key: readSigningKey(
      required("MESSENGER_TOKEN_KEY_FILE"),
      required("MESSENGER_TOKEN_KID"),
    ),
    sitePort: readPort("PORT", 8787),
    dashboardPort: readPort("DASHBOARD_PORT", 8788),
    demoUsers,
  };
}

function answer(
  response: ServerResponse,
  status: number,
  body = "",
  headers: Readonly<Record<string, string>> = {},
): void {
  // No Access-Control-Allow-Origin: a script on another origin cannot read the answer.
  response.writeHead(status, {
    "Content-Type": "text/plain; charset=utf-8",
    "Cache-Control": "no-store",
    ...headers,
  });
  response.end(body);
}

function handle(
  config: Config,
  route: Route,
  request: IncomingMessage,
  response: ServerResponse,
): void {
  const path = new URL(request.url ?? "/", "http://localhost").pathname;
  // Each server answers its own route only: the site's server has no inbox route.
  if (path !== route.path) return answer(response, 404);
  if (request.method !== "POST")
    return answer(response, 405, "", { Allow: "POST" });
  // Browsers send Sec-Fetch-Site. Only `same-origin`, a page on this server's own origin, may
  // ask: a page on another site, or on your public site next to the dashboard, is refused. Pages
  // of this origin all pass (see the top of this file).
  const site = request.headers["sec-fetch-site"];
  if (site !== undefined && site !== "same-origin")
    return answer(response, 403);

  const user = route.currentUser(request, config.demoUsers);
  // Nobody signed in: an empty answer, a guest for the messenger and `signout` for the inbox.
  if (user === undefined) return answer(response, 204);
  const token = signUserToken(config.key, {
    publishableKey: config.publishableKey,
    audience: route.audience,
    subject: user.id,
    name: user.name,
  });
  answer(response, 200, token);
}

/**
 * Starts the server that answers `route` on `port`. It listens on 127.0.0.1 only, so nothing
 * outside this machine reaches it, and prints its address as http://localhost, the name browsers
 * and curl resolve to this machine.
 */
function serve(config: Config, route: Route, port: number): void {
  const server = createServer((request, response) => {
    try {
      handle(config, route, request, response);
    } catch {
      answer(response, 500);
    }
  });
  server.on("error", (error) => {
    console.error(`token-server: ${route.label}: ${error.message}`);
    process.exit(1);
  });
  server.listen(port, "127.0.0.1", () => {
    const address = server.address();
    const bound =
      typeof address === "object" && address !== null ? address.port : port;
    console.log(
      `token-server: ${route.label} on http://localhost:${String(bound)}${route.path}`,
    );
  });
}

function main(): void {
  let config: Config;
  try {
    config = readConfig();
  } catch (error) {
    console.error(
      `token-server: ${error instanceof Error ? error.message : String(error)}`,
    );
    process.exit(1);
  }
  serve(config, SITE_ROUTE, config.sitePort);
  serve(config, DASHBOARD_ROUTE, config.dashboardPort);
  if (!config.demoUsers)
    console.log(
      "token-server: no sign-in wired in, so every request answers 204: nobody signed in. Replace currentVisitor() and currentOwner().",
    );
}

main();