0import { createHmac, timingSafeEqual } from "node:crypto";
1
2// Stateless signed token used by the Onboarder's self-serve
3// public-receipts opt-in link. No DB table — the token *is* the bearer
4// of the (install, owner, repo) tuple, and the HMAC binds it to the
5// server's OPTIN_HMAC_SECRET. Idempotent flip on click means replay
6// inside the validity window is a no-op, not an exploit.
7
8export const OPTIN_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000;
9
10export type OptInPayload = {
11 installationId: number;
12 owner: string;
13 repo: string;
14};
15
16type SignedPayload = OptInPayload & { exp: number };
17
18function getSecret(): string {
19 const secret = process.env["OPTIN_HMAC_SECRET"];
20 if (secret === undefined || secret.length === 0) {
21 throw new Error("OPTIN_HMAC_SECRET is required to sign or verify opt-in tokens");
22 }
23 return secret;
24}
25
26function base64urlEncode(input: Buffer | string): string {
27 const buf = typeof input === "string" ? Buffer.from(input, "utf8") : input;
28 return buf
29 .toString("base64")
30 .replace(/=+$/u, "")
31 .replace(/\+/gu, "-")
32 .replace(/\//gu, "_");
33}
34
35function base64urlDecode(input: string): Buffer {
36 const padded = input.replace(/-/gu, "+").replace(/_/gu, "/");
37 const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4));
38 return Buffer.from(padded + pad, "base64");
39}
40
41function hmacFor(secret: string, payload: string): Buffer {
42 return createHmac("sha256", secret).update(payload).digest();
43}
44
45export function signToken(
46 payload: OptInPayload,
47 now: Date = new Date(),
48): string {
49 const signed: SignedPayload = {
50 installationId: payload.installationId,
51 owner: payload.owner,
52 repo: payload.repo,
53 exp: now.getTime() + OPTIN_TOKEN_TTL_MS,
54 };
55 const encodedPayload = base64urlEncode(JSON.stringify(signed));
56 const mac = hmacFor(getSecret(), encodedPayload);
57 return `${encodedPayload}.${base64urlEncode(mac)}`;
58}
59
60export type VerifyResult =
61 | { kind: "ok"; payload: OptInPayload }
62 | { kind: "expired" }
63 | { kind: "invalid" };
64
65export function verifyTokenDetailed(
66 token: string,
67 now: Date = new Date(),
68): VerifyResult {
69 if (typeof token !== "string" || token.length === 0) return { kind: "invalid" };
70 const dot = token.indexOf(".");
71 if (dot === -1 || dot === token.length - 1) return { kind: "invalid" };
72 const encodedPayload = token.slice(0, dot);
73 const encodedMac = token.slice(dot + 1);
74
75 const expectedMac = hmacFor(getSecret(), encodedPayload);
76 let providedMac: Buffer;
77 try {
78 providedMac = base64urlDecode(encodedMac);
79 } catch {
80 return { kind: "invalid" };
81 }
82 if (providedMac.length !== expectedMac.length) return { kind: "invalid" };
83 if (!timingSafeEqual(expectedMac, providedMac)) return { kind: "invalid" };
84
85 let parsed: unknown;
86 try {
87 parsed = JSON.parse(base64urlDecode(encodedPayload).toString("utf8"));
88 } catch {
89 return { kind: "invalid" };
90 }
91 if (typeof parsed !== "object" || parsed === null) return { kind: "invalid" };
92 const obj = parsed as Record<string, unknown>;
93 const installationId = obj["installationId"];
94 const owner = obj["owner"];
95 const repo = obj["repo"];
96 const exp = obj["exp"];
97 if (
98 typeof installationId !== "number" ||
99 typeof owner !== "string" ||
100 typeof repo !== "string" ||
101 typeof exp !== "number"
102 ) {
103 return { kind: "invalid" };
104 }
105 if (now.getTime() >= exp) return { kind: "expired" };
106 return { kind: "ok", payload: { installationId, owner, repo } };
107}
108
109export function verifyToken(
110 token: string,
111 now: Date = new Date(),
112): OptInPayload | null {
113 const result = verifyTokenDetailed(token, now);
114 return result.kind === "ok" ? result.payload : null;
115}
116
117// Short stable correlation handle for logs. Never log the raw token —
118// it's a bearer credential for 30 days.
119export function tokenLogId(token: string): string {
120 return createHmac("sha256", "antfleet.optin.log").update(token).digest("hex").slice(0, 12);
121}
122
123// Build the absolute URL the Onboarder injects into the first-review
124// summary comment. action defaults to "enable"; pass "disable" for the
125// reversal link.
126export function buildOptInUrl(args: {
127 baseUrl: string;
128 payload: OptInPayload;
129 action?: "enable" | "disable";
130 now?: Date;
131}): string {
132 const token = signToken(args.payload, args.now);
133 const url = new URL("/api/opt-in", args.baseUrl);
134 url.searchParams.set("t", token);
135 if (args.action === "disable") {
136 url.searchParams.set("action", "disable");
137 }
138 return url.toString();
139}
140