Files
AgentCompany/src/main/agent-auth.ts
T

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 };