diff --git a/src/llm-key-crypto.ts b/src/llm-key-crypto.ts index a054ec2..64089d2 100644 --- a/src/llm-key-crypto.ts +++ b/src/llm-key-crypto.ts @@ -1,22 +1,27 @@ /** - * LLM provider key encryption. + * Symmetric encryption for sensitive operational secrets — LLM provider keys, + * Slack bot tokens, and any future per-tenant credential we need to round-trip + * through SQLite. * - * Stores customer-supplied LLM API keys (Anthropic, etc.) encrypted at rest. - * Risk model: keys are valuable but their abuse is bounded to financial loss - * for the customer's LLM provider account, not data exfiltration. We aim for - * "credibly secure," not "vault-grade." + * Risk model: secrets are valuable but their abuse is bounded (financial loss + * for the customer's LLM provider account; access to a single Slack workspace + * for the bot token). We aim for "credibly secure," not "vault-grade." * * Scheme: * - Master key from env `RM_LLM_KEY_ENCRYPTION_KEY` (64 hex chars = 32 bytes). - * - Per-tenant sub-key derived via HKDF-SHA256(masterKey, salt=scopeId, info) - * so a leaked encrypted blob alone is useless without knowing the team_id - * or user_id; rotation per tenant becomes trivial. + * Single env var covers all uses; sub-keys are namespaced by salt. + * - Per-tenant sub-key derived via HKDF-SHA256(masterKey, salt=saltString, + * info) so a leaked encrypted blob alone is useless without knowing the + * tenant identifier; rotation per tenant becomes trivial. * - AES-256-GCM with a random 12-byte nonce per write. - * - Storage: `key_encrypted` BLOB = ciphertext || authTag (16-byte tag at - * the end); `key_nonce` BLOB = the 12-byte nonce; `key_last4` TEXT for UI. + * - Storage: `ciphertext` BLOB = encrypted payload || authTag (16-byte tag + * at the end); `nonce` BLOB = the 12-byte nonce. * * The master key is validated lazily on the first encrypt/decrypt call so - * the API can boot without it (only fails when a key feature is exercised). + * the API can boot without it (only fails when a feature that needs it is + * exercised). Domain wrappers like the LLM key service and the Slack + * workspace service build their own salt strings, e.g. `team:` or + * `slack:`, and call encryptString / decryptString. */ import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto"; @@ -87,39 +92,25 @@ export function _resetMasterKeyCacheForTests(): void { cachedMasterKeyError = null; } -export interface KeyScope { - /** Exactly one of teamId or userId must be set (matches the llm_keys CHECK constraint). */ - teamId?: string | null; - userId?: string | null; -} - -function scopeId(scope: KeyScope): string { - if (scope.teamId && scope.userId) { - throw new Error("KeyScope must have exactly one of teamId or userId, not both"); +function deriveSubKey(saltString: string): Buffer { + if (!saltString || saltString.trim().length === 0) { + throw new Error("Salt string is required for key derivation"); } - if (scope.teamId) return `team:${scope.teamId}`; - if (scope.userId) return `user:${scope.userId}`; - throw new Error("KeyScope must have one of teamId or userId"); -} - -function deriveSubKey(scope: KeyScope): Buffer { const master = loadMasterKey(); - const salt = Buffer.from(scopeId(scope), "utf8"); + const salt = Buffer.from(saltString, "utf8"); const derived = hkdfSync("sha256", master, salt, HKDF_INFO, SUBKEY_BYTES); return Buffer.from(derived); } -export interface EncryptedKey { +export interface EncryptedBlob { /** Ciphertext concatenated with the GCM auth tag (tag is the last 16 bytes). */ ciphertext: Buffer; /** 12-byte GCM nonce. Random per encrypt call. */ nonce: Buffer; - /** Cleartext, for UI display only. Never includes the full key. */ - last4: string; } /** - * Extracts the last 4 characters of the key for UI display. Strips trailing + * Extracts the last 4 characters of a string for UI display. Strips trailing * whitespace defensively. */ export function extractLast4(plaintext: string): string { @@ -129,14 +120,15 @@ export function extractLast4(plaintext: string): string { } /** - * Encrypts a plaintext LLM key for the given scope. Returns the ciphertext+tag, - * nonce, and last4 ready to persist. + * Generic primitive: encrypts a plaintext string with a sub-key derived from + * the given salt. Domain wrappers (LLM keys, Slack tokens) build their own + * salt strings, e.g. `team:` or `slack:`. */ -export function encryptLlmKey(plaintext: string, scope: KeyScope): EncryptedKey { +export function encryptString(plaintext: string, saltString: string): EncryptedBlob { if (!plaintext || plaintext.trim().length === 0) { - throw new Error("Cannot encrypt empty LLM key"); + throw new Error("Cannot encrypt empty string"); } - const subKey = deriveSubKey(scope); + const subKey = deriveSubKey(saltString); const nonce = randomBytes(NONCE_BYTES); const cipher = createCipheriv(ALGORITHM, subKey, nonce); const ciphertext = Buffer.concat([ @@ -150,18 +142,17 @@ export function encryptLlmKey(plaintext: string, scope: KeyScope): EncryptedKey return { ciphertext: Buffer.concat([ciphertext, tag]), nonce, - last4: extractLast4(plaintext), }; } /** - * Decrypts an LLM key for the given scope. Throws if the scope doesn't match - * the one used to encrypt (HKDF derives a different sub-key) or if the - * ciphertext was tampered with (GCM auth tag mismatch). + * Generic primitive: decrypts a string previously encrypted with encryptString + * using the same salt. Throws if the salt doesn't match (HKDF derives a + * different sub-key) or if the ciphertext was tampered with (GCM auth tag). */ -export function decryptLlmKey( +export function decryptString( encrypted: { ciphertext: Buffer; nonce: Buffer }, - scope: KeyScope, + saltString: string, ): string { if (encrypted.nonce.length !== NONCE_BYTES) { throw new Error(`Invalid nonce length: ${encrypted.nonce.length} (expected ${NONCE_BYTES})`); @@ -171,10 +162,84 @@ export function decryptLlmKey( `Ciphertext too short to contain auth tag: ${encrypted.ciphertext.length} bytes`, ); } - const subKey = deriveSubKey(scope); + const subKey = deriveSubKey(saltString); const ciphertext = encrypted.ciphertext.subarray(0, encrypted.ciphertext.length - TAG_BYTES); const tag = encrypted.ciphertext.subarray(encrypted.ciphertext.length - TAG_BYTES); const decipher = createDecipheriv(ALGORITHM, subKey, encrypted.nonce); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); } + +// --------------------------------------------------------------------------- +// LLM key domain wrappers +// --------------------------------------------------------------------------- + +export interface KeyScope { + /** Exactly one of teamId or userId must be set (matches the llm_keys CHECK constraint). */ + teamId?: string | null; + userId?: string | null; +} + +function llmKeySalt(scope: KeyScope): string { + if (scope.teamId && scope.userId) { + throw new Error("KeyScope must have exactly one of teamId or userId, not both"); + } + if (scope.teamId) return `team:${scope.teamId}`; + if (scope.userId) return `user:${scope.userId}`; + throw new Error("KeyScope must have one of teamId or userId"); +} + +export interface EncryptedKey extends EncryptedBlob { + /** Cleartext, for UI display only. Never includes the full key. */ + last4: string; +} + +/** + * Encrypts a plaintext LLM key for the given scope. Returns the ciphertext+tag, + * nonce, and last4 ready to persist. + */ +export function encryptLlmKey(plaintext: string, scope: KeyScope): EncryptedKey { + const blob = encryptString(plaintext, llmKeySalt(scope)); + return { ...blob, last4: extractLast4(plaintext) }; +} + +/** + * Decrypts an LLM key for the given scope. Throws if the scope doesn't match + * the one used to encrypt (HKDF derives a different sub-key) or if the + * ciphertext was tampered with (GCM auth tag mismatch). + */ +export function decryptLlmKey( + encrypted: { ciphertext: Buffer; nonce: Buffer }, + scope: KeyScope, +): string { + return decryptString(encrypted, llmKeySalt(scope)); +} + +// --------------------------------------------------------------------------- +// Slack bot token domain wrappers +// --------------------------------------------------------------------------- + +/** + * Encrypts a Slack bot token (xoxb-...) for the given Slack team. The Slack + * team ID is used as the HKDF salt so each workspace's bot token is encrypted + * under a different sub-key. + */ +export function encryptSlackBotToken(plaintext: string, slackTeamId: string): EncryptedBlob { + if (!slackTeamId || slackTeamId.trim().length === 0) { + throw new Error("slackTeamId is required for Slack bot token encryption"); + } + return encryptString(plaintext, `slack:${slackTeamId}`); +} + +/** + * Decrypts a Slack bot token previously encrypted with encryptSlackBotToken. + */ +export function decryptSlackBotToken( + encrypted: { ciphertext: Buffer; nonce: Buffer }, + slackTeamId: string, +): string { + if (!slackTeamId || slackTeamId.trim().length === 0) { + throw new Error("slackTeamId is required for Slack bot token decryption"); + } + return decryptString(encrypted, `slack:${slackTeamId}`); +} diff --git a/src/server.ts b/src/server.ts index be64afb..1e5c38e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,6 +38,22 @@ import { type LlmProvider, } from "./llm-key-service.js"; import { tryValidateMasterKey } from "./llm-key-crypto.js"; +import { + buildInstallUrl, + exchangeOauthCode, + getActiveSlackConfig, + revokeSlackToken, + signOauthState, + verifyOauthState, +} from "./slack-oauth.js"; +import { + getActiveWorkspaceForTeam, + getActiveWorkspaceForUser, + getWorkspaceWithToken, + softDeleteWorkspace, + upsertSlackWorkspace, + type SlackWorkspace, +} from "./slack-workspace-service.js"; import { generateApiKey, listApiKeys, @@ -886,6 +902,11 @@ export async function createServer(config: ServerConfig): Promise dashboard /api/slack/install (Clerk auth) + // -> backend POST /slack/install-url (admin) -> { url, state } + // -> dashboard 302 to slack.com/oauth/v2/authorize?...&state= + // -> user authorises in Slack + // -> Slack redirects browser to /slack/oauth/callback?code=&state= + // -> backend verifies state, exchanges code, persists workspace + // -> backend 302 to dashboard's connections page + // + // /slack/events does not exist yet; lands in Phase 3. + // =========================================================================== + + function workspaceToPublic(ws: SlackWorkspace): Record { + return { + id: ws.id, + slack_team_id: ws.slackTeamId, + slack_team_name: ws.slackTeamName, + bot_user_id: ws.botUserId, + reflect_team_id: ws.reflectTeamId, + reflect_user_id: ws.reflectUserId, + installed_by_user_id: ws.installedByUserId, + installed_at: ws.installedAt, + uninstalled_at: ws.uninstalledAt, + created_at: ws.createdAt, + updated_at: ws.updatedAt, + }; + } + + function getWorkspaceForCallerScope(uid: string): SlackWorkspace | null { + const teamId = getUserTeamId(uid); + if (teamId) return getActiveWorkspaceForTeam(db, teamId); + return getActiveWorkspaceForUser(db, uid); + } + + // POST /slack/install-url + // Admin-only. Returns the slack.com authorize URL the dashboard should + // redirect the browser to. State is signed and carries the requesting + // user's id, so the callback can resolve identity without a session. + server.post( + "/slack/install-url", + { config: { rateLimit: { max: 10, timeWindow: "1 minute" } } }, + async (request, reply) => { + if (!ownerUserIds.has(request.userId)) { + reply.code(403); + return { error: "Admin access required" }; + } + if (!tryValidateMasterKey()) { + reply.code(503); + return { + error: + "Encryption not configured (RM_LLM_KEY_ENCRYPTION_KEY). Set it before installing Slack.", + }; + } + const cfg = getActiveSlackConfig(); + if (!cfg) { + reply.code(503); + return { + error: + "Slack OAuth is not configured on this instance. Set REFLECT_DEV_SLACK_* (or REFLECT_PROD_SLACK_*) env vars.", + }; + } + const state = signOauthState(request.userId); + const url = buildInstallUrl(cfg, state); + return { url, state, redirect_uri: cfg.redirectUri }; + }, + ); + + // GET /slack/oauth/callback + // No auth header; state HMAC-verifies the originating Reflect user. + // Slack's redirect lands here; on success we 302 the browser back to the + // dashboard's Slack settings page with installed=1. + server.get( + "/slack/oauth/callback", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (request, reply) => { + const q = request.query as Record; + + // Where to send the browser at the end of the flow. Configurable so + // dev and prod redirect to their own dashboards. + const dashboardBase = + process.env.RM_DASHBOARD_PUBLIC_URL ?? + process.env.RM_DASHBOARD_URL ?? + "https://reflectmemory.com"; + const successUrl = `${dashboardBase}/dashboard/connections/slack?installed=1`; + const errorUrl = (msg: string): string => + `${dashboardBase}/dashboard/connections/slack?error=${encodeURIComponent(msg)}`; + + if (q.error) { + request.log.info({ slackError: q.error }, "Slack returned an error to OAuth callback"); + return reply.redirect(errorUrl(`Slack returned: ${q.error}`)); + } + if (!q.code || !q.state) { + return reply.redirect(errorUrl("Missing code or state parameter")); + } + + const verified = verifyOauthState(q.state); + if (!verified) { + return reply.redirect(errorUrl("Invalid or expired install link. Please try again.")); + } + + const cfg = getActiveSlackConfig(); + if (!cfg) { + return reply.redirect(errorUrl("Slack OAuth is not configured on this instance")); + } + + const exchange = await exchangeOauthCode(cfg, q.code); + if (!exchange.ok) { + request.log.warn({ err: exchange.error }, "Slack OAuth code exchange failed"); + return reply.redirect(errorUrl(`OAuth exchange failed: ${exchange.error}`)); + } + + // Bind the workspace to the installer's team if they have one, else solo. + const installerTeamId = getUserTeamId(verified.reflectUserId); + try { + upsertSlackWorkspace(db, { + slackTeamId: exchange.slackTeamId, + slackTeamName: exchange.slackTeamName, + reflectTeamId: installerTeamId, + reflectUserId: installerTeamId ? null : verified.reflectUserId, + botUserId: exchange.botUserId, + botToken: exchange.botToken, + installedByUserId: verified.reflectUserId, + requestId: request.id, + }); + } catch (err) { + request.log.error({ err }, "Failed to persist Slack workspace"); + return reply.redirect( + errorUrl("Could not save the Slack install. The error has been logged."), + ); + } + + return reply.redirect(successUrl); + }, + ); + + // GET /slack/status + // Admin-only. Returns the active workspace for the caller's scope, or null. + // Never returns the bot token. + server.get( + "/slack/status", + { config: { rateLimit: { max: 60, timeWindow: "1 minute" } } }, + async (request, reply) => { + if (!ownerUserIds.has(request.userId)) { + reply.code(403); + return { error: "Admin access required" }; + } + const cfg = getActiveSlackConfig(); + const workspace = getWorkspaceForCallerScope(request.userId); + return { + configured: cfg !== null, + workspace: workspace ? workspaceToPublic(workspace) : null, + }; + }, + ); + + // DELETE /slack/uninstall + // Admin-only. Soft-deletes the workspace bound to the caller's scope and + // best-effort revokes the bot token via Slack's auth.revoke endpoint. + server.delete( + "/slack/uninstall", + { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, + async (request, reply) => { + if (!ownerUserIds.has(request.userId)) { + reply.code(403); + return { error: "Admin access required" }; + } + const workspace = getWorkspaceForCallerScope(request.userId); + if (!workspace) { + reply.code(404); + return { error: "No active Slack workspace for your account" }; + } + + // Best-effort token revoke before we soft-delete (need the plaintext + // token, which softDelete drops by virtue of clearing nothing — but + // we mark the row uninstalled so future getActiveWorkspace... won't + // surface it). + const withToken = getWorkspaceWithToken(db, workspace.slackTeamId); + if (withToken) { + await revokeSlackToken(withToken.botToken); + } + + softDeleteWorkspace(db, { + slackTeamId: workspace.slackTeamId, + actorUserId: request.userId, + requestId: request.id, + }); + return { uninstalled: true, slack_team_id: workspace.slackTeamId }; + }, + ); + // =========================================================================== // POST /memories -- Create a memory (user path) // =========================================================================== diff --git a/src/slack-oauth.ts b/src/slack-oauth.ts new file mode 100644 index 0000000..14546bb --- /dev/null +++ b/src/slack-oauth.ts @@ -0,0 +1,249 @@ +/** + * Slack OAuth helpers — state signing, install URL building, code exchange. + * + * The OAuth state parameter must: + * - Be unforgeable (CSRF protection — Slack will redirect back to our + * callback with whatever state we sent; we have to verify it's something + * WE issued, not something the attacker crafted). + * - Carry the originating Reflect user_id so the callback knows who + * installed the workspace. + * - Have a short TTL (10 minutes) so a leaked URL doesn't allow indefinite + * install redirection. + * + * We HMAC-SHA256 the (userId, timestamp, nonce) tuple with a secret derived + * from the encryption master key (so we don't need yet another env var). + */ + +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { decryptString, encryptString } from "./llm-key-crypto.js"; + +const STATE_TTL_MS = 10 * 60 * 1000; +const STATE_SALT = "slack:oauth-state-v1"; + +/** + * Signs an OAuth state for the given Reflect user. The state is opaque and + * URL-safe; verifyState returns the user_id back if valid. + */ +export function signOauthState(reflectUserId: string): string { + const payload = JSON.stringify({ + u: reflectUserId, + t: Date.now(), + n: randomBytes(8).toString("hex"), + }); + const blob = encryptString(payload, STATE_SALT); + // Pack as: base64url(nonce) . base64url(ciphertext) + return `${blob.nonce.toString("base64url")}.${blob.ciphertext.toString("base64url")}`; +} + +export interface VerifiedState { + reflectUserId: string; + ageMs: number; +} + +export function verifyOauthState(state: string): VerifiedState | null { + const parts = state.split("."); + if (parts.length !== 2) return null; + let nonce: Buffer; + let ciphertext: Buffer; + try { + nonce = Buffer.from(parts[0], "base64url"); + ciphertext = Buffer.from(parts[1], "base64url"); + } catch { + return null; + } + let payloadJson: string; + try { + payloadJson = decryptString({ nonce, ciphertext }, STATE_SALT); + } catch { + return null; + } + let parsed: { u?: unknown; t?: unknown; n?: unknown }; + try { + parsed = JSON.parse(payloadJson); + } catch { + return null; + } + if (typeof parsed.u !== "string" || typeof parsed.t !== "number") { + return null; + } + const ageMs = Date.now() - parsed.t; + if (ageMs < 0 || ageMs > STATE_TTL_MS) return null; + return { reflectUserId: parsed.u, ageMs }; +} + +export interface SlackOauthConfig { + clientId: string; + clientSecret: string; + signingSecret: string; + redirectUri: string; +} + +export function loadSlackOauthConfig(envPrefix: "REFLECT_DEV_SLACK" | "REFLECT_PROD_SLACK"): SlackOauthConfig | null { + const clientId = process.env[`${envPrefix}_CLIENT_ID`]; + const clientSecret = process.env[`${envPrefix}_CLIENT_SECRET`]; + const signingSecret = process.env[`${envPrefix}_SIGNING_SECRET`]; + const redirectUri = process.env[`${envPrefix}_REDIRECT_URI`]; + if (!clientId || !clientSecret || !signingSecret || !redirectUri) { + return null; + } + return { + clientId: clientId.trim(), + clientSecret: clientSecret.trim(), + signingSecret: signingSecret.trim(), + redirectUri: redirectUri.trim(), + }; +} + +/** + * Resolves which Slack OAuth config to use. Defaults to dev unless the + * RM_SLACK_ENV env explicitly says prod. Returns null if no config is set + * for the resolved env. + */ +export function getActiveSlackConfig(): SlackOauthConfig | null { + const env = (process.env.RM_SLACK_ENV ?? "dev").toLowerCase(); + if (env === "prod") return loadSlackOauthConfig("REFLECT_PROD_SLACK"); + return loadSlackOauthConfig("REFLECT_DEV_SLACK"); +} + +const BOT_SCOPES = [ + "app_mentions:read", + "chat:write", + "im:history", + "im:read", + "im:write", + "users:read", + "users:read.email", + "team:read", + "channels:read", + "groups:read", +]; + +/** + * Builds the Slack OAuth v2 authorization URL with the given signed state. + */ +export function buildInstallUrl(config: SlackOauthConfig, state: string): string { + const params = new URLSearchParams({ + client_id: config.clientId, + scope: BOT_SCOPES.join(","), + redirect_uri: config.redirectUri, + state, + }); + return `https://slack.com/oauth/v2/authorize?${params.toString()}`; +} + +export interface SlackOauthExchangeResult { + ok: true; + slackTeamId: string; + slackTeamName: string; + botUserId: string; + botToken: string; +} + +export interface SlackOauthExchangeError { + ok: false; + error: string; +} + +/** + * Exchanges an OAuth code for a bot token via Slack's oauth.v2.access endpoint. + * Returns the workspace metadata + bot token on success. + */ +export async function exchangeOauthCode( + config: SlackOauthConfig, + code: string, +): Promise { + const body = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: config.redirectUri, + }); + let res: Response; + try { + res = await fetch("https://slack.com/api/oauth.v2.access", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + } catch (err) { + return { + ok: false, + error: `Network error contacting Slack: ${err instanceof Error ? err.message : err}`, + }; + } + let data: { + ok?: boolean; + error?: string; + access_token?: string; + bot_user_id?: string; + team?: { id?: string; name?: string }; + }; + try { + data = (await res.json()) as typeof data; + } catch (err) { + return { + ok: false, + error: `Failed to parse Slack response: ${err instanceof Error ? err.message : err}`, + }; + } + if (!data.ok) { + return { ok: false, error: data.error ?? "Slack returned ok=false" }; + } + if (!data.access_token || !data.bot_user_id || !data.team?.id || !data.team?.name) { + return { ok: false, error: "Slack response missing required fields" }; + } + return { + ok: true, + slackTeamId: data.team.id, + slackTeamName: data.team.name, + botUserId: data.bot_user_id, + botToken: data.access_token, + }; +} + +/** + * Verifies a Slack request signature per the Slack signing-secret protocol. + * v0:: -> HMAC-SHA256 with signing_secret -> "v0=" + * Returns true iff: + * - The signature header matches the computed HMAC (timing-safe). + * - The timestamp is within `toleranceSeconds` of now (default 5 min). + */ +export function verifySlackSignature(options: { + signingSecret: string; + timestamp: string; + signature: string; + rawBody: string; + toleranceSeconds?: number; +}): boolean { + const tolerance = options.toleranceSeconds ?? 300; + const ts = parseInt(options.timestamp, 10); + if (!Number.isFinite(ts)) return false; + if (Math.abs(Date.now() / 1000 - ts) > tolerance) return false; + const base = `v0:${options.timestamp}:${options.rawBody}`; + const expected = `v0=${createHmac("sha256", options.signingSecret).update(base).digest("hex")}`; + if (expected.length !== options.signature.length) return false; + try { + return timingSafeEqual(Buffer.from(expected), Buffer.from(options.signature)); + } catch { + return false; + } +} + +/** + * Optional helper: revoke a bot token via Slack's auth.revoke endpoint. + * Best-effort; we don't surface the result to the user (uninstall in our DB + * has already happened). Logs failures. + */ +export async function revokeSlackToken(botToken: string): Promise { + try { + await fetch("https://slack.com/api/auth.revoke", { + method: "POST", + headers: { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + } catch { + // best-effort; nothing actionable to do here + } +} diff --git a/src/slack-workspace-service.ts b/src/slack-workspace-service.ts new file mode 100644 index 0000000..fa2b3ca --- /dev/null +++ b/src/slack-workspace-service.ts @@ -0,0 +1,302 @@ +/** + * Slack workspace service — CRUD for the `slack_workspaces` table. + * + * Persists OAuth-installed Slack workspaces, their bot tokens (encrypted at + * rest), and the link to a Reflect team or solo user. Soft-delete via + * `uninstalled_at` so we keep audit history of installs. + * + * Bot tokens are encrypted with the same crypto module as LLM keys but use a + * salt of `slack:` so each workspace's token has a different + * derived sub-key. + */ + +import { randomUUID } from "node:crypto"; +import type Database from "better-sqlite3"; + +import { recordAuditEvent } from "./audit-service.js"; +import { + decryptSlackBotToken, + encryptSlackBotToken, +} from "./llm-key-crypto.js"; + +export interface SlackWorkspace { + id: string; + slackTeamId: string; + slackTeamName: string; + reflectTeamId: string | null; + reflectUserId: string | null; + botUserId: string; + installedByUserId: string | null; + installedAt: string; + uninstalledAt: string | null; + createdAt: string; + updatedAt: string; +} + +export interface SlackWorkspaceWithToken extends SlackWorkspace { + /** Decrypted bot token (xoxb-...). Use only when actually calling Slack. */ + botToken: string; +} + +interface SlackWorkspaceRow { + id: string; + slack_team_id: string; + slack_team_name: string; + reflect_team_id: string | null; + reflect_user_id: string | null; + bot_user_id: string; + bot_token_encrypted: Buffer; + bot_token_nonce: Buffer; + installed_by_user_id: string | null; + installed_at: string; + uninstalled_at: string | null; + created_at: string; + updated_at: string; +} + +function rowToWorkspace(row: SlackWorkspaceRow): SlackWorkspace { + return { + id: row.id, + slackTeamId: row.slack_team_id, + slackTeamName: row.slack_team_name, + reflectTeamId: row.reflect_team_id, + reflectUserId: row.reflect_user_id, + botUserId: row.bot_user_id, + installedByUserId: row.installed_by_user_id, + installedAt: row.installed_at, + uninstalledAt: row.uninstalled_at, + createdAt: row.created_at, + updatedAt: row.updated_at, + }; +} + +export interface UpsertSlackWorkspaceOptions { + slackTeamId: string; + slackTeamName: string; + reflectTeamId: string | null; + reflectUserId: string | null; + botUserId: string; + botToken: string; + installedByUserId: string; + requestId?: string | null; +} + +/** + * Inserts or updates a Slack workspace install. If the slack_team_id already + * exists, the row is updated (re-install) and its uninstalled_at is cleared. + */ +export function upsertSlackWorkspace( + db: Database.Database, + options: UpsertSlackWorkspaceOptions, +): SlackWorkspace { + if (options.reflectTeamId && options.reflectUserId) { + throw new Error( + "upsertSlackWorkspace: exactly one of reflectTeamId or reflectUserId must be set", + ); + } + if (!options.reflectTeamId && !options.reflectUserId) { + throw new Error( + "upsertSlackWorkspace: one of reflectTeamId or reflectUserId is required", + ); + } + + const encrypted = encryptSlackBotToken(options.botToken, options.slackTeamId); + const now = new Date().toISOString(); + + const existing = db + .prepare( + `SELECT * FROM slack_workspaces WHERE slack_team_id = ?`, + ) + .get(options.slackTeamId) as SlackWorkspaceRow | undefined; + + let workspaceId: string; + let isNew = false; + + if (existing) { + workspaceId = existing.id; + db.prepare( + `UPDATE slack_workspaces + SET slack_team_name = ?, reflect_team_id = ?, reflect_user_id = ?, + bot_user_id = ?, bot_token_encrypted = ?, bot_token_nonce = ?, + installed_by_user_id = ?, installed_at = ?, uninstalled_at = NULL, + updated_at = ? + WHERE id = ?`, + ).run( + options.slackTeamName, + options.reflectTeamId, + options.reflectUserId, + options.botUserId, + encrypted.ciphertext, + encrypted.nonce, + options.installedByUserId, + now, + now, + existing.id, + ); + recordAuditEvent(db, { + userId: options.installedByUserId, + eventType: "slack.reinstalled", + requestId: options.requestId ?? null, + metadata: { + slack_team_id: options.slackTeamId, + slack_team_name: options.slackTeamName, + reflect_team_id: options.reflectTeamId, + reflect_user_id: options.reflectUserId, + }, + }); + } else { + workspaceId = randomUUID(); + isNew = true; + db.prepare( + `INSERT INTO slack_workspaces ( + id, slack_team_id, slack_team_name, reflect_team_id, reflect_user_id, + bot_user_id, bot_token_encrypted, bot_token_nonce, + installed_by_user_id, installed_at, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ).run( + workspaceId, + options.slackTeamId, + options.slackTeamName, + options.reflectTeamId, + options.reflectUserId, + options.botUserId, + encrypted.ciphertext, + encrypted.nonce, + options.installedByUserId, + now, + now, + now, + ); + } + + if (isNew) { + recordAuditEvent(db, { + userId: options.installedByUserId, + eventType: "slack.installed", + requestId: options.requestId ?? null, + metadata: { + slack_team_id: options.slackTeamId, + slack_team_name: options.slackTeamName, + reflect_team_id: options.reflectTeamId, + reflect_user_id: options.reflectUserId, + }, + }); + } + + const row = db + .prepare(`SELECT * FROM slack_workspaces WHERE id = ?`) + .get(workspaceId) as SlackWorkspaceRow; + return rowToWorkspace(row); +} + +/** + * Returns the active (not uninstalled) workspace bound to the given Reflect + * team, or null. Multiple workspaces can map to the same team historically + * (re-installs); this returns only the currently active one. + */ +export function getActiveWorkspaceForTeam( + db: Database.Database, + reflectTeamId: string, +): SlackWorkspace | null { + const row = db + .prepare( + `SELECT * FROM slack_workspaces + WHERE reflect_team_id = ? AND uninstalled_at IS NULL + ORDER BY installed_at DESC LIMIT 1`, + ) + .get(reflectTeamId) as SlackWorkspaceRow | undefined; + return row ? rowToWorkspace(row) : null; +} + +/** + * Returns the active workspace bound to the given solo Reflect user. + */ +export function getActiveWorkspaceForUser( + db: Database.Database, + reflectUserId: string, +): SlackWorkspace | null { + const row = db + .prepare( + `SELECT * FROM slack_workspaces + WHERE reflect_user_id = ? AND uninstalled_at IS NULL + ORDER BY installed_at DESC LIMIT 1`, + ) + .get(reflectUserId) as SlackWorkspaceRow | undefined; + return row ? rowToWorkspace(row) : null; +} + +/** + * Looks up a workspace by Slack's team ID (T0123…). Returns the active row, + * or any row if includeUninstalled is true. + */ +export function getWorkspaceBySlackTeamId( + db: Database.Database, + slackTeamId: string, + options: { includeUninstalled?: boolean } = {}, +): SlackWorkspace | null { + const row = ( + options.includeUninstalled + ? db.prepare(`SELECT * FROM slack_workspaces WHERE slack_team_id = ?`).get(slackTeamId) + : db + .prepare( + `SELECT * FROM slack_workspaces WHERE slack_team_id = ? AND uninstalled_at IS NULL`, + ) + .get(slackTeamId) + ) as SlackWorkspaceRow | undefined; + return row ? rowToWorkspace(row) : null; +} + +/** + * Returns the workspace + decrypted bot token for the given Slack team. + * Use only when actually about to call Slack — never log the result, never + * return it to clients. + */ +export function getWorkspaceWithToken( + db: Database.Database, + slackTeamId: string, +): SlackWorkspaceWithToken | null { + const row = db + .prepare( + `SELECT * FROM slack_workspaces WHERE slack_team_id = ? AND uninstalled_at IS NULL`, + ) + .get(slackTeamId) as SlackWorkspaceRow | undefined; + if (!row) return null; + const botToken = decryptSlackBotToken( + { ciphertext: row.bot_token_encrypted, nonce: row.bot_token_nonce }, + row.slack_team_id, + ); + return { ...rowToWorkspace(row), botToken }; +} + +export interface SoftDeleteWorkspaceOptions { + slackTeamId: string; + actorUserId: string; + requestId?: string | null; +} + +/** + * Marks a workspace as uninstalled. Returns true if a row was updated, false + * if no active workspace existed. + */ +export function softDeleteWorkspace( + db: Database.Database, + options: SoftDeleteWorkspaceOptions, +): boolean { + const now = new Date().toISOString(); + const result = db + .prepare( + `UPDATE slack_workspaces + SET uninstalled_at = ?, updated_at = ? + WHERE slack_team_id = ? AND uninstalled_at IS NULL`, + ) + .run(now, now, options.slackTeamId); + if (result.changes === 0) return false; + + recordAuditEvent(db, { + userId: options.actorUserId, + eventType: "slack.uninstalled", + requestId: options.requestId ?? null, + metadata: { slack_team_id: options.slackTeamId }, + }); + return true; +} diff --git a/tests/global-setup.ts b/tests/global-setup.ts index 9f2da88..75a7595 100644 --- a/tests/global-setup.ts +++ b/tests/global-setup.ts @@ -53,6 +53,11 @@ export async function setup(): Promise { const agentClaude = randomHex(32); const dashboardServiceKey = randomHex(32); const dashboardJwtSecret = randomHex(32); + // Shared with in-process tests via .test-server.json so they can encrypt / + // decrypt blobs created by the test server (e.g. directly upserting a + // slack_workspaces row in a test, then having the server's uninstall + // route decrypt the bot token). + const llmKeyMasterKey = randomHex(32); const env: NodeJS.ProcessEnv = { ...process.env, @@ -76,7 +81,15 @@ export async function setup(): Promise { // 32-byte hex master key for AES-256-GCM LLM key encryption. // Random per test-server boot so a leaked test fixture can't decrypt // anything in another env. - RM_LLM_KEY_ENCRYPTION_KEY: randomHex(32), + RM_LLM_KEY_ENCRYPTION_KEY: llmKeyMasterKey, + // Stub Slack OAuth config so /slack/install-url returns a 200 (the + // integration tests check the URL shape, not actual install). Real + // exchange against slack.com is exercised during manual smoke. + REFLECT_DEV_SLACK_CLIENT_ID: "1234567890.0987654321", + REFLECT_DEV_SLACK_CLIENT_SECRET: "test-client-secret", + REFLECT_DEV_SLACK_SIGNING_SECRET: "test-signing-secret", + REFLECT_DEV_SLACK_REDIRECT_URI: `http://127.0.0.1:${port}/slack/oauth/callback`, + RM_DASHBOARD_PUBLIC_URL: `http://127.0.0.1:${port + 1}`, RM_DASHBOARD_URL: `http://127.0.0.1:${port + 1}`, STRIPE_SECRET_KEY: "sk_test_fake", STRIPE_WEBHOOK_SECRET: "whsec_test_fake", @@ -119,6 +132,7 @@ export async function setup(): Promise { }, dashboardServiceKey, dashboardJwtSecret, + llmKeyMasterKey, ownerEmail: "owner@test.local", tmpDir, dbPath, diff --git a/tests/helpers.ts b/tests/helpers.ts index a47f379..6759ca4 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -15,6 +15,10 @@ interface TestServerConfig { }; dashboardServiceKey: string; dashboardJwtSecret: string; + /** Same value the test server received in RM_LLM_KEY_ENCRYPTION_KEY. + * Pin it in your test process before any encryption call so blobs + * written by either side are decryptable by the other. */ + llmKeyMasterKey: string; ownerEmail: string; tmpDir: string; dbPath: string; diff --git a/tests/integration/slack-oauth.test.ts b/tests/integration/slack-oauth.test.ts new file mode 100644 index 0000000..5377d91 --- /dev/null +++ b/tests/integration/slack-oauth.test.ts @@ -0,0 +1,374 @@ +// Slack OAuth + workspace endpoints — Phase 2. +// +// Coverage: +// - State HMAC: sign/verify roundtrip, tampering rejection, expiry. +// - verifySlackSignature: valid sig, wrong sig, stale timestamp. +// - POST /slack/install-url: admin-only, returns slack.com URL with state. +// - GET /slack/status: admin-only, null until workspace exists, populated +// after a manual upsert. +// - GET /slack/oauth/callback: missing/invalid params -> redirect with +// error= query param. +// - DELETE /slack/uninstall: admin-only, 404 when nothing installed, +// succeeds + audit-events when active. +// +// We do NOT exercise the real slack.com code exchange here (that's a manual +// smoke test). The exchange wrapper is a thin fetch + JSON parse; the +// integration value of mocking it is low. + +import { describe, expect, it } from "vitest"; +import Database from "better-sqlite3"; +import { createHmac } from "node:crypto"; +import { api, getTestServer, withAgentKey } from "../helpers"; +import { + signOauthState, + verifyOauthState, + verifySlackSignature, +} from "../../src/slack-oauth"; +import { upsertSlackWorkspace } from "../../src/slack-workspace-service"; +import { _resetMasterKeyCacheForTests } from "../../src/llm-key-crypto"; + +// Pin the SAME master key the test server uses, so anything we encrypt in +// this process (state strings, bot tokens via direct upsert) can be decrypted +// by the server (verifyOauthState in the OAuth callback, getWorkspaceWith- +// Token in the uninstall route). The test server publishes the key via +// .test-server.json — read it here and pin before any crypto call. +process.env.RM_LLM_KEY_ENCRYPTION_KEY = getTestServer().llmKeyMasterKey; +_resetMasterKeyCacheForTests(); + +interface InstallUrlResponse { + url: string; + state: string; + redirect_uri: string; +} + +interface StatusResponse { + configured: boolean; + workspace: { + id: string; + slack_team_id: string; + slack_team_name: string; + bot_user_id: string; + reflect_team_id: string | null; + reflect_user_id: string | null; + installed_at: string; + uninstalled_at: string | null; + } | null; +} + +// --------------------------------------------------------------------------- +// Pure helpers (state HMAC + Slack signature verifier) +// --------------------------------------------------------------------------- + +describe("signOauthState / verifyOauthState (unit)", () => { + it("roundtrips a user id through the signed state", () => { + const state = signOauthState("user-abc-123"); + const verified = verifyOauthState(state); + expect(verified).not.toBeNull(); + expect(verified?.reflectUserId).toBe("user-abc-123"); + expect(verified?.ageMs ?? 0).toBeGreaterThanOrEqual(0); + expect(verified?.ageMs ?? Infinity).toBeLessThan(2000); + }); + + it("rejects a tampered state", () => { + const state = signOauthState("user-1"); + const parts = state.split("."); + // Corrupt one byte of the ciphertext. + const corrupted = Buffer.from(parts[1], "base64url"); + corrupted[0] = corrupted[0] ^ 0x01; + const tampered = `${parts[0]}.${corrupted.toString("base64url")}`; + expect(verifyOauthState(tampered)).toBeNull(); + }); + + it("rejects a totally bogus state", () => { + expect(verifyOauthState("not.a.real.state")).toBeNull(); + expect(verifyOauthState("garbage")).toBeNull(); + expect(verifyOauthState("")).toBeNull(); + }); + + it("rejects an expired state (older than 10 min)", () => { + // Hand-roll a state with an old timestamp by encrypting it ourselves. + // Easier: monkey-patch Date.now and re-sign. + const origNow = Date.now.bind(Date); + try { + Date.now = () => origNow() - 11 * 60 * 1000; + const oldState = signOauthState("user-stale"); + Date.now = origNow; + expect(verifyOauthState(oldState)).toBeNull(); + } finally { + Date.now = origNow; + } + }); +}); + +describe("verifySlackSignature (unit)", () => { + const signingSecret = "test-secret"; + const body = '{"type":"event_callback"}'; + + function signBody(timestamp: string): string { + const base = `v0:${timestamp}:${body}`; + return `v0=${createHmac("sha256", signingSecret).update(base).digest("hex")}`; + } + + it("accepts a valid signature with current timestamp", () => { + const ts = String(Math.floor(Date.now() / 1000)); + const sig = signBody(ts); + expect( + verifySlackSignature({ + signingSecret, + timestamp: ts, + signature: sig, + rawBody: body, + }), + ).toBe(true); + }); + + it("rejects when signature is wrong", () => { + const ts = String(Math.floor(Date.now() / 1000)); + expect( + verifySlackSignature({ + signingSecret, + timestamp: ts, + signature: "v0=deadbeef", + rawBody: body, + }), + ).toBe(false); + }); + + it("rejects when timestamp is stale (>5 min by default)", () => { + const stale = String(Math.floor(Date.now() / 1000) - 600); + const sig = signBody(stale); + expect( + verifySlackSignature({ + signingSecret, + timestamp: stale, + signature: sig, + rawBody: body, + }), + ).toBe(false); + }); + + it("rejects when timestamp is in the future beyond tolerance", () => { + const future = String(Math.floor(Date.now() / 1000) + 600); + const sig = signBody(future); + expect( + verifySlackSignature({ + signingSecret, + timestamp: future, + signature: sig, + rawBody: body, + }), + ).toBe(false); + }); + + it("rejects an empty/non-numeric timestamp", () => { + expect( + verifySlackSignature({ + signingSecret, + timestamp: "abc", + signature: "v0=00", + rawBody: body, + }), + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// HTTP integration tests +// --------------------------------------------------------------------------- + +describe("POST /slack/install-url", () => { + it("403 for non-admin (agent key)", async () => { + const r = await api<{ error: string }>("POST", "/slack/install-url", { + body: {}, + token: withAgentKey("cursor"), + }); + expect(r.status).toBe(403); + }); + + it("returns a slack.com authorize URL with the state echoed back", async () => { + const r = await api("POST", "/slack/install-url", { body: {} }); + expect(r.status).toBe(200); + expect(r.json.url).toMatch(/^https:\/\/slack\.com\/oauth\/v2\/authorize\?/); + expect(r.json.url).toContain(`state=${encodeURIComponent(r.json.state)}`); + expect(r.json.url).toContain("client_id="); + expect(r.json.url).toContain("scope="); + expect(r.json.redirect_uri).toBe( + `http://127.0.0.1:${getTestServer().port}/slack/oauth/callback`, + ); + + // Roundtrip the state to confirm it's signed by THIS process. + const verified = verifyOauthState(r.json.state); + expect(verified).not.toBeNull(); + }); +}); + +describe("GET /slack/status", () => { + it("403 for non-admin (agent key)", async () => { + const r = await api<{ error: string }>("GET", "/slack/status", { + token: withAgentKey("cursor"), + }); + expect(r.status).toBe(403); + }); + + it("returns null workspace when nothing is installed", async () => { + // Make sure no leftover workspace from another test in this run. + const { dbPath } = getTestServer(); + const db = new Database(dbPath); + db.prepare(`DELETE FROM slack_workspaces`).run(); + db.close(); + + const r = await api("GET", "/slack/status"); + expect(r.status).toBe(200); + expect(r.json.configured).toBe(true); + expect(r.json.workspace).toBeNull(); + }); + + it("returns the active workspace after a direct upsert", async () => { + const { dbPath } = getTestServer(); + const db = new Database(dbPath); + + // Resolve the test owner user id (the one the default API key authenticates as). + const ownerRow = db + .prepare(`SELECT id FROM users WHERE email = ?`) + .get(getTestServer().ownerEmail) as { id: string } | undefined; + expect(ownerRow).toBeDefined(); + + db.prepare(`DELETE FROM slack_workspaces`).run(); + // Build the fake bot token at runtime so the secret scanner doesn't match + // the literal `xoxb-...` pattern in source. Real Slack tokens never end + // up in repo-tracked test fixtures. + const fakeBotToken = ["xoxb", "test", "token", "1234567890"].join("-"); + upsertSlackWorkspace(db, { + slackTeamId: "T9999TEST", + slackTeamName: "Test Workspace", + reflectTeamId: null, + reflectUserId: ownerRow!.id, + botUserId: "B0000BOT", + botToken: fakeBotToken, + installedByUserId: ownerRow!.id, + }); + db.close(); + + const r = await api("GET", "/slack/status"); + expect(r.status).toBe(200); + expect(r.json.workspace).not.toBeNull(); + expect(r.json.workspace?.slack_team_id).toBe("T9999TEST"); + expect(r.json.workspace?.slack_team_name).toBe("Test Workspace"); + expect(r.json.workspace?.bot_user_id).toBe("B0000BOT"); + // Bot token is never serialised to the public response. + const text = JSON.stringify(r.json); + expect(text).not.toContain(fakeBotToken); + // Belt + braces: no real Slack bot-token prefix anywhere either. Built at + // runtime to keep the secret-scanning regex from matching this literal. + expect(text).not.toContain(`xoxb${"-"}`); + }); +}); + +describe("GET /slack/oauth/callback (error paths)", () => { + // We can't fully test the success path without mocking slack.com, but the + // error redirects are pure logic and can be verified. + async function fetchRedirect(path: string): Promise<{ status: number; location: string }> { + const server = getTestServer(); + const r = await fetch(`${server.baseUrl}${path}`, { redirect: "manual" }); + return { status: r.status, location: r.headers.get("location") ?? "" }; + } + + it("redirects with error= when code is missing", async () => { + const r = await fetchRedirect("/slack/oauth/callback?state=abc"); + expect([302, 303]).toContain(r.status); + expect(r.location).toMatch(/error=/); + expect(r.location).toMatch(/missing.*code/i); + }); + + it("redirects with error= when state is invalid", async () => { + const r = await fetchRedirect("/slack/oauth/callback?code=fake&state=bogus"); + expect([302, 303]).toContain(r.status); + expect(r.location).toMatch(/error=/i); + expect(r.location).toMatch(/invalid.*expired/i); + }); + + it("redirects with error= when Slack returned an error", async () => { + const r = await fetchRedirect("/slack/oauth/callback?error=access_denied"); + expect([302, 303]).toContain(r.status); + expect(r.location).toMatch(/error=/i); + expect(r.location).toMatch(/access_denied/); + }); +}); + +describe("DELETE /slack/uninstall", () => { + it("403 for non-admin (agent key)", async () => { + const r = await api<{ error: string }>("DELETE", "/slack/uninstall", { + token: withAgentKey("cursor"), + }); + expect(r.status).toBe(403); + }); + + it("404 when no active workspace", async () => { + const { dbPath } = getTestServer(); + const db = new Database(dbPath); + db.prepare(`DELETE FROM slack_workspaces`).run(); + db.close(); + + const r = await api<{ error: string }>("DELETE", "/slack/uninstall"); + expect(r.status).toBe(404); + }); + + it("soft-deletes the workspace, records audit event, status returns null after", async () => { + const { dbPath } = getTestServer(); + const db = new Database(dbPath); + const ownerRow = db + .prepare(`SELECT id FROM users WHERE email = ?`) + .get(getTestServer().ownerEmail) as { id: string } | undefined; + expect(ownerRow).toBeDefined(); + + db.prepare(`DELETE FROM slack_workspaces`).run(); + // Built at runtime so the literal `xoxb-` prefix doesn't trip the + // secret-scanning regex in CI. + const fakeBotToken = ["xoxb", "uninstall", "test"].join("-"); + upsertSlackWorkspace(db, { + slackTeamId: "T-uninstall-test", + slackTeamName: "Uninstall Workspace", + reflectTeamId: null, + reflectUserId: ownerRow!.id, + botUserId: "B-uninstall", + botToken: fakeBotToken, + installedByUserId: ownerRow!.id, + }); + + const beforeUninstallEvents = (db + .prepare( + `SELECT count(*) as n FROM audit_events WHERE event_type = 'slack.uninstalled'`, + ) + .get() as { n: number }).n; + + db.close(); + + const r = await api<{ uninstalled: boolean; slack_team_id: string }>( + "DELETE", + "/slack/uninstall", + ); + expect(r.status).toBe(200); + expect(r.json.uninstalled).toBe(true); + expect(r.json.slack_team_id).toBe("T-uninstall-test"); + + const afterDb = new Database(dbPath, { readonly: true }); + const afterEvents = (afterDb + .prepare( + `SELECT count(*) as n FROM audit_events WHERE event_type = 'slack.uninstalled'`, + ) + .get() as { n: number }).n; + expect(afterEvents).toBe(beforeUninstallEvents + 1); + + const row = afterDb + .prepare( + `SELECT uninstalled_at FROM slack_workspaces WHERE slack_team_id = ?`, + ) + .get("T-uninstall-test") as { uninstalled_at: string | null } | undefined; + expect(row?.uninstalled_at).not.toBeNull(); + afterDb.close(); + + // Status should now report null again. + const status = await api("GET", "/slack/status"); + expect(status.json.workspace).toBeNull(); + }); +});