All docs pages

user-token.ts

// Signs a user token: a short JWT that tells the platform who is signed in on your site
// (contracts/user-token.md). Your backend signs one each time a page asks, with the private key
// whose public half you uploaded in the partner console. Uses only Node's own crypto.
//
// - A visitor's token (`aud` "messenger-widget") signs a visitor in to the messenger on your
//   profile pages.
// - An owner's token (`aud` "messenger-inbox") signs a profile owner in to the inbox on your owner
//   dashboard. Its `sub` is the same user ID your profile sync sends as `owner`.
import {
  createPrivateKey,
  randomBytes,
  sign,
  type KeyObject,
} from "node:crypto";
import { readFileSync } from "node:fs";

/** What a token is for (contracts/user-token.md, "Audience"). A token for one is refused for the other. */
export type Audience = "messenger-widget" | "messenger-inbox";

/** The key you sign with: your private key, and the key id (`kid`) the console showed when you uploaded its public half. */
export type SigningKey = Readonly<{ privateKey: KeyObject; kid: string }>;

/** Who the token is for. */
export type TokenRequest = Readonly<{
  /** Your site's publishable key, `pk_...`. It goes in `iss`. */
  publishableKey: string;
  audience: Audience;
  /** Your own stable ID for the user, at most 128 characters. Never an email address, a phone number or a name. */
  subject: string;
  /** Optional, at most 60 characters: the name the profile owner sees for a visitor, or the inbox shows for an owner. */
  name?: string | undefined;
}>;

/**
 * How long a token stays valid. The contract allows at most 5 minutes. The page uses the token at
 * once, so 2 minutes is plenty and leaves room for a clock that is a little off.
 */
export const TOKEN_LIFETIME_SECONDS = 120;

/**
 * Reads a private key from a PEM file: Ed25519 (signs as EdDSA) or P-256 (signs as ES256).
 * Keep the file on your server, readable only by the process that signs.
 */
export function readSigningKey(file: string, kid: string): SigningKey {
  const privateKey = createPrivateKey(readFileSync(file));
  algorithmOf(privateKey);
  if (kid === "")
    throw new Error(
      "The key id is empty. Use the kid the partner console showed for this key.",
    );
  return { privateKey, kid };
}

/** The JWT algorithm for `key`: EdDSA for Ed25519, ES256 for P-256. The platform takes no other. */
function algorithmOf(key: KeyObject): "EdDSA" | "ES256" {
  if (key.asymmetricKeyType === "ed25519") return "EdDSA";
  if (
    key.asymmetricKeyType === "ec" &&
    key.asymmetricKeyDetails?.namedCurve === "prime256v1"
  )
    return "ES256";
  throw new Error(
    "The key is neither Ed25519 nor P-256, the only kinds the platform takes. Make an Ed25519 key pair.",
  );
}

function base64url(value: object): string {
  return Buffer.from(JSON.stringify(value)).toString("base64url");
}

/** The length in characters (Unicode code points), as the contract counts it. */
function characters(text: string): number {
  return Array.from(text).length;
}

/**
 * A fresh token for one user, signed now. Throws when a claim breaks the contract's limits, so a
 * mistake shows up on your server rather than as a guest in the messenger.
 */
export function signUserToken(
  key: SigningKey,
  request: TokenRequest,
  nowMs: number = Date.now(),
): string {
  const { publishableKey, audience, subject, name } = request;
  if (!publishableKey.startsWith("pk_"))
    throw new Error(
      "iss must be your site's publishable key, which starts with pk_.",
    );
  if (characters(subject) < 1 || characters(subject) > 128)
    throw new Error("sub must be 1 to 128 characters.");
  // An email address would travel through the user's browser in every token.
  if (subject.includes("@"))
    throw new Error(
      "sub must be your own stable ID for the user, never an email address, a phone number or a name.",
    );
  if (name !== undefined && (characters(name) < 1 || characters(name) > 60))
    throw new Error("name must be 1 to 60 characters.");

  const algorithm = algorithmOf(key.privateKey);
  const issuedAt = Math.floor(nowMs / 1000);
  const header = base64url({ alg: algorithm, kid: key.kid, typ: "JWT" });
  const claims = base64url({
    iss: publishableKey,
    aud: audience,
    sub: subject,
    iat: issuedAt,
    exp: issuedAt + TOKEN_LIFETIME_SECONDS,
    // 32 random hex characters, new for every token: the platform refuses a jti it has seen.
    jti: randomBytes(16).toString("hex"),
    ...(name === undefined ? {} : { name }),
  });
  const signingInput = Buffer.from(`${header}.${claims}`);
  // ES256 signatures in a JWT are the raw r and s values (IEEE P1363), not DER.
  const signature =
    algorithm === "EdDSA"
      ? sign(null, signingInput, key.privateKey)
      : sign("sha256", signingInput, {
          key: key.privateKey,
          dsaEncoding: "ieee-p1363",
        });
  return `${header}.${claims}.${signature.toString("base64url")}`;
}