import { err, ok, type CommandContext } from "@tailor-platform/erp-kit/core"; import type { Transaction } from "../generated/kysely-tailordb"; import { InvalidEventError, SignatureInvalidError } from "../lib/errors.generated"; // Slack signs event deliveries with `v0=HMAC-SHA256(signingSecret, // "v0:{timestamp}:{rawBody}")` and a request timestamp header. Requests older // (or newer) than 5 minutes are rejected to prevent replay. const SIGNATURE_VERSION = "v0"; const TIMESTAMP_TOLERANCE_SECONDS = 60 * 5; export interface HandleSlackAppUninstalledInput { /** Raw (unparsed) request body of the Slack event delivery. */ rawBody: string; /** `X-Slack-Request-Timestamp` header value (unix seconds). */ timestamp: string; /** `X-Slack-Signature` header value (`v0=`). */ signature: string; /** Slack app signing secret used to verify the request. */ signingSecret: string; } function hexToBytes(hex: string): Uint8Array | null { if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null; const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } return bytes; } /** * Verifies the Slack request signature with Web Crypto. `crypto.subtle.verify` * performs a constant-time comparison. */ async function verifySlackSignature(input: HandleSlackAppUninstalledInput): Promise { if (!input.signature.startsWith(`${SIGNATURE_VERSION}=`)) return false; const signatureBytes = hexToBytes(input.signature.slice(SIGNATURE_VERSION.length + 1)); if (!signatureBytes) return false; const key = await crypto.subtle.importKey( "raw", new TextEncoder().encode(input.signingSecret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"], ); const baseString = `${SIGNATURE_VERSION}:${input.timestamp}:${input.rawBody}`; return crypto.subtle.verify("HMAC", key, signatureBytes, new TextEncoder().encode(baseString)); } export async function run( db: Transaction, input: HandleSlackAppUninstalledInput, ctx: CommandContext, ) { void ctx; if (!input.rawBody || !input.timestamp || !input.signature || !input.signingSecret) { return err(new SignatureInvalidError("missing-signature-material")); } // Replay protection: reject requests whose timestamp is more than 5 minutes // away from now (mirrors the inline `new Date()` clock used below). const timestampSeconds = Number(input.timestamp); if (!Number.isFinite(timestampSeconds)) { return err(new SignatureInvalidError("invalid-timestamp")); } if (Math.abs(Date.now() / 1000 - timestampSeconds) > TIMESTAMP_TOLERANCE_SECONDS) { return err(new SignatureInvalidError("stale-timestamp")); } if (!(await verifySlackSignature(input))) { return err(new SignatureInvalidError("signature-mismatch")); } // The teamId is taken from the signature-verified body only — never from a // separately supplied (spoofable) input field. let teamId: string | undefined; try { // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- runtime JSON boundary const event = JSON.parse(input.rawBody) as { team_id?: unknown }; if (typeof event.team_id === "string" && event.team_id) teamId = event.team_id; } catch { return err(new InvalidEventError("malformed-event-body")); } if (!teamId) return err(new InvalidEventError("missing-team-id")); const workspace = await db .selectFrom("SlackWorkspaceIntegration") .selectAll() .where("teamId", "=", teamId) .executeTakeFirst(); if (!workspace) return ok({ revoked: false, slackWorkspaceIntegration: null }); const now = new Date(); const slackWorkspaceIntegration = await db .updateTable("SlackWorkspaceIntegration") .set({ status: "REVOKED", revokedAt: now, updatedAt: now }) .where("id", "=", workspace.id) .returningAll() .executeTakeFirst(); return ok({ revoked: true, slackWorkspaceIntegration }); }