fix: slack bot authenticates via oauth

This commit is contained in:
johnyeo
2026-06-05 18:52:26 +01:00
committed by Charlie Lamb
parent 4e8c26da0a
commit c59153439b
38 changed files with 8645 additions and 190 deletions

View File

@@ -15,12 +15,20 @@ bun run chat:tunnel
2. Start Autumn with the same public URL: 2. Start Autumn with the same public URL:
```sh ```sh
CHAT_URL=https://c.autumn.ngrok.app SLACK_BOT_URL=https://c.autumn.ngrok.app bun d NGROK_URL=https://c.autumn.ngrok.app bun d
``` ```
`bun d` derives `CHAT_URL`, `SLACK_BOT_URL`, and `SLACK_REDIRECT_URI` from
`NGROK_URL`, so the Slack OAuth redirect becomes
`https://c.autumn.ngrok.app/slack/oauth/callback`. This exact URL must be in
the Slack app's OAuth redirect URLs.
The chat SDK stores its own subscriptions, locks, and queues in Postgres. By The chat SDK stores its own subscriptions, locks, and queues in Postgres. By
default it uses the same `DATABASE_URL` host with the database name changed to default it uses the same `DATABASE_URL` host with the database name changed to
`chat`; set `CHAT_STATE_DATABASE_URL` to override this. `chat`; set `CHAT_STATE_DATABASE_URL` to override this. `bun dev:services up`
creates the local `chat` database. The `@chat-adapter/state-pg` package creates
its state tables automatically on connect, so there is no separate migration
command for the chat state database.
3. Create a Slack app at https://api.slack.com/apps using `slack-manifest.example.json`. 3. Create a Slack app at https://api.slack.com/apps using `slack-manifest.example.json`.

View File

