paid down the broker's accumulated type debt. zero behavioral changes, purely type-system tightening: - broker.ts: row extraction helper for postgres-js result vs pg shape; findMemberByPubkey defaultGroups null-coalescing. - env.ts: zod default ordered before transform (zod v4 ordering). - index.ts: typed JSON.parse for the tg/token, upload-auth, file-upload, member patch and mesh-settings handlers; export SelfEditablePolicy from member-api; added bodyVersion to WSSendMessage; added the disconnect/kick/ban/unban/list_bans message types to WSClientMessage; String(key) cast for neo4j record symbol-typed keys. - jwt.ts, paths.ts, telegram-token.ts: typed JSON.parse results. - service-manager.ts: typed package.json + MCP JSON-RPC reader. - telegram-bridge.ts: typed WS message handler; missing log import; null-tolerant BridgeRow + skip rows missing memberId/displayName; typed e in catch. - types.ts: bodyVersion on WSSendMessage, manifest on WSSkillData, five new admin message types (kick/disconnect/ban/unban/list_bans). - packages/db/server.ts: drizzle constructor positional args + scoped ts-expect-error for the namespace-bag schema generic mismatch. apps/broker/src/types.ts will eventually want a real audit pass to catch every WS verb and surface the orphans, but this clears the path for 1.30.0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
147 lines
3.9 KiB
TypeScript
147 lines
3.9 KiB
TypeScript
/**
|
|
* JWT verification for CLI sync tokens.
|
|
*
|
|
* Sync tokens are HS256 JWTs issued by the dashboard after OAuth,
|
|
* shared secret between dashboard and broker via env var.
|
|
*
|
|
* JTI dedup: tracks used token IDs in a TTL-evicted Set to prevent replay.
|
|
*/
|
|
|
|
import { env } from "./env";
|
|
|
|
// --- Types ---
|
|
|
|
export interface SyncTokenPayload {
|
|
sub: string; // dashboard user ID
|
|
email: string;
|
|
meshes: Array<{
|
|
id: string;
|
|
slug: string;
|
|
role: "admin" | "member";
|
|
}>;
|
|
action: "sync" | "create";
|
|
newMesh?: {
|
|
name: string;
|
|
slug: string;
|
|
};
|
|
jti: string; // unique token ID for replay prevention
|
|
iat: number;
|
|
exp: number;
|
|
}
|
|
|
|
// --- JTI dedup ---
|
|
|
|
const usedJtis = new Map<string, number>(); // jti → expiry timestamp (ms)
|
|
|
|
// Sweep expired JTIs every 5 minutes
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [jti, exp] of usedJtis) {
|
|
if (exp < now) usedJtis.delete(jti);
|
|
}
|
|
}, 5 * 60_000);
|
|
|
|
// --- Verification ---
|
|
|
|
/**
|
|
* Verify and decode a sync token JWT.
|
|
* Returns the decoded payload on success, or an error string on failure.
|
|
*/
|
|
export async function verifySyncToken(
|
|
token: string,
|
|
): Promise<{ ok: true; payload: SyncTokenPayload } | { ok: false; error: string }> {
|
|
// Get shared secret from env
|
|
const secret = env.CLI_SYNC_SECRET;
|
|
if (!secret) {
|
|
return { ok: false, error: "CLI_SYNC_SECRET not configured on broker" };
|
|
}
|
|
|
|
try {
|
|
// Decode JWT manually (HS256)
|
|
const parts = token.split(".");
|
|
if (parts.length !== 3) {
|
|
return { ok: false, error: "malformed JWT" };
|
|
}
|
|
|
|
const headerB64 = parts[0]!;
|
|
const payloadB64 = parts[1]!;
|
|
const signatureB64 = parts[2]!;
|
|
|
|
// Verify signature (HS256)
|
|
const encoder = new TextEncoder();
|
|
const key = await crypto.subtle.importKey(
|
|
"raw",
|
|
encoder.encode(secret),
|
|
{ name: "HMAC", hash: "SHA-256" },
|
|
false,
|
|
["sign", "verify"],
|
|
);
|
|
|
|
const signatureInput = encoder.encode(`${headerB64}.${payloadB64}`);
|
|
const signature = base64UrlDecode(signatureB64);
|
|
|
|
const valid = await crypto.subtle.verify("HMAC", key, signature, signatureInput);
|
|
if (!valid) {
|
|
return { ok: false, error: "invalid signature" };
|
|
}
|
|
|
|
// Decode header — must be HS256
|
|
const header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64))) as { alg?: string };
|
|
if (header.alg !== "HS256") {
|
|
return { ok: false, error: `unsupported algorithm: ${header.alg}` };
|
|
}
|
|
|
|
// Decode payload
|
|
const payload = JSON.parse(
|
|
new TextDecoder().decode(base64UrlDecode(payloadB64)),
|
|
) as SyncTokenPayload;
|
|
|
|
// Check expiry
|
|
const now = Math.floor(Date.now() / 1000);
|
|
if (payload.exp && payload.exp < now) {
|
|
return { ok: false, error: "token expired" };
|
|
}
|
|
|
|
// Check iat not in the future (30s tolerance)
|
|
if (payload.iat && payload.iat > now + 30) {
|
|
return { ok: false, error: "token issued in the future" };
|
|
}
|
|
|
|
// JTI dedup
|
|
if (!payload.jti) {
|
|
return { ok: false, error: "missing jti" };
|
|
}
|
|
if (usedJtis.has(payload.jti)) {
|
|
return { ok: false, error: "token already used" };
|
|
}
|
|
// Mark as used with expiry time
|
|
usedJtis.set(payload.jti, (payload.exp ?? now + 900) * 1000);
|
|
|
|
// Basic validation
|
|
if (!payload.sub || !payload.email) {
|
|
return { ok: false, error: "missing sub or email" };
|
|
}
|
|
if (!Array.isArray(payload.meshes)) {
|
|
return { ok: false, error: "missing meshes array" };
|
|
}
|
|
|
|
return { ok: true, payload };
|
|
} catch (e) {
|
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
function base64UrlDecode(input: string): Uint8Array {
|
|
// Add padding
|
|
let base64 = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
while (base64.length % 4) base64 += "=";
|
|
const binary = atob(base64);
|
|
const bytes = new Uint8Array(binary.length);
|
|
for (let i = 0; i < binary.length; i++) {
|
|
bytes[i] = binary.charCodeAt(i);
|
|
}
|
|
return bytes;
|
|
}
|