user-token.ts
import {
createPrivateKey,
randomBytes,
sign,
type KeyObject,
} from "node:crypto";
import { readFileSync } from "node:fs";
export type Audience = "messenger-widget" | "messenger-inbox";
export type SigningKey = Readonly<{ privateKey: KeyObject; kid: string }>;
export type TokenRequest = Readonly<{
publishableKey: string;
audience: Audience;
subject: string;
name?: string | undefined;
}>;
export const TOKEN_LIFETIME_SECONDS = 120;
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 };
}
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");
}
function characters(text: string): number {
return Array.from(text).length;
}
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.");
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,
jti: randomBytes(16).toString("hex"),
...(name === undefined ? {} : { name }),
});
const signingInput = Buffer.from(`${header}.${claims}`);
const signature =
algorithm === "EdDSA"
? sign(null, signingInput, key.privateKey)
: sign("sha256", signingInput, {
key: key.privateKey,
dsaEncoding: "ieee-p1363",
});
return `${header}.${claims}.${signature.toString("base64url")}`;
}