@@ -96,7 +96,7 @@ const readDocs = async (mcp: ReturnType<typeof createAutumnMcpClient>) => {
}; };
export const runChatAgent = async ({ export const runChatAgent = async ({
apiKey, token,
env, env,
logger = rootLogger, logger = rootLogger,
message, message,
@@ -106,7 +106,7 @@ export const runChatAgent = async ({
provider, provider,
recentMessages, recentMessages,
}: { }: {
apiKey: string; token: string;
env: AppEnv; env: AppEnv;
logger?: AutumnLogger; logger?: AutumnLogger;
message: string; message: string;
@@ -116,7 +116,11 @@ export const runChatAgent = async ({
provider: string; provider: string;
recentMessages?: ChatContextMessage[]; recentMessages?: ChatContextMessage[];
}) => { }) => {
const mcp = createAutumnMcpClient(apiKey, { requireApproval: true }); const mcp = createAutumnMcpClient({
token,
appEnv: env,
options: { requireApproval: true },
});
let previewApproval: let previewApproval:
| { | {
toolName: string; toolName: string;
@@ -138,12 +142,15 @@ export const runChatAgent = async ({
}); });
await onAction?.("Loading Autumn tools and guidance"); await onAction?.("Loading Autumn tools and guidance");
const [tools, docsText] = await Promise.all([ const [tools, docsText] = await Promise.all([
getAutumnMcpTools(mcp, { getAutumnMcpTools({
applyApprovalPolicy: true, mcp,
logger, options: {
onToolCall: onAction, applyApprovalPolicy: true,
onPreview: (approval) => { logger,
previewApproval = approval; onToolCall: onAction,
onPreview: (approval) => {
previewApproval = approval;
},
}, },
}), }),
readDocs(mcp), readDocs(mcp),

View File

@@ -1,4 +1,6 @@
import { isSecretKeyPrefix } from "@autumn/auth";
import type { AutumnLogger } from "@autumn/logging"; import type { AutumnLogger } from "@autumn/logging";
import type { AppEnv } from "@autumn/shared";
import { MCPClient } from "@mastra/mcp"; import { MCPClient } from "@mastra/mcp";
import { env } from "../lib/env.js"; import { env } from "../lib/env.js";
import { logger as rootLogger } from "../lib/logger.js"; import { logger as rootLogger } from "../lib/logger.js";
@@ -26,29 +28,41 @@ type ToolOptions = {
}; };
const withAuthFetch = const withAuthFetch =
(apiKey: string) => (input: RequestInfo | URL, init?: RequestInit) => { ({ appEnv, token }: { appEnv: AppEnv; token: string }) =>
(input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers); const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${apiKey}`); headers.set("Authorization", `Bearer ${token}`);
headers.set("secret-key", apiKey); headers.set("x-autumn-environment", appEnv);
if (isSecretKeyPrefix({ token })) {
headers.set("secret-key", token);
}
return fetch(input, { ...init, headers }); return fetch(input, { ...init, headers });
}; };
export const createAutumnMcpClient = ( export const createAutumnMcpClient = ({
apiKey: string, token,
options: { requireApproval?: boolean } = {}, appEnv,
) => { options = {},
const fetchWithAuth = withAuthFetch(apiKey); }: {
token: string;
appEnv: AppEnv;
options?: { requireApproval?: boolean };
}) => {
const fetchWithAuth = withAuthFetch({ appEnv, token });
const headers: Record<string, string> = {
Authorization: `Bearer ${token}`,
"x-autumn-environment": appEnv,
};
if (isSecretKeyPrefix({ token })) {
headers["secret-key"] = token;
}
return new MCPClient({ return new MCPClient({
id: `autumn-${apiKey.slice(0, 14)}`, id: `autumn-${token.slice(0, 14)}`,
servers: { servers: {
autumn: { autumn: {
url: new URL("/mcp", env.MCP_SERVER_URL), url: new URL("/mcp", env.MCP_SERVER_URL),
requestInit: { requestInit: { headers },
headers: {
Authorization: `Bearer ${apiKey}`,
"secret-key": apiKey,
},
},
eventSourceInit: { fetch: fetchWithAuth }, eventSourceInit: { fetch: fetchWithAuth },
fetch: fetchWithAuth, fetch: fetchWithAuth,
requireToolApproval: options.requireApproval requireToolApproval: options.requireApproval
@@ -59,7 +73,13 @@ export const createAutumnMcpClient = (
}); });
}; };
const formatToolAction = (toolName: string, args: Record<string, unknown>) => { const formatToolAction = ({
toolName,
args,
}: {
toolName: string;
args: Record<string, unknown>;
}) => {
const request = const request =
args.request && typeof args.request === "object" args.request && typeof args.request === "object"
? (args.request as Record<string, unknown>) ? (args.request as Record<string, unknown>)
@@ -76,10 +96,13 @@ const formatToolAction = (toolName: string, args: Record<string, unknown>) => {
return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`; return `${toolLabel(toolName)}${details.length ? ` (${details.join(", ")})` : ""}`;
}; };
export const getAutumnMcpTools = async ( export const getAutumnMcpTools = async ({
mcp: MCPClient, mcp,
options: ToolOptions = {}, options = {},
) => { }: {
mcp: MCPClient;
options?: ToolOptions;
}) => {
const logger = options.logger ?? rootLogger; const logger = options.logger ?? rootLogger;
const { toolsets, errors } = await mcp.listToolsetsWithErrors(); const { toolsets, errors } = await mcp.listToolsetsWithErrors();
if (Object.keys(errors).length) { if (Object.keys(errors).length) {
@@ -111,7 +134,7 @@ export const getAutumnMcpTools = async (
event: "leaf.mcp_tool_called", event: "leaf.mcp_tool_called",
tool: toolName, tool: toolName,
}); });
await options.onToolCall?.(formatToolAction(toolName, args)); await options.onToolCall?.(formatToolAction({ toolName, args }));
const result = await execute(args, ...rest); const result = await execute(args, ...rest);
const writeTool = getWriteToolForPreview(toolName); const writeTool = getWriteToolForPreview(toolName);
if (writeTool) { if (writeTool) {
@@ -136,17 +159,19 @@ export const getAutumnMcpTools = async (
}; };
export const executeAutumnMcpTool = async ({ export const executeAutumnMcpTool = async ({
apiKey, env,
token,
toolName, toolName,
args, args,
}: { }: {
apiKey: string; env: AppEnv;
token: string;
toolName: string; toolName: string;
args: Record<string, unknown>; args: Record<string, unknown>;
}) => { }) => {
const mcp = createAutumnMcpClient(apiKey); const mcp = createAutumnMcpClient({ token, appEnv: env });
try { try {
const tools = await getAutumnMcpTools(mcp); const tools = await getAutumnMcpTools({ mcp });
const tool = tools[toolName.replace(/^autumn_/, "")]; const tool = tools[toolName.replace(/^autumn_/, "")];
if (!tool?.execute) throw new Error(`Unknown Autumn MCP tool: ${toolName}`); if (!tool?.execute) throw new Error(`Unknown Autumn MCP tool: ${toolName}`);
return await tool.execute(args); return await tool.execute(args);

View File

@@ -1,5 +1,5 @@
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
import { logger as rootLogger } from "../lib/logger.js"; import { logger as rootLogger } from "../lib/logger.js";
import { getInstallationKey } from "../providers/slack/installations.js";
import { agentOutputSchema, type BotMessage } from "../types.js"; import { agentOutputSchema, type BotMessage } from "../types.js";
import { runChatAgent, selectChatEnv } from "./agent.js"; import { runChatAgent, selectChatEnv } from "./agent.js";
@@ -35,9 +35,13 @@ export const runMessage = async ({
provider: installation.provider, provider: installation.provider,
}, },
}); });
const token = await getInstallationOAuthAccessToken({
installation,
env,
});
return agentOutputSchema.parse( return agentOutputSchema.parse(
await runChatAgent({ await runChatAgent({
apiKey: getInstallationKey(installation, env), token,
env, env,
logger, logger,
message: text, message: text,

View File

@@ -1,15 +1,15 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import { import {
AppEnv, type AppEnv,
type ChatProvider, type ChatProvider,
chatApprovals, chatApprovals,
chatInstallations, chatInstallations,
} from "@autumn/shared"; } from "@autumn/shared";
import { addMinutes, isPast } from "date-fns"; import { addMinutes, isPast } from "date-fns";
import { and, eq, gt } from "drizzle-orm"; import { and, eq, gt } from "drizzle-orm";
import { decrypt } from "../lib/crypto.js";
import { db } from "../lib/db.js";
import { executeAutumnMcpTool } from "../agent/mcp.js"; import { executeAutumnMcpTool } from "../agent/mcp.js";
import { getInstallationOAuthAccessToken } from "../internal/installations/actions/getInstallationOAuthAccessToken.js";
import { db } from "../lib/db.js";
export const normalizeToolName = (toolName: string) => export const normalizeToolName = (toolName: string) =>
toolName.replace(/^autumn_/, ""); toolName.replace(/^autumn_/, "");
@@ -120,14 +120,14 @@ export const approveAndRun = async (id: string, providerUserId: string) => {
}); });
if (!installation) throw new Error("Chat installation not found"); if (!installation) throw new Error("Chat installation not found");
const encryptedKey = const token = await getInstallationOAuthAccessToken({
claimed.env === AppEnv.Live installation,
? installation.live_api_key env: claimed.env,
: installation.sandbox_api_key; });
if (!encryptedKey) throw new Error(`Missing ${claimed.env} API key`);
const result = await executeAutumnMcpTool({ const result = await executeAutumnMcpTool({
apiKey: decrypt(encryptedKey), token,
env: claimed.env,
toolName: claimed.tool_name, toolName: claimed.tool_name,
args: claimed.tool_args, args: claimed.tool_args,
}); });

View File

@@ -0,0 +1,83 @@
import type { AppEnv, ChatInstallation } from "@autumn/shared";
import { decrypt, encrypt } from "../../../lib/crypto.js";
import { db } from "../../../lib/db.js";
import { env as leafEnv } from "../../../lib/env.js";
import {
getChatOAuthCredentialByInstallationEnv,
updateChatOAuthCredentialTokens,
} from "../repos/chatOAuthCredentialsRepo.js";
import {
parseOAuthScopeString,
parseOAuthTokenResponse,
} from "../utils/oauthTokenResponse.js";
const TOKEN_EXPIRY_SKEW_MS = 60_000;
const getTokenEndpoint = () =>
new URL("/api/auth/oauth2/token", leafEnv.BETTER_AUTH_URL).href;
const getDefaultExpiresAt = () => Date.now() + 60 * 60 * 1000;
export const getInstallationOAuthAccessToken = async ({
installation,
env,
}: {
installation: ChatInstallation;
env: AppEnv;
}) => {
const credential = await getChatOAuthCredentialByInstallationEnv({
db,
chatInstallationId: installation.id,
env,
});
if (!credential) {
throw new Error(
`Missing ${env} Autumn OAuth credentials for Slack install`,
);
}
if (credential.access_token_expires_at - TOKEN_EXPIRY_SKEW_MS > Date.now()) {
return decrypt(credential.access_token);
}
const refreshToken = decrypt(credential.refresh_token);
const body = new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: credential.oauth_client_id,
});
const response = await fetch(getTokenEndpoint(), {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body,
});
if (!response.ok) {
throw new Error(
`Could not refresh ${env} Autumn OAuth token for Slack install`,
);
}
const parsed = parseOAuthTokenResponse({ body: await response.json() });
const accessTokenExpiresAt = parsed.expires_in
? Date.now() + parsed.expires_in * 1000
: getDefaultExpiresAt();
const nextRefreshToken = parsed.refresh_token ?? refreshToken;
const scopes = parseOAuthScopeString({ scope: parsed.scope });
await updateChatOAuthCredentialTokens({
db,
id: credential.id,
accessToken: encrypt(parsed.access_token),
refreshToken: encrypt(nextRefreshToken),
accessTokenExpiresAt,
scopes: scopes.length > 0 ? scopes : credential.scopes,
updatedAt: Date.now(),
});
return parsed.access_token;
};

View File

@@ -0,0 +1,218 @@
import crypto from "node:crypto";
import { prefixOAuthToken } from "@autumn/auth";
import {
AppEnv,
type ChatInstallation,
chatOAuthCredentials,
oauthAccessToken,
oauthClient,
oauthConsent,
oauthRefreshToken,
} from "@autumn/shared";
import { ALL_SCOPES } from "@autumn/shared/utils/scopeDefinitions";
import { and, eq } from "drizzle-orm";
import { encrypt } from "../../../lib/crypto.js";
import type { db } from "../../../lib/db.js";
import { AUTUMN_SLACK_OAUTH_CLIENT_ID } from "./upsertInstallationOAuthCredential.js";
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000;
const REFRESH_TOKEN_TTL_MS = 365 * 24 * 60 * 60 * 1000;
const tokenHash = ({ token }: { token: string }) => {
const hash = crypto.createHash("sha256").update(token).digest();
return hash
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
};
const generateToken = () => crypto.randomBytes(48).toString("base64url");
const ensureSlackMcpOAuthClient = async ({ tx }: { tx: ChatTransaction }) => {
const now = new Date();
await tx
.insert(oauthClient)
.values({
id: `oauth_client_${crypto.randomUUID().replace(/-/g, "")}`,
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
name: "Slack",
redirectUris: ["slack://autumn-chat"],
scopes: [...ALL_SCOPES],
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
public: true,
type: "native",
metadata: {
kind: "mcp_client",
mcpClientType: "slack",
},
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: oauthClient.clientId,
set: {
name: "Slack",
scopes: [...ALL_SCOPES],
tokenEndpointAuthMethod: "none",
grantTypes: ["authorization_code", "refresh_token"],
responseTypes: ["code"],
public: true,
type: "native",
metadata: {
kind: "mcp_client",
mcpClientType: "slack",
},
updatedAt: now,
},
});
};
const upsertOAuthConsent = async ({
tx,
env,
orgId,
userId,
}: {
tx: ChatTransaction;
env: AppEnv;
orgId: string;
userId: string;
}) => {
const now = new Date();
const [existingConsent] = await tx
.select({ id: oauthConsent.id })
.from(oauthConsent)
.where(
and(
eq(oauthConsent.clientId, AUTUMN_SLACK_OAUTH_CLIENT_ID),
eq(oauthConsent.userId, userId),
eq(oauthConsent.referenceId, orgId),
eq(oauthConsent.env, env),
),
)
.limit(1);
if (existingConsent) {
await tx
.update(oauthConsent)
.set({
scopes: [...ALL_SCOPES],
updatedAt: now,
})
.where(eq(oauthConsent.id, existingConsent.id));
return existingConsent.id;
}
const consentId = `oauth_consent_${crypto.randomUUID().replace(/-/g, "")}`;
await tx.insert(oauthConsent).values({
id: consentId,
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
userId,
referenceId: orgId,
scopes: [...ALL_SCOPES],
env,
redirectUri: "slack://autumn-chat",
createdAt: now,
updatedAt: now,
});
return consentId;
};
const createCredentialForEnv = async ({
tx,
installation,
env,
userId,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
env: AppEnv;
userId: string;
}) => {
const now = Date.now();
const nowDate = new Date(now);
const rawAccessToken = generateToken();
const rawRefreshToken = generateToken();
const accessTokenExpiresAt = now + ACCESS_TOKEN_TTL_MS;
const refreshTokenExpiresAt = now + REFRESH_TOKEN_TTL_MS;
const refreshTokenId = `oauth_refresh_${crypto.randomUUID().replace(/-/g, "")}`;
const accessTokenId = `oauth_access_${crypto.randomUUID().replace(/-/g, "")}`;
const consentId = await upsertOAuthConsent({
tx,
env,
orgId: installation.org_id,
userId,
});
await tx.insert(oauthRefreshToken).values({
id: refreshTokenId,
token: tokenHash({ token: rawRefreshToken }),
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
userId,
referenceId: installation.org_id,
expiresAt: new Date(refreshTokenExpiresAt),
createdAt: nowDate,
authTime: nowDate,
scopes: [...ALL_SCOPES],
});
await tx.insert(oauthAccessToken).values({
id: accessTokenId,
token: tokenHash({ token: rawAccessToken }),
clientId: AUTUMN_SLACK_OAUTH_CLIENT_ID,
userId,
referenceId: installation.org_id,
refreshId: refreshTokenId,
expiresAt: new Date(accessTokenExpiresAt),
createdAt: nowDate,
scopes: [...ALL_SCOPES],
});
await tx.insert(chatOAuthCredentials).values({
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
chat_installation_id: installation.id,
org_id: installation.org_id,
env,
oauth_client_id: AUTUMN_SLACK_OAUTH_CLIENT_ID,
oauth_consent_id: consentId,
access_token: encrypt(prefixOAuthToken({ token: rawAccessToken })),
refresh_token: encrypt(rawRefreshToken),
access_token_expires_at: accessTokenExpiresAt,
scopes: [...ALL_SCOPES],
created_at: now,
updated_at: now,
});
};
export const replaceInstallationOAuthCredentials = async ({
tx,
installation,
userId,
}: {
tx: ChatTransaction;
installation: ChatInstallation;
userId: string;
}) => {
if (!userId) {
throw new Error("Missing user id for Slack MCP OAuth credentials");
}
await ensureSlackMcpOAuthClient({ tx });
await createCredentialForEnv({
tx,
installation,
env: AppEnv.Sandbox,
userId,
});
await createCredentialForEnv({
tx,
installation,
env: AppEnv.Live,
userId,
});
};

View File

@@ -0,0 +1,47 @@
import crypto from "node:crypto";
import type { AppEnv, ChatInstallation } from "@autumn/shared";
import { encrypt } from "../../../lib/crypto.js";
import { db } from "../../../lib/db.js";
import { upsertChatOAuthCredential } from "../repos/chatOAuthCredentialsRepo.js";
export const AUTUMN_SLACK_OAUTH_CLIENT_ID = "autumn_mcp_slack";
export const upsertInstallationOAuthCredential = async ({
installation,
env,
accessToken,
refreshToken,
accessTokenExpiresAt,
scopes,
oauthClientId = AUTUMN_SLACK_OAUTH_CLIENT_ID,
oauthConsentId,
}: {
installation: ChatInstallation;
env: AppEnv;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
scopes: string[];
oauthClientId?: string;
oauthConsentId?: string | null;
}) => {
const now = Date.now();
return upsertChatOAuthCredential({
db,
credential: {
id: `chat_oauth_${crypto.randomUUID().replace(/-/g, "")}`,
chat_installation_id: installation.id,
org_id: installation.org_id,
env,
oauth_client_id: oauthClientId,
oauth_consent_id: oauthConsentId ?? null,
access_token: encrypt(accessToken),
refresh_token: encrypt(refreshToken),
access_token_expires_at: accessTokenExpiresAt,
scopes,
created_at: now,
updated_at: now,
},
});
};

View File

@@ -0,0 +1,89 @@
import {
type AppEnv,
type ChatOAuthCredential,
chatOAuthCredentials,
} from "@autumn/shared";
import { and, eq } from "drizzle-orm";
import type { ChatDb } from "../../../lib/db.js";
export type ChatOAuthCredentialInsert =
typeof chatOAuthCredentials.$inferInsert;
export const getChatOAuthCredentialByInstallationEnv = async ({
db,
chatInstallationId,
env,
}: {
db: ChatDb;
chatInstallationId: string;
env: AppEnv;
}) =>
db.query.chatOAuthCredentials.findFirst({
where: and(
eq(chatOAuthCredentials.chat_installation_id, chatInstallationId),
eq(chatOAuthCredentials.env, env),
),
});
export const upsertChatOAuthCredential = async ({
db,
credential,
}: {
db: ChatDb;
credential: ChatOAuthCredentialInsert;
}) => {
const [row] = await db
.insert(chatOAuthCredentials)
.values(credential)
.onConflictDoUpdate({
target: [
chatOAuthCredentials.chat_installation_id,
chatOAuthCredentials.env,
],
set: {
org_id: credential.org_id,
oauth_client_id: credential.oauth_client_id,
oauth_consent_id: credential.oauth_consent_id,
access_token: credential.access_token,
refresh_token: credential.refresh_token,
access_token_expires_at: credential.access_token_expires_at,
scopes: credential.scopes,
updated_at: credential.updated_at,
},
})
.returning();
return row as ChatOAuthCredential;
};
export const updateChatOAuthCredentialTokens = async ({
db,
id,
accessToken,
refreshToken,
accessTokenExpiresAt,
scopes,
updatedAt,
}: {
db: ChatDb;
id: string;
accessToken: string;
refreshToken: string;
accessTokenExpiresAt: number;
scopes: string[];
updatedAt: number;
}) => {
const [row] = await db
.update(chatOAuthCredentials)
.set({
access_token: accessToken,
refresh_token: refreshToken,
access_token_expires_at: accessTokenExpiresAt,
scopes,
updated_at: updatedAt,
})
.where(eq(chatOAuthCredentials.id, id))
.returning();
return row as ChatOAuthCredential | undefined;
};

View File

@@ -0,0 +1,22 @@
import { z } from "zod";
const oauthTokenPayloadSchema = z.object({
access_token: z.string().min(1),
refresh_token: z.string().min(1).optional(),
expires_in: z.number().optional(),
scope: z.string().optional(),
});
const oauthTokenResponseSchema = z.preprocess((value) => {
if (value && typeof value === "object" && "response" in value) {
return (value as { response?: unknown }).response;
}
return value;
}, oauthTokenPayloadSchema);
export const parseOAuthTokenResponse = ({ body }: { body: unknown }) =>
oauthTokenResponseSchema.parse(body);
export const parseOAuthScopeString = ({ scope }: { scope?: string }) =>
scope?.split(/\s+/).filter(Boolean) ?? [];

View File

@@ -1,14 +1,14 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { getBearerToken } from "@autumn/auth"; import { getBearerToken, isOAuthToken, isSecretKeyPrefix } from "@autumn/auth";
import { import {
getProtectedResourceMetadataUrl, getProtectedResourceMetadataUrl,
getWwwAuthenticateHeader, getWwwAuthenticateHeader,
} from "@autumn/auth/oauth"; } from "@autumn/auth/oauth";
import { import {
DEFAULT_API_VERSION,
MCP_OAUTH_SCOPES,
type AutumnMcpAuth, type AutumnMcpAuth,
DEFAULT_API_VERSION,
environmentSchema, environmentSchema,
MCP_OAUTH_SCOPES,
type MCPServerFlags, type MCPServerFlags,
type OAuthEnvironment, type OAuthEnvironment,
} from "@autumn/mcp"; } from "@autumn/mcp";
@@ -72,12 +72,21 @@ const getStaticApiKey = ({
flags: MCPOAuthFlags; flags: MCPOAuthFlags;
}): string | undefined => { }): string | undefined => {
const secretKey = headers.get("secret-key"); const secretKey = headers.get("secret-key");
if (secretKey) return secretKey; if (secretKey && isSecretKeyPrefix({ token: secretKey })) return secretKey;
const bearer = getBearerToken({ headers }); const bearer = getBearerToken({ headers });
if (bearer?.startsWith("am_")) return bearer; if (bearer && isSecretKeyPrefix({ token: bearer })) return bearer;
return flags["oauth-enabled"] ? undefined : flags["secret-key"]; const fallbackSecretKey = flags["secret-key"];
if (
!flags["oauth-enabled"] &&
fallbackSecretKey &&
isSecretKeyPrefix({ token: fallbackSecretKey })
) {
return fallbackSecretKey;
}
return undefined;
}; };
const principalFromSecret = ({ const principalFromSecret = ({
@@ -134,7 +143,7 @@ export const buildAuthForRequest = async ({
} }
const bearer = getBearerToken({ headers }); const bearer = getBearerToken({ headers });
if (bearer) { if (bearer && isOAuthToken({ token: bearer })) {
return { return {
apiKey: bearer, apiKey: bearer,
authMethod: "oauth", authMethod: "oauth",
@@ -148,6 +157,14 @@ export const buildAuthForRequest = async ({
}; };
} }
if (bearer) {
throw new OAuthHttpError(
401,
"Invalid OAuth token prefix",
"invalid_token",
);
}
if (flags["oauth-enabled"]) { if (flags["oauth-enabled"]) {
throw new OAuthHttpError( throw new OAuthHttpError(
401, 401,

View File

@@ -5,29 +5,16 @@ import {
type ChatInstallation, type ChatInstallation,
type ChatProvider, type ChatProvider,
chatInstallations, chatInstallations,
Scopes,
} from "@autumn/shared"; } from "@autumn/shared";
import type { ChatInstallState } from "@autumn/shared/utils/chatState"; import type { ChatInstallState } from "@autumn/shared/utils/chatState";
import { and, eq, or } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import { replaceInstallationOAuthCredentials } from "../../internal/installations/actions/replaceInstallationOAuthCredentials.js";
import { decrypt, encrypt } from "../../lib/crypto.js"; import { decrypt, encrypt } from "../../lib/crypto.js";
import { db } from "../../lib/db.js"; import { db } from "../../lib/db.js";
import { env } from "../../lib/env.js"; import { env } from "../../lib/env.js";
type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0]; type ChatTransaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
const apiKeyScopes = [
Scopes.Customers.Read,
Scopes.Customers.Write,
Scopes.Plans.Read,
Scopes.Plans.Write,
Scopes.Billing.Read,
Scopes.Billing.Write,
Scopes.Balances.Write,
];
const apiKeyPrefix = (env: AppEnv) =>
env === AppEnv.Live ? "am_sk_live" : "am_sk_test";
export const getStateSecret = () => env.CHAT_STATE_SECRET; export const getStateSecret = () => env.CHAT_STATE_SECRET;
export const findInstallation = (provider: ChatProvider, workspaceId: string) => export const findInstallation = (provider: ChatProvider, workspaceId: string) =>
@@ -50,33 +37,6 @@ export const getInstallationKey = (
return decrypt(key); return decrypt(key);
}; };
const buildApiKey = ({
orgId,
userId,
env,
provider,
}: {
orgId: string;
userId: string;
env: AppEnv;
provider: ChatProvider;
}) => {
const secret = `${apiKeyPrefix(env)}_${crypto.randomBytes(32).toString("base64url")}`;
const key = {
id: `key_${crypto.randomUUID().replace(/-/g, "")}`,
org_id: orgId,
user_id: userId,
name: `Chat MCP (${provider})`,
prefix: secret.substring(0, 14),
created_at: Date.now(),
env,
hashed_key: crypto.createHash("sha256").update(secret).digest("hex"),
meta: { created_via: "chat", provider },
scopes: apiKeyScopes,
};
return { key, secret };
};
const deleteInstallationApiKeys = async ( const deleteInstallationApiKeys = async (
tx: ChatTransaction, tx: ChatTransaction,
installation: ChatInstallation, installation: ChatInstallation,
@@ -111,19 +71,6 @@ export const replaceInstallation = async ({
scopes: string[]; scopes: string[];
installedByProviderUserId?: string; installedByProviderUserId?: string;
}) => { }) => {
const sandbox = buildApiKey({
orgId: state.orgId,
userId: state.userId,
env: AppEnv.Sandbox,
provider,
});
const live = buildApiKey({
orgId: state.orgId,
userId: state.userId,
env: AppEnv.Live,
provider,
});
const sameOrg = and( const sameOrg = and(
eq(chatInstallations.org_id, state.orgId), eq(chatInstallations.org_id, state.orgId),
eq(chatInstallations.provider, provider), eq(chatInstallations.provider, provider),
@@ -134,8 +81,6 @@ export const replaceInstallation = async ({
); );
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
await tx.insert(apiKeys).values([sandbox.key, live.key]);
const existingInstallations = await tx.query.chatInstallations.findMany({ const existingInstallations = await tx.query.chatInstallations.findMany({
where: or(sameOrg, sameWorkspace), where: or(sameOrg, sameWorkspace),
}); });
@@ -144,24 +89,29 @@ export const replaceInstallation = async ({
} }
await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace)); await tx.delete(chatInstallations).where(or(sameOrg, sameWorkspace));
await tx.insert(chatInstallations).values({ const [installation] = await tx
id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`, .insert(chatInstallations)
org_id: state.orgId, .values({
provider, id: `chat_inst_${crypto.randomUUID().replace(/-/g, "")}`,
workspace_id: workspaceId, org_id: state.orgId,
workspace_name: workspaceName, provider,
bot_user_id: botUserId, workspace_id: workspaceId,
bot_access_token: encrypt(botAccessToken), workspace_name: workspaceName,
scopes, bot_user_id: botUserId,
default_env: state.env, bot_access_token: encrypt(botAccessToken),
sandbox_api_key_id: sandbox.key.id, scopes,
sandbox_api_key: encrypt(sandbox.secret), default_env: state.env,
live_api_key_id: live.key.id, installed_by_user_id: state.userId,
live_api_key: encrypt(live.secret), installed_by_provider_user_id: installedByProviderUserId,
installed_by_user_id: state.userId, created_at: Date.now(),
installed_by_provider_user_id: installedByProviderUserId, updated_at: Date.now(),
created_at: Date.now(), })
updated_at: Date.now(), .returning();
await replaceInstallationOAuthCredentials({
tx,
installation,
userId: state.userId,
}); });
}); });
}; };

View File

@@ -0,0 +1,23 @@
const AUTUMN_SECRET_KEY_PREFIX = "am_sk";
const AUTUMN_PUBLISHABLE_KEY_PREFIX = "am_pk";
const AUTUMN_OAUTH_TOKEN_PREFIX = "am_oauth_";
export const isSecretKeyPrefix = ({ token }: { token: string }) =>
token.startsWith(AUTUMN_SECRET_KEY_PREFIX);
export const isPublishableKeyPrefix = ({ token }: { token: string }) =>
token.startsWith(AUTUMN_PUBLISHABLE_KEY_PREFIX);
export const isAutumnApiKey = ({ token }: { token: string }) =>
isSecretKeyPrefix({ token }) || isPublishableKeyPrefix({ token });
export const isOAuthToken = ({ token }: { token: string }) =>
token.startsWith(AUTUMN_OAUTH_TOKEN_PREFIX);
export const prefixOAuthToken = ({ token }: { token: string }) =>
isOAuthToken({ token }) ? token : `${AUTUMN_OAUTH_TOKEN_PREFIX}${token}`;
export const stripOAuthTokenPrefix = ({ token }: { token: string }) =>
isOAuthToken({ token })
? token.slice(AUTUMN_OAUTH_TOKEN_PREFIX.length)
: token;

View File

@@ -1 +1,2 @@
export * from "./authTokenUtils.js";
export * from "./getBearerToken.js"; export * from "./getBearerToken.js";

View File

@@ -13,6 +13,7 @@ const FORMATTED_LOG_EXCLUDE_FIELDS = new Set([
"query", "query",
"durationMs", "durationMs",
"duration_ms", "duration_ms",
"event",
"context", "context",
"workflow", "workflow",
"trigger", "trigger",

View File

@@ -74,6 +74,10 @@ export const createAutumnClient = (auth: AutumnMcpAuth) => ({
"Content-Type": "application/json", "Content-Type": "application/json",
Accept: "application/json", Accept: "application/json",
"x-api-version": auth.xApiVersion ?? DEFAULT_API_VERSION, "x-api-version": auth.xApiVersion ?? DEFAULT_API_VERSION,
"x-autumn-environment": auth.env,
...(auth.authMethod === "oauth"
? { "x-autumn-oauth-resource": auth.resource }
: {}),
...(auth.failOpen === undefined ...(auth.failOpen === undefined
? {} ? {}
: { "fail-open": String(auth.failOpen) }), : { "fail-open": String(auth.failOpen) }),

View File

@@ -24,6 +24,9 @@ const CHAT_PORT = process.env.CHAT_PORT
const LOCAL_CLIENT_URL = `http://localhost:${VITE_PORT}`; const LOCAL_CLIENT_URL = `http://localhost:${VITE_PORT}`;
const LOCAL_SERVER_URL = `http://localhost:${SERVER_PORT}`; const LOCAL_SERVER_URL = `http://localhost:${SERVER_PORT}`;
const LOCAL_CHAT_URL = `http://localhost:${CHAT_PORT}`; const LOCAL_CHAT_URL = `http://localhost:${CHAT_PORT}`;
const publicTunnelUrl = process.env.NGROK_URL?.replace(/\/$/, "");
const CHAT_URL = process.env.CHAT_URL ?? publicTunnelUrl ?? LOCAL_CHAT_URL;
const SLACK_BOT_URL = process.env.SLACK_BOT_URL ?? publicTunnelUrl ?? CHAT_URL;
const skipWorkers = false; const skipWorkers = false;
const isProductionMode = process.argv.includes("--production"); const isProductionMode = process.argv.includes("--production");
@@ -36,6 +39,12 @@ const viteAppEnv = envFile.includes(".env.prod")
const useLocalAuthUrls = viteAppEnv === "dev" && !isProductionMode; const useLocalAuthUrls = viteAppEnv === "dev" && !isProductionMode;
const localUrl = (value: string | undefined, fallback: string) => const localUrl = (value: string | undefined, fallback: string) =>
value && !value.includes(".useautumn.com") ? value : fallback; value && !value.includes(".useautumn.com") ? value : fallback;
const SLACK_REDIRECT_URI = useLocalAuthUrls
? localUrl(
process.env.SLACK_REDIRECT_URI,
`${SLACK_BOT_URL}/slack/oauth/callback`,
)
: (process.env.SLACK_REDIRECT_URI ?? `${SLACK_BOT_URL}/slack/oauth/callback`);
/** /**
* Read environment variable from .env file * Read environment variable from .env file
@@ -266,8 +275,9 @@ async function startDev() {
MCP_RESOURCE_URLS: MCP_RESOURCE_URLS:
process.env.MCP_RESOURCE_URLS ?? `http://localhost:${CHAT_PORT}/mcp`, process.env.MCP_RESOURCE_URLS ?? `http://localhost:${CHAT_PORT}/mcp`,
AUTUMN_API_URL: process.env.AUTUMN_API_URL ?? LOCAL_SERVER_URL, AUTUMN_API_URL: process.env.AUTUMN_API_URL ?? LOCAL_SERVER_URL,
CHAT_URL: process.env.CHAT_URL ?? LOCAL_CHAT_URL, CHAT_URL,
SLACK_BOT_URL: process.env.SLACK_BOT_URL ?? LOCAL_CHAT_URL, SLACK_BOT_URL,
SLACK_REDIRECT_URI,
DISCORD_BOT_URL: process.env.DISCORD_BOT_URL ?? LOCAL_CHAT_URL, DISCORD_BOT_URL: process.env.DISCORD_BOT_URL ?? LOCAL_CHAT_URL,
VITE_APP_ENV: viteAppEnv, VITE_APP_ENV: viteAppEnv,
...(useLocalAuthUrls && { ...(useLocalAuthUrls && {

View File

@@ -11,6 +11,7 @@ const localConfig = {
redisStackPort: 6379, redisStackPort: 6379,
dragonflyPort: 6380, dragonflyPort: 6380,
databaseUrl: "postgresql://postgres:postgres@localhost:5432/autumn", databaseUrl: "postgresql://postgres:postgres@localhost:5432/autumn",
chatStateDatabaseUrl: "postgresql://postgres:postgres@localhost:5432/chat",
cacheUrl: "redis://localhost:6379", cacheUrl: "redis://localhost:6379",
dragonflyUrl: "redis://localhost:6380", dragonflyUrl: "redis://localhost:6380",
}; };
@@ -166,6 +167,32 @@ const doctor = async () => {
if (results.some((result) => !result)) process.exit(1); if (results.some((result) => !result)) process.exit(1);
}; };
const psql = ({ args, quiet = false }: { args: string[]; quiet?: boolean }) =>
dockerCompose({
args: ["exec", "-T", "postgres", "psql", "-U", "postgres", ...args],
quiet,
});
const ensureChatDatabase = () => {
const result = psql({
args: [
"-d",
"postgres",
"-tAc",
"SELECT 1 FROM pg_database WHERE datname = 'chat'",
],
quiet: true,
});
const exists = new TextDecoder().decode(result.stdout).trim() === "1";
if (exists) {
log("chat database already exists");
return;
}
log("creating chat database");
psql({ args: ["-d", "postgres", "-c", "CREATE DATABASE chat"] });
};
const up = async () => { const up = async () => {
log("starting Docker services"); log("starting Docker services");
dockerCompose({ args: ["up", "-d", "--remove-orphans"] }); dockerCompose({ args: ["up", "-d", "--remove-orphans"] });
@@ -176,6 +203,7 @@ const up = async () => {
waitForTcp({ port: localConfig.dragonflyPort, label: "Dragonfly" }), waitForTcp({ port: localConfig.dragonflyPort, label: "Dragonfly" }),
]); ]);
ensureChatDatabase();
await doctor(); await doctor();
}; };
@@ -219,6 +247,7 @@ Commands:
Local service values: Local service values:
DATABASE_URL=${localConfig.databaseUrl} DATABASE_URL=${localConfig.databaseUrl}
CHAT_STATE_DATABASE_URL=${localConfig.chatStateDatabaseUrl}
CACHE_URL=${localConfig.cacheUrl} CACHE_URL=${localConfig.cacheUrl}
CACHE_URL_US_EAST=${localConfig.cacheUrl} CACHE_URL_US_EAST=${localConfig.cacheUrl}
CACHE_V2_DRAGONFLY_URL=${localConfig.dragonflyUrl} CACHE_V2_DRAGONFLY_URL=${localConfig.dragonflyUrl}

View File

@@ -49,23 +49,23 @@ export const attachPoolErrorHandlers = ({
}; };
const emitSnapshot = (): void => { const emitSnapshot = (): void => {
const role = getRole(); // const role = getRole();
for (const { pool, name, max } of registry.values()) { // for (const { pool, name, max } of registry.values()) {
const totalCount = pool.totalCount; // const totalCount = pool.totalCount;
const idleCount = pool.idleCount; // const idleCount = pool.idleCount;
const waitingCount = pool.waitingCount; // const waitingCount = pool.waitingCount;
logger.debug("pg_pool_stats", { // logger.debug("pg_pool_stats", {
type: "pg_pool_stats", // type: "pg_pool_stats",
pool: name, // pool: name,
pid: process.pid, // pid: process.pid,
role, // role,
totalCount, // totalCount,
idleCount, // idleCount,
waitingCount, // waitingCount,
max, // max,
utilization: max > 0 ? totalCount / max : 0, // utilization: max > 0 ? totalCount / max : 0,
}); // });
} // }
}; };
export const startPgPoolMonitor = (intervalMs = 30_000): void => { export const startPgPoolMonitor = (intervalMs = 30_000): void => {

View File

@@ -0,0 +1,79 @@
import {
AppEnv,
AuthType,
ErrCode,
RecaseError,
sortFeatures,
} from "@autumn/shared";
import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { getOAuthAccessTokenRecord } from "@/internal/auth/oauth/oauthAccessTokenApiKey.js";
import { oauthConsentRepo } from "@/internal/auth/repos/index.js";
import { OrgService } from "@/internal/orgs/OrgService.js";
const getOAuthEnvironment = ({ c }: { c: Context<HonoEnv> }) => {
const env = c.req.header("x-autumn-environment") ?? AppEnv.Sandbox;
if (env === AppEnv.Live || env === AppEnv.Sandbox) return env;
throw new RecaseError({
message: "Invalid x-autumn-environment",
code: ErrCode.InvalidRequest,
statusCode: 400,
});
};
export const handleOAuthMiddleware = async ({
c,
token,
next,
}: {
c: Context<HonoEnv>;
token: string;
next: Next;
}) => {
const ctx = c.get("ctx");
const env = getOAuthEnvironment({ c });
const tokenRecord = await getOAuthAccessTokenRecord({
db: ctx.db,
accessToken: token,
resource: c.req.header("x-autumn-oauth-resource") ?? null,
requestedScopes: null,
});
const consent = await oauthConsentRepo.getForClientUserOrg({
db: ctx.db,
clientId: tokenRecord.clientId,
userId: tokenRecord.userId,
referenceId: tokenRecord.referenceId,
env,
});
if (!consent) {
throw new RecaseError({
message: "OAuth consent not found for environment",
code: ErrCode.InvalidRequest,
statusCode: 401,
});
}
const data = await OrgService.getWithFeatures({
db: ctx.db,
orgId: tokenRecord.referenceId,
env,
});
if (!data) {
throw new RecaseError({
message: "Org not found",
code: ErrCode.OrgNotFound,
statusCode: 404,
});
}
ctx.org = data.org;
ctx.features = sortFeatures({ features: data.features }) ?? [];
ctx.env = env;
ctx.userId = tokenRecord.userId;
ctx.authType = AuthType.SecretKey;
ctx.scopes = tokenRecord.scopes;
await next();
};

View File

@@ -1,8 +1,14 @@
import { getBearerToken } from "@autumn/auth"; import {
import { AuthType, ErrCode, type Feature, RecaseError } from "@autumn/shared"; getBearerToken,
isOAuthToken,
isPublishableKeyPrefix,
isSecretKeyPrefix,
} from "@autumn/auth";
import { AuthType, ErrCode, RecaseError, sortFeatures } from "@autumn/shared";
import type { Context, Next } from "hono"; import type { Context, Next } from "hono";
import type { HonoEnv } from "@/honoUtils/HonoEnv.js"; import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js"; import { verifyKey } from "@/internal/dev/api-keys/apiKeyUtils.js";
import { handleOAuthMiddleware } from "./authMiddlewares/handleOAuthMiddleware.js";
import { betterAuthMiddleware } from "./betterAuthMiddleware.js"; import { betterAuthMiddleware } from "./betterAuthMiddleware.js";
import { publicKeyMiddleware } from "./publicKeyMiddleware.js"; import { publicKeyMiddleware } from "./publicKeyMiddleware.js";
@@ -30,11 +36,11 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
return betterAuthMiddleware(c, next); return betterAuthMiddleware(c, next);
} }
const apiKey = getBearerToken({ headers: c.req.raw.headers }); const bearerToken = getBearerToken({ headers: c.req.raw.headers });
// Step 1 & 2: Check if Authorization header exists // Step 1 & 2: Check if Authorization header exists
// If from dashboard and no Bearer token, use Better Auth session instead // If from dashboard and no Bearer token, use Better Auth session instead
if (!apiKey) { if (!bearerToken) {
throw new RecaseError({ throw new RecaseError({
message: "Secret key not found in Authorization header", message: "Secret key not found in Authorization header",
code: ErrCode.NoSecretKey, code: ErrCode.NoSecretKey,
@@ -42,27 +48,31 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
}); });
} }
if (!apiKey.startsWith("am_")) { if (isOAuthToken({ token: bearerToken })) {
throw new RecaseError({ return handleOAuthMiddleware({ c, token: bearerToken, next });
message: `Invalid secret key: ${maskApiKey(apiKey)}`,
code: ErrCode.InvalidSecretKey,
statusCode: 401,
});
} }
// Step 3: Handle publishable key verification // Step 3: Handle publishable key verification
if (apiKey.startsWith("am_pk")) { if (isPublishableKeyPrefix({ token: bearerToken })) {
return publicKeyMiddleware(c, apiKey, next); return publicKeyMiddleware(c, bearerToken, next);
}
if (!isSecretKeyPrefix({ token: bearerToken })) {
throw new RecaseError({
message: "Invalid authorization token prefix",
code: ErrCode.InvalidRequest,
statusCode: 401,
});
} }
// Step 4: Verify the API key // Step 4: Verify the API key
const { valid, data } = await verifyKey({ const { valid, data } = await verifyKey({
db: ctx.db, db: ctx.db,
key: apiKey, key: bearerToken,
}); });
if (!valid || !data) { if (!valid || !data) {
const maskedKey = maskApiKey(apiKey); const maskedKey = maskApiKey(bearerToken);
throw new RecaseError({ throw new RecaseError({
message: `Invalid secret key: ${maskedKey}`, message: `Invalid secret key: ${maskedKey}`,
code: ErrCode.InvalidSecretKey, code: ErrCode.InvalidSecretKey,
@@ -74,13 +84,7 @@ export const secretKeyMiddleware = async (c: Context<HonoEnv>, next: Next) => {
const { org, features, env, userId } = data; const { org, features, env, userId } = data;
const scopes = (data as { scopes?: string[] | null }).scopes ?? []; const scopes = (data as { scopes?: string[] | null }).scopes ?? [];
if (features) { sortFeatures({ features });
features.sort((a: Feature, b: Feature) => {
if (a.archived && !b.archived) return 1;
if (!a.archived && b.archived) return -1;
return 0;
});
}
ctx.org = org; ctx.org = org;
ctx.features = features; ctx.features = features;

View File

@@ -45,6 +45,7 @@ import { handleUpsertAdminRateLimitRedisAllowlistConfig } from "./handleUpsertAd
import { handleUpsertAdminRedisV2CacheConfig } from "./handleUpsertAdminRedisV2CacheConfig"; import { handleUpsertAdminRedisV2CacheConfig } from "./handleUpsertAdminRedisV2CacheConfig";
import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig"; import { handleUpsertAdminRequestBlockConfig } from "./handleUpsertAdminRequestBlockConfig";
import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig"; import { handleUpsertAdminStripeSyncConfig } from "./handleUpsertAdminStripeSyncConfig";
import { handleUpsertSlackMcpOAuthClient } from "./handleUpsertSlackMcpOAuthClient";
import { handleDeleteRollout } from "./rollouts/handleDeleteRollout"; import { handleDeleteRollout } from "./rollouts/handleDeleteRollout";
import { handleDeleteRolloutOrg } from "./rollouts/handleDeleteRolloutOrg"; import { handleDeleteRolloutOrg } from "./rollouts/handleDeleteRolloutOrg";
import { handleGetRollouts } from "./rollouts/handleGetRollouts"; import { handleGetRollouts } from "./rollouts/handleGetRollouts";
@@ -160,6 +161,10 @@ honoAdminRouter.delete("/cache-v2-ramp", ...handleDeleteAdminCacheV2Ramp);
honoAdminRouter.get("/org-member", ...handleGetOrgMember); honoAdminRouter.get("/org-member", ...handleGetOrgMember);
honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount); honoAdminRouter.get("/master-stripe-account", ...handleGetMasterStripeAccount);
honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients); honoAdminRouter.get("/oauth-clients", ...handleListOAuthClients);
honoAdminRouter.post(
"/oauth-clients/slack-mcp",
...handleUpsertSlackMcpOAuthClient,
);
honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems); honoAdminRouter.post("/invoice-line-items", ...handleGetInvoiceLineItems);
honoAdminRouter.post( honoAdminRouter.post(
"/customer-products/:customer_product_id/send-updated-webhook", "/customer-products/:customer_product_id/send-updated-webhook",

View File

@@ -0,0 +1,37 @@
import { ErrCode, RecaseError, Scopes } from "@autumn/shared";
import { registerMcpOAuthClient } from "@/internal/auth/actions/index.js";
import { createRoute } from "../../honoMiddlewares/routeHandler";
const getClientUrl = () =>
(process.env.CLIENT_URL || "http://localhost:3000").replace(/\/+$/, "");
const getSlackMcpRedirectUris = () => {
const clientUrl = getClientUrl();
return [
`${clientUrl}/admin/oauth/slack-mcp/callback`,
`${clientUrl}/sandbox/admin/oauth/slack-mcp/callback`,
];
};
export const handleUpsertSlackMcpOAuthClient = createRoute({
scopes: [Scopes.Superuser],
handler: async (c) => {
const { db } = c.get("ctx");
const result = await registerMcpOAuthClient({
db,
clientName: "Slack MCP",
redirectUris: getSlackMcpRedirectUris(),
scope: undefined,
});
if ("error" in result) {
throw new RecaseError({
message: result.error,
code: ErrCode.InvalidRequest,
statusCode: result.status,
});
}
return c.json(result.body, result.status);
},
});

View File

@@ -4,6 +4,7 @@ import { generateId } from "@/utils/genUtils.js";
import { type OAuthClientRecord, oauthClientRepo } from "../repos/index.js"; import { type OAuthClientRecord, oauthClientRepo } from "../repos/index.js";
const MCP_CLIENT_KIND = "mcp_client"; const MCP_CLIENT_KIND = "mcp_client";
export const SLACK_MCP_OAUTH_CLIENT_ID = "autumn_mcp_slack";
const REGISTER_CACHE_TTL_MS = 5 * 60 * 1000; const REGISTER_CACHE_TTL_MS = 5 * 60 * 1000;
const DANGEROUS_REDIRECT_SCHEMES = new Set([ const DANGEROUS_REDIRECT_SCHEMES = new Set([
"javascript:", "javascript:",
@@ -107,7 +108,11 @@ const classifyMcpClient = ({
return { type: "codex", name: "Codex", clientId: "autumn_mcp_codex" }; return { type: "codex", name: "Codex", clientId: "autumn_mcp_codex" };
} }
if (haystack.includes("slack")) { if (haystack.includes("slack")) {
return { type: "slack", name: "Slack", clientId: "autumn_mcp_slack" }; return {
type: "slack",
name: "Slack",
clientId: SLACK_MCP_OAUTH_CLIENT_ID,
};
} }
return null; return null;

View File

@@ -1,7 +1,9 @@
import { prefixOAuthToken } from "@autumn/auth";
import { RecaseError } from "@autumn/shared"; import { RecaseError } from "@autumn/shared";
import type { Context } from "hono"; import type { Context } from "hono";
import { db } from "@/db/initDrizzle.js"; import { db } from "@/db/initDrizzle.js";
import { auth } from "@/utils/auth.js"; import { auth } from "@/utils/auth.js";
import { SLACK_MCP_OAUTH_CLIENT_ID } from "../actions/registerMcpOAuthClient.js";
import { import {
getExternalOAuthApiKeyForToken, getExternalOAuthApiKeyForToken,
getOAuthAccessTokenRecord, getOAuthAccessTokenRecord,
@@ -48,6 +50,30 @@ const rewriteTokenBody = ({
}; };
}; };
const rewriteOAuthAccessTokenBody = ({
accessToken,
body,
}: {
accessToken: string;
body: Record<string, unknown>;
}) => {
const response = body.response;
if (isRecord(response)) {
return {
...body,
response: {
...response,
access_token: accessToken,
},
};
}
return {
...body,
access_token: accessToken,
};
};
const tokenResponseHeaders = (response?: Response) => { const tokenResponseHeaders = (response?: Response) => {
const headers = new Headers(response?.headers); const headers = new Headers(response?.headers);
headers.set("Content-Type", "application/json"); headers.set("Content-Type", "application/json");
@@ -116,6 +142,16 @@ export const handleOAuthTokenWithApiKey = async (c: Context) => {
resource, resource,
requestedScopes, requestedScopes,
}); });
if (tokenRecord.clientId === SLACK_MCP_OAUTH_CLIENT_ID) {
return jsonTokenResponse({
body: rewriteOAuthAccessTokenBody({
accessToken: prefixOAuthToken({ token: accessToken }),
body,
}),
response,
status: response.status,
});
}
apiKeyResult = await getExternalOAuthApiKeyForToken({ apiKeyResult = await getExternalOAuthApiKeyForToken({
db, db,
tokenRecord, tokenRecord,

View File

@@ -1,3 +1,4 @@
import { stripOAuthTokenPrefix } from "@autumn/auth";
import { import {
AppEnv, AppEnv,
checkScopes, checkScopes,
@@ -59,12 +60,13 @@ export const getOAuthAccessTokenRecord = async ({
resource: string | null; resource: string | null;
requestedScopes: ScopeString[] | null; requestedScopes: ScopeString[] | null;
}) => { }) => {
const hashedToken = await hashOAuthToken(accessToken); const rawAccessToken = stripOAuthTokenPrefix({ token: accessToken });
const tokenValues = [...new Set([hashedToken, accessToken])]; const hashedToken = await hashOAuthToken(rawAccessToken);
const tokenValues = [...new Set([hashedToken, rawAccessToken])];
const tokenRecord = const tokenRecord =
(await oauthAccessTokenRepo.getValidByTokenValues({ db, tokenValues })) ?? (await oauthAccessTokenRepo.getValidByTokenValues({ db, tokenValues })) ??
(await verifyResourceAccessToken({ (await verifyResourceAccessToken({
accessToken, accessToken: rawAccessToken,
resource, resource,
requestedScopes, requestedScopes,
})); }));

View File

@@ -80,11 +80,13 @@ export const getOAuthConsentForClientUserOrg = async ({
clientId, clientId,
userId, userId,
referenceId, referenceId,
env,
}: { }: {
db: DrizzleCli; db: DrizzleCli;
clientId: string; clientId: string;
userId: string; userId: string;
referenceId: string; referenceId: string;
env?: AppEnv;
}) => { }) => {
const [consent] = await db const [consent] = await db
.select({ .select({
@@ -99,6 +101,7 @@ export const getOAuthConsentForClientUserOrg = async ({
eq(oauthConsent.clientId, clientId), eq(oauthConsent.clientId, clientId),
eq(oauthConsent.userId, userId), eq(oauthConsent.userId, userId),
eq(oauthConsent.referenceId, referenceId), eq(oauthConsent.referenceId, referenceId),
...(env ? [eq(oauthConsent.env, env)] : []),
), ),
) )
.limit(1); .limit(1);

View File

@@ -5,6 +5,7 @@ import { actions } from "../models/analyticsModels/actionTable.js";
import { import {
chatApprovals, chatApprovals,
chatInstallations, chatInstallations,
chatOAuthCredentials,
} from "../models/chatModels/chatTable.js"; } from "../models/chatModels/chatTable.js";
import { chatResults } from "../models/chatResultModels/chatResultTable.js"; import { chatResults } from "../models/chatResultModels/chatResultTable.js";
import { checkoutsRelations } from "../models/checkouts/checkoutRelations.js"; import { checkoutsRelations } from "../models/checkouts/checkoutRelations.js";
@@ -107,6 +108,7 @@ export {
autoTopupLimitStates as autoTopupLimits, autoTopupLimitStates as autoTopupLimits,
chatApprovals, chatApprovals,
chatInstallations, chatInstallations,
chatOAuthCredentials,
chatResults, chatResults,
checkouts, checkouts,
checkoutsRelations, checkoutsRelations,

View File

@@ -16,10 +16,10 @@ CREATE TABLE "passkey" (
ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint ALTER TABLE "passkey" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint ALTER TABLE "migration_runs" ADD COLUMN "target_limit" numeric;--> statement-breakpoint
ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint ALTER TABLE "passkey" ADD CONSTRAINT "passkey_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint CREATE INDEX CONCURRENTLY "passkey_userId_idx" ON "passkey" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint CREATE INDEX CONCURRENTLY "passkey_credentialId_idx" ON "passkey" USING btree ("credential_id");--> statement-breakpoint
CREATE INDEX "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint CREATE INDEX CONCURRENTLY "idx_customer_products_revenuecat_processor" ON "customer_products" USING btree ("internal_customer_id") WHERE ("customer_products"."processor" ->> 'type') = 'revenuecat';--> statement-breakpoint
CREATE INDEX "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint CREATE INDEX CONCURRENTLY "idx_customers_cursor" ON "customers" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
CREATE INDEX "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint CREATE INDEX CONCURRENTLY "idx_entities_cursor" ON "entities" USING btree ("org_id","env","created_at" DESC,"id" DESC);--> statement-breakpoint
CREATE INDEX "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint CREATE INDEX CONCURRENTLY "idx_entitlements_internal_reward_id_c_partial" ON "entitlements" USING btree ("internal_reward_id" COLLATE "C") WHERE "entitlements"."internal_reward_id" IS NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled'; CREATE UNIQUE INDEX CONCURRENTLY "vercel_resources_installation_name_unique_idx" ON "vercel_resources" USING btree ("org_id","env","installation_id","name") WHERE status <> 'uninstalled';

View File

@@ -1 +1 @@
CREATE INDEX "idx_invoice_line_items_customer_product_ids" ON "invoice_line_items" USING gin ("customer_product_ids"); CREATE INDEX CONCURRENTLY "idx_invoice_line_items_customer_product_ids" ON "invoice_line_items" USING gin ("customer_product_ids");

View File

@@ -0,0 +1,18 @@
CREATE TABLE "chat_oauth_credentials" (
"id" text PRIMARY KEY NOT NULL,
"chat_installation_id" text NOT NULL,
"org_id" text NOT NULL,
"env" text NOT NULL,
"oauth_client_id" text NOT NULL,
"oauth_consent_id" text,
"access_token" text NOT NULL,
"refresh_token" text NOT NULL,
"access_token_expires_at" numeric NOT NULL,
"scopes" jsonb NOT NULL,
"created_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL,
"updated_at" numeric DEFAULT ROUND(date_part('epoch', NOW()) * 1000)::BIGINT NOT NULL,
CONSTRAINT "chat_oauth_credentials_installation_env_key" UNIQUE("chat_installation_id","env")
);
--> statement-breakpoint
ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_installation_id_fkey" FOREIGN KEY ("chat_installation_id") REFERENCES "public"."chat_installations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "chat_oauth_credentials" ADD CONSTRAINT "chat_oauth_credentials_org_id_fkey" FOREIGN KEY ("org_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;

View File

@@ -1,5 +1,5 @@
{ {
"id": "b9eaa778-a288-48ca-8e0f-42b17e0dc873", "id": "40c5361a-8cff-473f-93c1-4dfbc06b00d7",
"prevId": "9e1bb4b2-1869-4ca9-ba67-8fbcea263c37", "prevId": "9e1bb4b2-1869-4ca9-ba67-8fbcea263c37",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -840,6 +840,129 @@
"checkConstraints": {}, "checkConstraints": {},
"isRLSEnabled": false "isRLSEnabled": false
}, },
"public.chat_oauth_credentials": {
"name": "chat_oauth_credentials",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"chat_installation_id": {
"name": "chat_installation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"org_id": {
"name": "org_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"env": {
"name": "env",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oauth_client_id": {
"name": "oauth_client_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"oauth_consent_id": {
"name": "oauth_consent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "numeric",
"primaryKey": false,
"notNull": true
},
"scopes": {
"name": "scopes",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "numeric",
"primaryKey": false,
"notNull": true,
"default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT"
},
"updated_at": {
"name": "updated_at",
"type": "numeric",
"primaryKey": false,
"notNull": true,
"default": "ROUND(date_part('epoch', NOW()) * 1000)::BIGINT"
}
},
"indexes": {},
"foreignKeys": {
"chat_oauth_credentials_installation_id_fkey": {
"name": "chat_oauth_credentials_installation_id_fkey",
"tableFrom": "chat_oauth_credentials",
"tableTo": "chat_installations",
"columnsFrom": [
"chat_installation_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"chat_oauth_credentials_org_id_fkey": {
"name": "chat_oauth_credentials_org_id_fkey",
"tableFrom": "chat_oauth_credentials",
"tableTo": "organizations",
"columnsFrom": [
"org_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"chat_oauth_credentials_installation_env_key": {
"name": "chat_oauth_credentials_installation_env_key",
"nullsNotDistinct": false,
"columns": [
"chat_installation_id",
"env"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.chat_results": { "public.chat_results": {
"name": "chat_results", "name": "chat_results",
"schema": "", "schema": "",
@@ -4524,13 +4647,6 @@
"notNull": true, "notNull": true,
"default": false "default": false
}, },
"archived": {
"name": "archived",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": { "created_at": {
"name": "created_at", "name": "created_at",
"type": "numeric", "type": "numeric",
@@ -7397,4 +7513,4 @@
"schemas": {}, "schemas": {},
"tables": {} "tables": {}
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -61,6 +61,13 @@
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1780679328640,
"tag": "0008_premium_pet_avengers",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780665982843, "when": 1780665982843,
"tag": "0008_slippery_william_stryker", "tag": "0008_slippery_william_stryker",
"breakpoints": true "breakpoints": true

View File

@@ -81,5 +81,42 @@ export const chatApprovals = pgTable(
], ],
); );
export const chatOAuthCredentials = pgTable(
"chat_oauth_credentials",
{
id: text().primaryKey().notNull(),
chat_installation_id: text("chat_installation_id").notNull(),
org_id: text("org_id").notNull(),
env: text("env").$type<AppEnv>().notNull(),
oauth_client_id: text("oauth_client_id").notNull(),
oauth_consent_id: text("oauth_consent_id"),
access_token: text("access_token").notNull(),
refresh_token: text("refresh_token").notNull(),
access_token_expires_at: numeric("access_token_expires_at", {
mode: "number",
}).notNull(),
scopes: jsonb().$type<string[]>().notNull(),
created_at: numeric({ mode: "number" }).notNull().default(sqlNow),
updated_at: numeric({ mode: "number" }).notNull().default(sqlNow),
},
(table) => [
foreignKey({
columns: [table.chat_installation_id],
foreignColumns: [chatInstallations.id],
name: "chat_oauth_credentials_installation_id_fkey",
}).onDelete("cascade"),
foreignKey({
columns: [table.org_id],
foreignColumns: [organizations.id],
name: "chat_oauth_credentials_org_id_fkey",
}).onDelete("cascade"),
unique("chat_oauth_credentials_installation_env_key").on(
table.chat_installation_id,
table.env,
),
],
);
export type ChatInstallation = typeof chatInstallations.$inferSelect; export type ChatInstallation = typeof chatInstallations.$inferSelect;
export type ChatApproval = typeof chatApprovals.$inferSelect; export type ChatApproval = typeof chatApprovals.$inferSelect;
export type ChatOAuthCredential = typeof chatOAuthCredentials.$inferSelect;

View File

@@ -7,6 +7,7 @@ export * from "./apiFeatureToDbFeature";
export * from "./convertFeatureUtils"; export * from "./convertFeatureUtils";
export * from "./creditSystemUtils"; export * from "./creditSystemUtils";
export * from "./findFeatureUtils"; export * from "./findFeatureUtils";
export * from "./sortFeatures";
export const featureUtils = { export const featureUtils = {
isConsumable: isConsumableFeature, isConsumable: isConsumableFeature,

View File

@@ -0,0 +1,13 @@
import type { Feature } from "../../models/featureModels/featureModels.js";
export const sortFeatures = ({ features }: { features?: Feature[] }) => {
if (!features) return features;
features.sort((a, b) => {
if (a.archived && !b.archived) return 1;
if (!a.archived && b.archived) return -1;
return 0;
});
return features;
};

View File

@@ -1,9 +1,10 @@
import { AppEnv } from "@autumn/shared"; import { AppEnv } from "@autumn/shared";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
Globe, Globe,
Key, Key,
MessageSquare,
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
@@ -62,6 +63,25 @@ export const OAuthClientsView = () => {
}); });
const clients: OAuthClient[] = data?.clients || []; const clients: OAuthClient[] = data?.clients || [];
const upsertSlackMcpMutation = useMutation({
mutationFn: async () => {
const { data } = await axiosInstance.post(
"/admin/oauth-clients/slack-mcp",
);
return data;
},
onSuccess: (client) => {
toast.success(
`Slack MCP OAuth client ready: ${client.client_id ?? "autumn_mcp_slack"}`,
);
refetch();
},
onError: (error) => {
toast.error(
getBackendErr(error, "Failed to create Slack MCP OAuth client"),
);
},
});
const handleDeleteClient = async (client_id: string) => { const handleDeleteClient = async (client_id: string) => {
if (!confirm("Are you sure you want to delete this OAuth client?")) { if (!confirm("Are you sure you want to delete this OAuth client?")) {
@@ -157,6 +177,15 @@ export const OAuthClientsView = () => {
> >
Refresh Refresh
</IconButton> </IconButton>
<IconButton
variant="secondary"
size="sm"
icon={<MessageSquare className="w-4 h-4" />}
onClick={() => upsertSlackMcpMutation.mutate()}
disabled={upsertSlackMcpMutation.isPending}
>
Add Slack MCP
</IconButton>
<IconButton <IconButton
variant="primary" variant="primary"
size="sm" size="sm"