105 lines
3.4 KiB
TypeScript
105 lines
3.4 KiB
TypeScript
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { FatalError } from "./errors/index.js";
|
|
|
|
let signingKey: Buffer | null = null;
|
|
const TOKEN_TTL_MS = 48 * 60 * 60 * 1000; // 48 hours
|
|
const SIGNING_KEY_BYTES = 32;
|
|
|
|
// Once-per-process warning when a malformed token shape is seen. Helps
|
|
// diagnose schema breaks after upgrades — without leaking the full token.
|
|
let loggedMalformedTokenWarning = false;
|
|
function warnMalformedTokenOnce(token: string, reason: string) {
|
|
if (loggedMalformedTokenWarning) return;
|
|
loggedMalformedTokenWarning = true;
|
|
const fingerprint = token.slice(0, 8);
|
|
|
|
console.warn(`[agent-auth] token shape invalid (${reason}); first8="${fingerprint}..."`);
|
|
}
|
|
|
|
interface TokenPayload {
|
|
agentId: string;
|
|
companyId: string;
|
|
runId: string;
|
|
iat: number;
|
|
exp: number;
|
|
}
|
|
|
|
function sign(payload: TokenPayload): string {
|
|
const key = getSigningKey();
|
|
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url");
|
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
const signature = createHmac("sha256", key)
|
|
.update(`${header}.${body}`)
|
|
.digest("base64url");
|
|
return `${header}.${body}.${signature}`;
|
|
}
|
|
|
|
function verify(token: string): TokenPayload | null {
|
|
const key = getSigningKey();
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) return null;
|
|
const [header, body, signature] = parts as [string, string, string];
|
|
|
|
const expectedSignature = createHmac("sha256", key)
|
|
.update(`${header}.${body}`)
|
|
.digest("base64url");
|
|
|
|
const sigBuffer = Buffer.from(signature, "base64url");
|
|
const expectedBuffer = Buffer.from(expectedSignature, "base64url");
|
|
if (sigBuffer.length !== expectedBuffer.length || !timingSafeEqual(sigBuffer, expectedBuffer)) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const payload = JSON.parse(Buffer.from(body, "base64url").toString()) as TokenPayload;
|
|
if (Date.now() > payload.exp) return null;
|
|
return payload;
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
warnMalformedTokenOnce(token, reason);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function getSigningKey() {
|
|
if (!signingKey) {
|
|
throw new Error("Agent token signing key was used before initialization.");
|
|
}
|
|
return signingKey;
|
|
}
|
|
|
|
export function initializeAgentTokenSigningKey(profileDir: string) {
|
|
mkdirSync(profileDir, { recursive: true });
|
|
const keyPath = join(profileDir, "agent-api-signing.key");
|
|
let key: Buffer;
|
|
try {
|
|
key = readFileSync(keyPath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
key = randomBytes(SIGNING_KEY_BYTES);
|
|
writeFileSync(keyPath, key, { mode: 0o600, flag: "wx" });
|
|
}
|
|
if (key.length !== SIGNING_KEY_BYTES) {
|
|
throw new FatalError({
|
|
code: "agent_auth.signing_key.invalid",
|
|
message: `Agent token signing key must be ${SIGNING_KEY_BYTES} bytes.`,
|
|
context: { keyPath, actualBytes: key.length },
|
|
});
|
|
}
|
|
chmodSync(keyPath, 0o600);
|
|
signingKey = key;
|
|
}
|
|
|
|
export function createAgentToken(agentId: string, companyId: string, runId: string): string {
|
|
const now = Date.now();
|
|
return sign({ agentId, companyId, runId, iat: now, exp: now + TOKEN_TTL_MS });
|
|
}
|
|
|
|
export function validateAgentToken(token: string): TokenPayload | null {
|
|
return verify(token);
|
|
}
|
|
|
|
export type { TokenPayload };
|