rspace-online/modules/rtasks/lib/token.ts

88 lines
2.3 KiB
TypeScript

/** HMAC-SHA256 signed tokens for email checklist links. */
import { checklistConfig } from "./checklist-config";
interface TokenPayload {
/** repo slug */
r: string;
/** task ID */
t: string;
/** AC index */
a: number;
/** expiry (unix seconds) */
e: number;
}
interface TokenData extends TokenPayload {
/** hex signature */
s: string;
}
async function getKey(): Promise<CryptoKey> {
return crypto.subtle.importKey(
"raw",
new TextEncoder().encode(checklistConfig.hmacSecret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
}
function toBase64Url(data: string): string {
return btoa(data).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function fromBase64Url(b64: string): string {
return atob(b64.replace(/-/g, "+").replace(/_/g, "/"));
}
function toHex(buf: ArrayBuffer): string {
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
async function sign(payload: string): Promise<string> {
const key = await getKey();
const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(payload));
return toHex(sig);
}
export async function createToken(repo: string, taskId: string, acIndex: number): Promise<string> {
const expiry = Math.floor(Date.now() / 1000) + checklistConfig.tokenExpiryDays * 86400;
const payload: TokenPayload = { r: repo, t: taskId, a: acIndex, e: expiry };
const payloadStr = `${payload.r}:${payload.t}:${payload.a}:${payload.e}`;
const sig = await sign(payloadStr);
const token: TokenData = { ...payload, s: sig };
return toBase64Url(JSON.stringify(token));
}
export interface VerifiedToken {
repo: string;
taskId: string;
acIndex: number;
}
export async function verifyToken(token: string): Promise<VerifiedToken> {
let data: TokenData;
try {
data = JSON.parse(fromBase64Url(token));
} catch {
throw new Error("Invalid token format");
}
if (!data.r || !data.t || typeof data.a !== "number" || !data.e || !data.s) {
throw new Error("Malformed token");
}
if (data.e < Math.floor(Date.now() / 1000)) {
throw new Error("Token expired");
}
const payloadStr = `${data.r}:${data.t}:${data.a}:${data.e}`;
const expected = await sign(payloadStr);
if (data.s !== expected) {
throw new Error("Invalid signature");
}
return { repo: data.r, taskId: data.t, acIndex: data.a };
}