diff --git a/server/src/internal/admin/handleSlackAdminChat.ts b/server/src/internal/admin/handleSlackAdminChat.ts index a49c00966..dc8d91325 100644 --- a/server/src/internal/admin/handleSlackAdminChat.ts +++ b/server/src/internal/admin/handleSlackAdminChat.ts @@ -1,24 +1,32 @@ -import { randomUUID } from "node:crypto"; +import crypto, { randomUUID } from "node:crypto"; +import { stripOAuthTokenPrefix } from "@autumn/auth"; import { AppEnv, + apiKeys, + type ChatOAuthCredential, chatInstallations, chatOAuthCredentials, createChatInstallState, ErrCode, + oauthAccessToken, + oauthConsent, + oauthRefreshToken, organizations, RecaseError, Scopes, } from "@autumn/shared"; import { addMinutes } from "date-fns"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; import { z } from "zod/v4"; import type { DrizzleCli } from "@/db/initDrizzle.js"; import { createRoute } from "@/honoMiddlewares/routeHandler.js"; +import { hashOAuthToken } from "@/utils/oauthUtils.js"; import { createSlackInstallUrl, getChatStateSecret, getSlackAdminProvider, } from "../chat/chatUtils.js"; +import { clearSecretKeyCache } from "../dev/api-keys/cacheApiKeyUtils.js"; const targetBody = z.strictObject({ org_id: z.string().min(1), @@ -55,6 +63,123 @@ const getSlackAdminOAuthCredentials = async ({ where: eq(chatOAuthCredentials.chat_installation_id, installationId), }); +const getOrgSummary = async ({ + db, + orgId, +}: { + db: DrizzleCli; + orgId: string; +}) => + db.query.organizations.findFirst({ + where: eq(organizations.id, orgId), + columns: { + id: true, + name: true, + slug: true, + }, + }); + +const decryptChatCredentialToken = ({ token }: { token: string }) => { + const key = crypto + .createHash("sha256") + .update(process.env.ENCRYPTION_PASSWORD ?? "") + .digest(); + const buffer = Buffer.from(token, "base64"); + if (buffer[0] !== 1) throw new Error("Unsupported encrypted payload"); + const decipher = crypto.createDecipheriv( + "aes-256-gcm", + key, + buffer.subarray(1, 13), + ); + decipher.setAuthTag(buffer.subarray(13, 29)); + return Buffer.concat([ + decipher.update(buffer.subarray(29)), + decipher.final(), + ]).toString("utf8"); +}; + +const getStoredOAuthTokenValues = async ({ + token, + stripPrefix = false, +}: { + token: string; + stripPrefix?: boolean; +}) => { + const rawToken = stripPrefix ? stripOAuthTokenPrefix({ token }) : token; + return [rawToken, await hashOAuthToken(rawToken)]; +}; + +const revokeSlackAdminOAuthArtifacts = async ({ + db, + credentials, +}: { + db: Pick; + credentials: ChatOAuthCredential[]; +}) => { + const consentIds = [ + ...new Set( + credentials + .map((credential) => credential.oauth_consent_id) + .filter((id): id is string => Boolean(id)), + ), + ]; + + if (consentIds.length > 0) { + const accessTokenValues: string[] = []; + const refreshTokenValues: string[] = []; + for (const credential of credentials) { + accessTokenValues.push( + ...(await getStoredOAuthTokenValues({ + token: decryptChatCredentialToken({ token: credential.access_token }), + stripPrefix: true, + })), + ); + refreshTokenValues.push( + ...(await getStoredOAuthTokenValues({ + token: decryptChatCredentialToken({ + token: credential.refresh_token, + }), + })), + ); + } + + const uniqueAccessTokenValues = [...new Set(accessTokenValues)]; + const uniqueRefreshTokenValues = [...new Set(refreshTokenValues)]; + if (uniqueAccessTokenValues.length > 0) { + await db + .delete(oauthAccessToken) + .where(inArray(oauthAccessToken.token, uniqueAccessTokenValues)); + } + if (uniqueRefreshTokenValues.length > 0) { + await db + .delete(oauthRefreshToken) + .where(inArray(oauthRefreshToken.token, uniqueRefreshTokenValues)); + } + + for (const consentId of consentIds) { + const linkedKeys = await db + .select({ id: apiKeys.id, hashedKey: apiKeys.hashed_key }) + .from(apiKeys) + .where(sql`${apiKeys.meta}->>'oauth_consent_id' = ${consentId}`); + + for (const key of linkedKeys) { + await db.delete(apiKeys).where(eq(apiKeys.id, key.id)); + if (key.hashedKey) + await clearSecretKeyCache({ hashedKey: key.hashedKey }); + } + } + + await db.delete(oauthConsent).where(inArray(oauthConsent.id, consentIds)); + } + + const credentialIds = credentials.map((credential) => credential.id); + if (credentialIds.length > 0) { + await db + .delete(chatOAuthCredentials) + .where(inArray(chatOAuthCredentials.id, credentialIds)); + } +}; + export const handleCreateSlackAdminInstall = createRoute({ scopes: [Scopes.Superuser], handler: async (c) => { @@ -78,6 +203,9 @@ export const handleGetSlackAdminInstall = createRoute({ handler: async (c) => { const { db } = c.get("ctx"); const installation = await getSlackAdminInstallation({ db }); + const targetOrg = installation + ? await getOrgSummary({ db, orgId: installation.org_id }) + : null; const oauthCredentials = installation ? await getSlackAdminOAuthCredentials({ db, @@ -93,6 +221,8 @@ export const handleGetSlackAdminInstall = createRoute({ workspace_name: installation.workspace_name, bot_user_id: installation.bot_user_id, target_org_id: installation.org_id, + target_org_name: targetOrg?.name ?? null, + target_org_slug: targetOrg?.slug ?? null, target_env: installation.default_env, updated_at: installation.updated_at, installed_by_user_id: installation.installed_by_user_id, @@ -135,6 +265,10 @@ export const handleUpdateSlackAdminTarget = createRoute({ }); } + const oauthCredentials = await getSlackAdminOAuthCredentials({ + db, + installationId: installation.id, + }); const updated = await db.transaction(async (tx) => { const now = Date.now(); const [updatedInstallation] = await tx @@ -148,9 +282,10 @@ export const handleUpdateSlackAdminTarget = createRoute({ .where(eq(chatInstallations.id, installation.id)) .returning(); - await tx - .delete(chatOAuthCredentials) - .where(eq(chatOAuthCredentials.chat_installation_id, installation.id)); + await revokeSlackAdminOAuthArtifacts({ + db: tx, + credentials: oauthCredentials, + }); return updatedInstallation; }); @@ -161,6 +296,8 @@ export const handleUpdateSlackAdminTarget = createRoute({ workspace_id: updated.workspace_id, workspace_name: updated.workspace_name, target_org_id: updated.org_id, + target_org_name: targetOrg.name, + target_org_slug: targetOrg.slug, target_env: updated.default_env, updated_at: updated.updated_at, installed_by_user_id: updated.installed_by_user_id, @@ -175,11 +312,16 @@ export const handleDeleteSlackAdminInstall = createRoute({ const { db } = c.get("ctx"); const installation = await getSlackAdminInstallation({ db }); if (!installation) return c.json({ success: true }); + const oauthCredentials = await getSlackAdminOAuthCredentials({ + db, + installationId: installation.id, + }); await db.transaction(async (tx) => { - await tx - .delete(chatOAuthCredentials) - .where(eq(chatOAuthCredentials.chat_installation_id, installation.id)); + await revokeSlackAdminOAuthArtifacts({ + db: tx, + credentials: oauthCredentials, + }); await tx .delete(chatInstallations) .where( diff --git a/server/src/internal/auth/repos/oauthConsentRepo.ts b/server/src/internal/auth/repos/oauthConsentRepo.ts index 70b7ee853..603a911ef 100644 --- a/server/src/internal/auth/repos/oauthConsentRepo.ts +++ b/server/src/internal/auth/repos/oauthConsentRepo.ts @@ -1,5 +1,6 @@ +import { AUTUMN_ADMIN_OAUTH_CLIENT_ID } from "@autumn/auth/oauth"; import { type AppEnv, oauthConsent } from "@autumn/shared"; -import { and, eq, isNull, or } from "drizzle-orm"; +import { and, eq, isNull, ne, or, sql } from "drizzle-orm"; import type { DrizzleCli } from "@/db/initDrizzle.js"; export type OAuthConsentApiKeyRecord = { @@ -13,10 +14,12 @@ export const listOAuthConsentsByReferenceId = async ({ db, referenceId, env, + includeInternal = false, }: { db: DrizzleCli; referenceId: string; env?: AppEnv; + includeInternal?: boolean; }) => db .select({ @@ -35,6 +38,12 @@ export const listOAuthConsentsByReferenceId = async ({ env ? or(isNull(oauthConsent.env), eq(oauthConsent.env, env)) : undefined, + includeInternal + ? undefined + : and( + ne(oauthConsent.clientId, AUTUMN_ADMIN_OAUTH_CLIENT_ID), + sql`COALESCE(${oauthConsent.metadata}->>'kind', '') != 'slack_admin'`, + ), ), ); diff --git a/vite/src/views/admin/components/SlackAdminBotTab.tsx b/vite/src/views/admin/components/SlackAdminBotTab.tsx index b10e00673..51e967c94 100644 --- a/vite/src/views/admin/components/SlackAdminBotTab.tsx +++ b/vite/src/views/admin/components/SlackAdminBotTab.tsx @@ -23,6 +23,8 @@ type SlackAdminInstallation = { workspace_name?: string | null; bot_user_id?: string | null; target_org_id: string; + target_org_name?: string | null; + target_org_slug?: string | null; target_env: AppEnv; updated_at?: number | null; installed_by_user_id?: string | null; @@ -75,6 +77,10 @@ export const SlackAdminBotTab = () => { const installation = data?.installation ?? null; const credentials = installation?.oauth_credentials ?? []; + const targetOrgName = + installation?.target_org_name || + installation?.target_org_slug || + installation?.target_org_id; const { data: orgSearchData, isLoading: isSearchingOrgs } = useQuery({ @@ -215,6 +221,19 @@ export const SlackAdminBotTab = () => {
Target org + {installation ? ( +
+

+ {targetOrgName} +

+

+ {installation.target_org_id} + {installation.target_org_slug + ? ` - ${installation.target_org_slug}` + : ""} +

+
+ ) : null} setTargetOrgIdOrSlug(event.target.value)}