68 lines
2.7 KiB
TypeScript
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)
|
|
);
|
|
};
|