import { createHmac, timingSafeEqual } from "node:crypto"; interface JwtHeader { alg: string; typ?: string; } export interface LocalAgentJwtClaims { sub: string; company_id: string; adapter_type: string; run_id: string; iat: number; exp: number; iss?: string; aud?: string; jti?: string; } const JWT_ALGORITHM = "HS256"; function parseNumber(value: string | undefined, fallback: number) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) return fallback; return Math.floor(parsed); } function jwtConfig() { const secret = process.env.PAPERCLIP_AGENT_JWT_SECRET; if (!secret) return null; return { secret, ttlSeconds: parseNumber(process.env.PAPERCLIP_AGENT_JWT_TTL_SECONDS, 60 * 60 * 48), issuer: process.env.PAPERCLIP_AGENT_JWT_ISSUER ?? "paperclip", audience: process.env.PAPERCLIP_AGENT_JWT_AUDIENCE ?? "paperclip-api", }; } function base64UrlEncode(value: string) { return Buffer.from(value, "utf8").toString("base64url"); } function base64UrlDecode(value: string) { return Buffer.from(value, "base64url").toString("utf8"); } function signPayload(secret: string, signingInput: string) { return createHmac("sha256", secret).update(signingInput).digest("base64url"); } function parseJson(value: string): Record | null { try { const parsed = JSON.parse(value); return parsed && typeof parsed === "object" ? parsed as Record : null; } catch { return null; } } function safeCompare(a: string, b: string) { const left = Buffer.from(a); const right = Buffer.from(b); if (left.length !== right.length) return false; return timingSafeEqual(left, right); } export function createLocalAgentJwt(agentId: string, companyId: string, adapterType: string, runId: string) { const config = jwtConfig(); if (!config) return null; const now = Math.floor(Date.now() / 1000); const claims: LocalAgentJwtClaims = { sub: agentId, company_id: companyId, adapter_type: adapterType, run_id: runId, iat: now, exp: now + config.ttlSeconds, iss: config.issuer, aud: config.audience, }; const header = { alg: JWT_ALGORITHM, typ: "JWT", }; const signingInput = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(claims))}`; const signature = signPayload(config.secret, signingInput); return `${signingInput}.${signature}`; } export function verifyLocalAgentJwt(token: string): LocalAgentJwtClaims | null { if (!token) return null; const config = jwtConfig(); if (!config) return null; const parts = token.split("."); if (parts.length !== 3) return null; const [headerB64, claimsB64, signature] = parts; const header = parseJson(base64UrlDecode(headerB64)); if (!header || header.alg !== JWT_ALGORITHM) return null; const signingInput = `${headerB64}.${claimsB64}`; const expectedSig = signPayload(config.secret, signingInput); if (!safeCompare(signature, expectedSig)) return null; const claims = parseJson(base64UrlDecode(claimsB64)); if (!claims) return null; const sub = typeof claims.sub === "string" ? claims.sub : null; const companyId = typeof claims.company_id === "string" ? claims.company_id : null; const adapterType = typeof claims.adapter_type === "string" ? claims.adapter_type : null; const runId = typeof claims.run_id === "string" ? claims.run_id : null; const iat = typeof claims.iat === "number" ? claims.iat : null; const exp = typeof claims.exp === "number" ? claims.exp : null; if (!sub || !companyId || !adapterType || !runId || !iat || !exp) return null; const now = Math.floor(Date.now() / 1000); if (exp < now) return null; const issuer = typeof claims.iss === "string" ? claims.iss : undefined; const audience = typeof claims.aud === "string" ? claims.aud : undefined; if (issuer && issuer !== config.issuer) return null; if (audience && audience !== config.audience) return null; return { sub, company_id: companyId, adapter_type: adapterType, run_id: runId, iat, exp, ...(issuer ? { iss: issuer } : {}), ...(audience ? { aud: audience } : {}), jti: typeof claims.jti === "string" ? claims.jti : undefined, }; }