From 5a18940897654a9850a868ef21ba5965e69252eb Mon Sep 17 00:00:00 2001 From: Andrei Jiroh Halili Date: Sun, 21 Jul 2024 16:48:54 +0800 Subject: [PATCH] feat(golinks): update backend code for Slack slash commands Signed-off-by: Andrei Jiroh Halili --- .yarnrc.yml | 2 +- apps/golinks-v2/src/api/slack.ts | 76 +++++++++++++++++++++++++++++--- apps/golinks-v2/src/index.tsx | 2 +- apps/golinks-v2/src/lib/auth.ts | 4 ++ apps/golinks-v2/src/types.ts | 12 +++++ apps/golinks-v2/wrangler.toml | 1 + 6 files changed, 89 insertions(+), 8 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index f46ce91..951a379 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,2 +1,2 @@ -yarnPath: .yarn/releases/yarn-4.2.2.cjs nodeLinker: node-modules +yarnPath: .yarn/releases/yarn-4.3.1.cjs diff --git a/apps/golinks-v2/src/api/slack.ts b/apps/golinks-v2/src/api/slack.ts index ee49c92..5e0cbbd 100644 --- a/apps/golinks-v2/src/api/slack.ts +++ b/apps/golinks-v2/src/api/slack.ts @@ -1,5 +1,6 @@ import fetch from "cross-fetch"; import { Context } from "hono"; +import crypto from "node:crypto"; /** * Handle requests for OAuth-based app installation @@ -32,7 +33,11 @@ export async function slackOAuth(context: Context) { body: formBody, }); const result = await api.json() - return context.json({ ok: true, result: result }); + console.log(`[slack-oauth] result: ${JSON.stringify(result)} (${api.status})`); + if (result.ok == false) { + return context.json({ ok: false, error: result.error }, api.status); + } + return context.json({ ok: true, result: "Successfully installed." }); } catch (err) { console.error(err); return context.json({ ok: false, error: "Something gone wrong" }); @@ -40,9 +45,68 @@ export async function slackOAuth(context: Context) { } } -export function handleSlackCommand(context: Context) { - const headers = context.req.header() - const data = context.req.parseBody() - console.log(JSON.stringify(data)) - return context.newResponse("Still working in it.") +/** + * Function handler for `/api/slack/slash-commands/:command` POST requests + * on Hono. + * @param context The `context` object from Hono. + * @returns + */ +export async function handleSlackCommand(context: Context) { + // 1. Get Signing Secret and Request Body + const signingSecret = context.env.SLACK_SIGNING_SECRET; // Access signing secret from env + const body = await context.req.arrayBuffer(); + + // 2. Get Request Headers + const timestamp = context.req.header("X-Slack-Request-Timestamp"); + const signature = context.req.header("X-Slack-Signature"); + + // 3. Validate Request Timestamp (optional) + if (!validateTimestamp(timestamp)) { + return new Response("Request is too old.", { status: 403 }); + } + + // 4. Calculate Base String + const baseString = `v0:${timestamp}:${await createHashedBody(body)}`; + + // 5. Calculate Expected Signature + const expectedSignature = `sha256=${crypto.createHmac("sha256", signingSecret).update(baseString).digest("hex")}`; + await console.log(`[slack-slash-commands]: expected: ${expectedSignature}, received: ${signature}`); + + // 6. Validate Signature + if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) { + return new Response("Invalid request signature.", { status: 403 }); + } + + const { command } = await context.req.param(); + const data = JSON.parse(await context.req.text()); // Parse body as JSON + + await console.log(`[slack-slash-commands] params: ${JSON.stringify(data)}`); + await console.log(`[slack-slash-commands] headers: ${JSON.stringify({ timestamp, command })}`); + + if (command === "go") { + return context.newResponse("Still working in it.", 200, { "Content-Type": "text/plain" }); + } + return context.newResponse("Unsupported command"); +} + +/** + * Validate request timestamps to ensure that we don't received forged requests + * within 3-5 minutes + * @param timestamp The request timestamp in string form + * @returns + */ +function validateTimestamp(timestamp: string): boolean { + if (!timestamp) { + return false; + } + const currentTimestamp = Date.now() + const requestTimestamp = new Date(timestamp) + const delta = Math.abs(currentTimestamp - requestTimestamp) / (1000 * 60); + return delta >= 3 && delta <= 5; +} + +async function createHashedBody(body: ArrayBuffer): Promise { + const hash = crypto.createHash("sha256"); + hash.update(body); + return hash.digest("hex"); } diff --git a/apps/golinks-v2/src/index.tsx b/apps/golinks-v2/src/index.tsx index e3e9970..7d9902a 100644 --- a/apps/golinks-v2/src/index.tsx +++ b/apps/golinks-v2/src/index.tsx @@ -73,7 +73,7 @@ openapi.get("/api/discord-invites", DiscordInviteLinkList); openapi.post("/api/discord-invites", DiscordInviteLinkCreate); openapi.get("/api/ping", PingPong); openapi.get("/api/commit", CommitHash); -app.post("/api/slack-slash-commands/:command", handleSlackCommand) +app.post("/api/slack/slash-commands/:command", async (c) => handleSlackCommand(c)) // Slack bot and slash commands app.get("/slack", async(c) => slackOAuth(c)); diff --git a/apps/golinks-v2/src/lib/auth.ts b/apps/golinks-v2/src/lib/auth.ts index 1ccab7a..41bd282 100644 --- a/apps/golinks-v2/src/lib/auth.ts +++ b/apps/golinks-v2/src/lib/auth.ts @@ -5,6 +5,10 @@ export async function adminApiKeyAuth(c: Context, next: Next) { return await next(); } + if (c.req.path.startsWith("/api/slack")) { + return await next() + } + const adminApiKey = c.env.ADMIN_KEY; const apiKeyHeader = c.req.header("X-Golinks-Admin-Key"); console.debug(`[auth] ${adminApiKey}:${apiKeyHeader}`); diff --git a/apps/golinks-v2/src/types.ts b/apps/golinks-v2/src/types.ts index 2afd35d..1edb234 100644 --- a/apps/golinks-v2/src/types.ts +++ b/apps/golinks-v2/src/types.ts @@ -33,6 +33,12 @@ export const DiscordInvites = z.object({ export interface Env { DEPLOY_ENV: "production" | "staging" | "development"; GIT_DEPLOY_COMMIT: string; + SLACK_OAUTH_ID: string + SLACK_OAUTH_SECRET: string, + SLACK_OAUTH_CALLBACK_URL: string, + SLACK_SIGNING_SECRET: string, + GITHUB_OAUTH_ID: string, + GITHUB_OAUTH_SECRET: string, golinks: D1Database; ADMIN_KEY: string; } @@ -42,4 +48,10 @@ export type EnvBindings = { DEPLOY_ENV: "production" | "staging" | "development"; ADMIN_KEY: string; GIT_DEPLOY_COMMIT: string; + SLACK_OAUTH_ID: string; + SLACK_OAUTH_SECRET: string; + SLACK_OAUTH_CALLBACK_URL: string; + SLACK_SIGNING_SECRET: string; + GITHUB_OAUTH_ID: string; + GITHUB_OAUTH_SECRET: string; }; diff --git a/apps/golinks-v2/wrangler.toml b/apps/golinks-v2/wrangler.toml index f59366d..5c651d9 100644 --- a/apps/golinks-v2/wrangler.toml +++ b/apps/golinks-v2/wrangler.toml @@ -13,6 +13,7 @@ compatibility_date = "2024-07-12" # TODO: Update this once a month account_id = "cf0bd808c6a294fd8c4d8f6d2cdeca05" placement = { mode = "smart" } +compatibility_flags = [ "nodejs_compat" ] # Please do not leak your secrets here. vars = { DEPLOY_ENV = "development", ADMIN_KEY = "gostg_localdev-null" }