import type { Request, Response } from "express";
import { z } from "zod";
import crypto from "node:crypto";
import pino from "pino";
import type { InstallStore, SlackInstallation } from "./installStore.js";
const log = pino({ level: process.env.LOG_LEVEL ?? "info" });
const CallbackQuery = z.object({
code: z.string().min(1),
state: z.string().min(1)
});
type OauthDeps = {
store: InstallStore;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes: string; // comma-separated
publicBaseUrl: string;
};
// In production: store state in DB/Redis keyed by state, include user/session binding.
// For now: in-memory nonce cache with automatic pruning.
const stateCache = new Map<string, { createdAt: number }>();
const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes
function pruneStateCache() {
const now = Date.now();
let pruned = 0;
for (const [state, data] of stateCache.entries()) {
if (now - data.createdAt > STATE_TTL_MS) {
stateCache.delete(state);
pruned++;
}
}
if (pruned > 0) {
log.debug({ pruned, remaining: stateCache.size }, "Pruned expired OAuth states");
}
}
// Prune state cache every 5 minutes
setInterval(pruneStateCache, 5 * 60 * 1000);
function newState() {
const s = crypto.randomUUID();
stateCache.set(s, { createdAt: Date.now() });
return s;
}
function validateState(state: string): boolean {
const v = stateCache.get(state);
if (!v) return false;
if (Date.now() - v.createdAt > STATE_TTL_MS) {
stateCache.delete(state);
return false;
}
stateCache.delete(state); // Consume the state (one-time use)
return true;
}
export function slackInstallHandler(deps: OauthDeps) {
return async (req: Request, res: Response) => {
try {
const state = newState();
log.info({ ip: req.ip, statesInCache: stateCache.size }, "Slack OAuth install initiated");
// Slack authorize URL (v2)
// You can also include user_scope if you want user tokens; for bot-only, omit user_scope.
const url =
"https://slack.com/oauth/v2/authorize" +
`?client_id=${encodeURIComponent(deps.clientId)}` +
`&scope=${encodeURIComponent(deps.scopes)}` +
`&redirect_uri=${encodeURIComponent(deps.redirectUri)}` +
`&state=${encodeURIComponent(state)}`;
res.redirect(url);
} catch (err) {
log.error({ err }, "Failed to initiate Slack OAuth");
res.status(500).send("Failed to initiate OAuth flow");
}
};
}
export function slackOauthCallbackHandler(deps: OauthDeps) {
return async (req: Request, res: Response) => {
try {
const parsed = CallbackQuery.safeParse(req.query);
if (!parsed.success) {
log.warn({ query: req.query, errors: parsed.error }, "Invalid OAuth callback params");
res.status(400).send("Invalid callback params");
return;
}
const { code, state } = parsed.data;
if (!validateState(state)) {
log.warn({ state, ip: req.ip }, "Invalid or expired OAuth state");
res.status(400).send("Invalid or expired state. Please try the installation again.");
return;
}
// Exchange code -> tokens (Slack oauth.v2.access)
const form = new URLSearchParams();
form.set("client_id", deps.clientId);
form.set("client_secret", deps.clientSecret);
form.set("code", code);
form.set("redirect_uri", deps.redirectUri);
const r = await fetch("https://slack.com/api/oauth.v2.access", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form
});
if (!r.ok) {
log.error({ status: r.status, statusText: r.statusText }, "Slack OAuth API request failed");
res.status(500).send("Failed to complete OAuth flow");
return;
}
const data: any = await r.json();
if (!data.ok) {
log.error({ error: data.error, details: data }, "Slack OAuth failed");
res.status(400).send(`Slack OAuth failed: ${data.error ?? "unknown_error"}`);
return;
}
// Slack returns bot token in access_token for bot installs; also team info.
const install: SlackInstallation = {
team_id: data.team?.id,
enterprise_id: data.enterprise?.id ?? null,
bot_access_token: data.access_token,
bot_user_id: data.bot_user_id ?? null,
authed_user_id: data.authed_user?.id ?? null,
refresh_token: data.refresh_token ?? null,
expires_at: data.expires_in ? Date.now() + Number(data.expires_in) * 1000 : null,
scope: data.scope ?? null,
installed_at: Date.now()
};
if (!install.team_id || !install.bot_access_token) {
log.error({ data }, "Slack OAuth response missing required fields");
res.status(500).send("Slack OAuth response missing required fields");
return;
}
await deps.store.upsert(install);
log.info({
team_id: install.team_id,
enterprise_id: install.enterprise_id,
has_refresh_token: !!install.refresh_token,
scopes: install.scope
}, "Slack workspace connected successfully");
// Redirect to a "connected" page in AffinityBots or show success.
res.status(200).send(
`✅ Slack connected successfully for workspace ${install.team_id}. You can close this window.`
);
} catch (err) {
log.error({ err }, "OAuth callback handler failed");
res.status(500).send("An error occurred during OAuth. Please try again.");
}
};
}