Files
spoon/apps/agent-worker/src/terminal-token.ts
T

68 lines
2.7 KiB
TypeScript

import { createHmac, timingSafeEqual } from 'node:crypto';
// Short-lived, job-scoped token authorizing a browser terminal connection.
// Minted server-side by the Next app (which has verified job ownership) and
// verified here so the browser never sees the shared worker secret. Format:
// `${expiresAtMs}.${jobId}.${hmacSha256Hex}`
const signature = (payload: string, secret: string) =>
createHmac('sha256', secret).update(payload).digest('hex');
export const verifyTerminalToken = (
token: string,
jobId: string,
secret: string,
): boolean => {
if (!token || !secret) return false;
const parts = token.split('.');
if (parts.length !== 3) return false;
const [expRaw, tokenJobId, provided] = parts;
if (tokenJobId !== jobId) return false;
const exp = Number.parseInt(expRaw ?? '', 10);
if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = signature(`${expRaw}.${tokenJobId}`, secret);
const providedBuf = Buffer.from(provided ?? '', 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
return (
providedBuf.length === expectedBuf.length &&
timingSafeEqual(providedBuf, expectedBuf)
);
};
// Extract the username a box token authorizes without verifying its signature.
// The token itself authorizes the connection, so the upgrade handler reads the
// username from it (rather than a query param) and then verifies the whole
// token against that username. Returns null unless the format matches
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`.
export const parseBoxTokenUsername = (token: string): string | null => {
const parts = token.split('.');
if (parts.length !== 4 || parts[1] !== 'box') return null;
const username = parts[2];
if (!username) return null;
return username;
};
// User-scoped variant authorizing a terminal connection to a user's box.
// Embeds a literal `box` segment so its four parts never collide with the
// three-part job token above. Format:
// `${expiresAtMs}.box.${username}.${hmacSha256Hex}`
export const verifyBoxTerminalToken = (
token: string,
username: string,
secret: string,
): boolean => {
if (!token || !secret) return false;
const parts = token.split('.');
if (parts.length !== 4 || parts[1] !== 'box') return false;
const [expRaw, , tokenUsername, provided] = parts;
if (tokenUsername !== username) return false;
const exp = Number.parseInt(expRaw ?? '', 10);
if (!Number.isFinite(exp) || Date.now() > exp) return false;
const expected = signature(`${expRaw}.box.${tokenUsername}`, secret);
const providedBuf = Buffer.from(provided ?? '', 'hex');
const expectedBuf = Buffer.from(expected, 'hex');
return (
providedBuf.length === expectedBuf.length &&
timingSafeEqual(providedBuf, expectedBuf)
);
};