Files
claudemesh/apps/broker/src/jwt.ts
Alejandro Gutiérrez f013436541 chore(broker): typecheck clean (77 → 0)
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>
2026-05-04 13:22:09 +01:00

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