feat: Require API keys from non langchain users (#448)

* feat: Require API keys from non langchain users

* cr

* cr
This commit is contained in:
Brace Sproul 2025-07-17 18:10:48 -07:00 • committed by GitHub
parent 9e5f042c0e
commit dda277456a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 361 additions and 83 deletions

View file

@ -20,6 +20,7 @@ import {
} from "../../utils/github/label.js";
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
import { RequestSource } from "../../constants.js";
import { isAllowedUser } from "../../utils/github/allowed-users.js";
const logger = createLogger(LogLevel.INFO, "GitHubIssueWebhook");
@ -129,6 +130,13 @@ webhooks.on("issues.labeled", async ({ payload }) => {
userLogin: payload.sender.login,
};
if (!isAllowedUser(issueData.userLogin)) {
logger.error("User is not a member of allowed orgs", {
username: issueData.userLogin,
});
return;
}
const langGraphClient = createLangGraphClient({
defaultHeaders: {
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptSecret(

View file

@ -5,6 +5,7 @@ import {
verifyGithubUserId,
} from "@open-swe/shared/github/verify-user";
import {
API_KEY_REQUIRED_MESSAGE,
GITHUB_INSTALLATION_NAME,
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
@ -16,6 +17,8 @@ import { verifyGitHubWebhookOrThrow } from "./github.js";
import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js";
import { LANGGRAPH_USER_PERMISSIONS } from "../constants.js";
import { getGitHubPatFromRequest } from "../utils/github-pat.js";
import { isAllowedUser } from "../utils/github/allowed-users.js";
import { validate } from "uuid";
// TODO: Export from LangGraph SDK
export interface BaseAuthReturn {
@ -31,6 +34,58 @@ interface AuthenticateReturn extends BaseAuthReturn {
};
}
function apiKeysInRequestBody(
bodyStr: string | Record<string, unknown>,
): boolean {
try {
const body = typeof bodyStr === "string" ? JSON.parse(bodyStr) : bodyStr;
if (
body.config?.configurable &&
("anthropicApiKey" in body.config.configurable.apiKeys ||
"openaiApiKey" in body.config.configurable.apiKeys ||
"googleApiKey" in body.config.configurable.apiKeys)
) {
return true;
}
return false;
} catch {
// no-op
return false;
}
}
function isRunReq(reqUrl: string): boolean {
try {
const url = new URL(reqUrl);
const pathnameParts = url.pathname.split("/");
const isCreateAndWait = !!(
pathnameParts[1] === "threads" &&
validate(pathnameParts[2]) &&
pathnameParts[3] === "runs" &&
pathnameParts[4] === "wait" &&
pathnameParts.length === 5
);
const isCreateBackground = !!(
pathnameParts[1] === "threads" &&
validate(pathnameParts[2]) &&
pathnameParts[3] === "runs" &&
pathnameParts.length === 4
);
const isCreateStream = !!(
pathnameParts[1] === "threads" &&
validate(pathnameParts[2]) &&
pathnameParts[3] === "runs" &&
pathnameParts[4] === "stream" &&
pathnameParts.length === 5
);
return !!isCreateAndWait || !!isCreateBackground || !!isCreateStream;
} catch {
// no-op
return false;
}
}
export const auth = new Auth()
.authenticate<AuthenticateReturn>(async (request: Request) => {
if (request.method === "OPTIONS") {
@ -100,10 +155,18 @@ export const auth = new Auth()
});
}
const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
const decryptedAccessToken = encryptedAccessToken
? decryptSecret(encryptedAccessToken, encryptionKey)
: undefined;
const decryptedInstallationToken = decryptSecret(
encryptedInstallationToken,
encryptionKey,
);
let user: GithubUser | undefined;
const encryptedAccessToken = request.headers.get(GITHUB_TOKEN_COOKIE);
if (!encryptedAccessToken) {
if (!decryptedAccessToken) {
// If there isn't a user access token, check to see if the user info is in headers.
// This would indicate a bot created the request.
const userIdHeader = request.headers.get(GITHUB_USER_ID_HEADER);
@ -114,15 +177,13 @@ export const auth = new Auth()
});
}
user = await verifyGithubUserId(
decryptSecret(encryptedInstallationToken, encryptionKey),
decryptedInstallationToken,
Number(userIdHeader),
userLoginHeader,
);
} else {
// Ensure we decrypt the token before passing to the verification function.
user = await verifyGithubUser(
decryptSecret(encryptedAccessToken, encryptionKey),
);
user = await verifyGithubUser(decryptedAccessToken);
}
if (!user) {
@ -131,6 +192,18 @@ export const auth = new Auth()
});
}
const reqCopy = request.clone();
const reqBody = await reqCopy.text();
if (!isAllowedUser(user.login)) {
if (isRunReq(request.url)) {
if (!apiKeysInRequestBody(reqBody)) {
throw new HTTPException(401, {
message: API_KEY_REQUIRED_MESSAGE,
});
}
}
}
return {
identity: user.id.toString(),
is_authenticated: true,

View file

@ -14,7 +14,10 @@ export function getCustomConfigurableFields(
GraphConfigurationMetadata,
)) {
if (key in config.configurable) {
if (metadataValue.x_open_swe_ui_config.type !== "hidden") {
if (
metadataValue.x_open_swe_ui_config.type !== "hidden" ||
key === "apiKeys"
) {
result[key as keyof GraphConfig["configurable"]] =
config.configurable[key as keyof GraphConfig["configurable"]];
}

View file

@ -0,0 +1,66 @@
export const ALLOWED_USERS = [
"agola11",
"akira",
"aliyanishfaq",
"andrewnguonly",
"angus-langchain",
"bracesproul",
"ArthurLangChain",
"baskaryan",
"bvs-langchain",
"catherine-langchain",
"ccurme",
"crystalro0",
"dqbd",
"emily-langchain",
"eric-langchain",
"EugeneJinXin",
"eyurtsev",
"gladwig2",
"hari-dhanushkodi",
"hinthornw",
"hntrl",
"hwchase17",
"iakshay",
"isahers1",
"j-broekhuizen",
"jacoblee93",
"jdrogers940",
"joaquin-borggio-lc",
"katmayb",
"keshivtandon",
"langchain-infra",
"lc-arjun",
"lc-chad",
"lnhsingh",
"madams0013",
"mdrxy",
"mhk197",
"nfcampos",
"nhuang-lc",
"nitboss",
"PeriniM",
"phvash",
"QuentinBrosse",
"rlancemartin",
"romain-priour-lc",
"samecrowder",
"samnoyes",
"starmorph",
"suraj-langchain",
"sydney-runkle",
"tanushree-sharma",
"victorm-lc",
"xornivore",
"xuro-langchain",
];
// HACK: Until we setup proper support for API credits, we will only allow users to self host Open SWE
export function isAllowedUser(username: string): boolean {
const restrictToLangChainAuth =
process.env.RESTRICT_TO_LANGCHAIN_AUTH === "true";
if (!restrictToLangChainAuth) {
return true;
}
return ALLOWED_USERS.some((u) => u === username);
}

View file

@ -1,5 +1,7 @@
import { initChatModel } from "langchain/chat_models/universal";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { isAllowedUser } from "./github/allowed-users.js";
import { decryptSecret } from "@open-swe/shared/crypto";
export enum Task {
/**
@ -35,6 +37,22 @@ const TASK_TO_CONFIG_DEFAULTS_MAP = {
},
};
const providerToApiKey = (
providerName: string,
apiKeys: Record<string, string>,
): string => {
switch (providerName) {
case "openai":
return apiKeys.openaiApiKey;
case "anthropic":
return apiKeys.anthropicApiKey;
case "google-genai":
return apiKeys.googleApiKey;
default:
throw new Error(`Unknown provider: ${providerName}`);
}
};
export async function loadModel(config: GraphConfig, task: Task) {
const modelStr =
config.configurable?.[`${task}ModelName`] ??
@ -66,9 +84,35 @@ export async function loadModel(config: GraphConfig, task: Task) {
maxTokens = maxTokens > 8_192 ? 8_192 : maxTokens;
}
// TODO: Fix types
const userLogin = (config.configurable as any)?.langgraph_auth_user
?.display_name;
const secretsEncryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!secretsEncryptionKey) {
throw new Error("SECRETS_ENCRYPTION_KEY environment variable is required");
}
if (!userLogin) {
throw new Error("User login not found in config");
}
const apiKeys = config.configurable?.apiKeys;
let apiKey: string | null = null;
if (!isAllowedUser(userLogin)) {
if (!apiKeys) {
throw new Error("API keys not found in config");
}
apiKey = decryptSecret(
providerToApiKey(modelProvider, apiKeys),
secretsEncryptionKey,
);
if (!apiKey) {
throw new Error("No API key found for provider: " + modelProvider);
}
}
const model = await initChatModel(modelName, {
modelProvider,
temperature: thinkingModel ? undefined : temperature,
...(apiKey ? { apiKey } : {}),
...(thinkingModel && modelProvider === "anthropic"
? {
thinking: { budget_tokens: thinkingBudgetTokens, type: "enabled" },

View file

@ -10,6 +10,7 @@ import {
getInstallationNameFromReq,
getGitHubAccessTokenOrThrow,
} from "./utils";
import { encryptSecret } from "@open-swe/shared/crypto";
// This file acts as a proxy for requests to your LangGraph server.
// Read the [Going to Production](https://github.com/langchain-ai/agent-chat-ui?tab=readme-ov-file#going-to-production) section for more information.
@ -19,6 +20,33 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
apiUrl: process.env.LANGGRAPH_API_URL ?? "http://localhost:2024",
runtime: "edge", // default
disableWarningLog: true,
bodyParameters: (req, body) => {
if (body.config?.configurable && "apiKeys" in body.config.configurable) {
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error(
"SECRETS_ENCRYPTION_KEY environment variable is required",
);
}
const apiKeys = body.config.configurable.apiKeys;
const encryptedApiKeys: Record<string, unknown> = {};
// Encrypt each field in the apiKeys object
for (const [key, value] of Object.entries(apiKeys)) {
if (typeof value === "string" && value.trim() !== "") {
encryptedApiKeys[key] = encryptSecret(value, encryptionKey);
} else {
encryptedApiKeys[key] = value;
}
}
// Update the body with encrypted apiKeys
body.config.configurable.apiKeys = encryptedApiKeys;
return body;
}
return body;
},
headers: async (req) => {
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!encryptionKey) {

View file

@ -11,12 +11,20 @@ const Toaster = ({ ...props }: ToasterProps) => {
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg dark:group-[.toaster]:bg-slate-950 dark:group-[.toaster]:border-slate-800 dark:group-[.toaster]:shadow-2xl dark:group-[.toaster]:shadow-black/20",
description:
"group-[.toast]:text-muted-foreground dark:group-[.toast]:text-slate-400",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium",
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground font-medium hover:group-[.toast]:bg-primary/90 dark:group-[.toast]:bg-slate-200 dark:group-[.toast]:text-slate-900 dark:hover:group-[.toast]:bg-slate-100",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium",
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground font-medium hover:group-[.toast]:bg-muted/80 dark:group-[.toast]:bg-slate-800 dark:group-[.toast]:text-slate-300 dark:hover:group-[.toast]:bg-slate-700",
success:
"group-[.toast]:bg-green-50 group-[.toast]:text-green-900 group-[.toast]:border-green-200 dark:group-[.toast]:bg-green-950/50 dark:group-[.toast]:text-green-100 dark:group-[.toast]:border-green-800/50",
error:
"group-[.toast]:bg-red-50 group-[.toast]:text-red-900 group-[.toast]:border-red-200 dark:group-[.toast]:bg-red-950/50 dark:group-[.toast]:text-red-100 dark:group-[.toast]:border-red-800/50",
warning:
"group-[.toast]:bg-yellow-50 group-[.toast]:text-yellow-900 group-[.toast]:border-yellow-200 dark:group-[.toast]:bg-yellow-950/50 dark:group-[.toast]:text-yellow-100 dark:group-[.toast]:border-yellow-800/50",
info: "group-[.toast]:bg-blue-50 group-[.toast]:text-blue-900 group-[.toast]:border-blue-200 dark:group-[.toast]:bg-blue-950/50 dark:group-[.toast]:text-blue-100 dark:group-[.toast]:border-blue-800/50",
},
}}
{...props}

View file

@ -14,7 +14,10 @@ import { GraphState } from "@open-swe/shared/open-swe/types";
import { Base64ContentBlock, HumanMessage } from "@langchain/core/messages";
import { toast } from "sonner";
import { DEFAULT_CONFIG_KEY, useConfigStore } from "@/hooks/useConfigStore";
import { MANAGER_GRAPH_ID } from "@open-swe/shared/constants";
import {
API_KEY_REQUIRED_MESSAGE,
MANAGER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { ManagerGraphUpdate } from "@open-swe/shared/open-swe/manager/types";
import { useDraftStorage } from "@/hooks/useDraftStorage";
@ -113,7 +116,31 @@ export function TerminalInput({
setContentBlocks([]);
setAutoAcceptPlan(false);
} catch (e) {
console.error(e);
if (
typeof e === "object" &&
e !== null &&
"message" in e &&
e.message !== null &&
typeof e.message === "string" &&
e.message.includes(API_KEY_REQUIRED_MESSAGE)
) {
toast.error(
<p>
{API_KEY_REQUIRED_MESSAGE} Please add your API key(s) in{" "}
<a
className="text-blue-500 underline underline-offset-1 dark:text-blue-400"
href="/settings?tab=api-keys"
>
settings
</a>
</p>,
{
richColors: true,
duration: 30_000,
closeButton: true,
},
);
}
} finally {
setLoading(false);
}

View file

@ -12,10 +12,12 @@ import { Eye, EyeOff, Key, Trash2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { useConfigStore, DEFAULT_CONFIG_KEY } from "@/hooks/useConfigStore";
interface ApiKey {
id: string;
name: string;
description?: string;
value: string;
isVisible: boolean;
lastUsed?: string;
@ -26,72 +28,82 @@ interface ApiKeySection {
keys: ApiKey[];
}
const API_KEY_SECTIONS: Record<string, Omit<ApiKeySection, "keys">> = {
llms: {
title: "LLMs",
},
infrastructure: {
title: "Infrastructure",
},
};
const API_KEY_DEFINITIONS = {
llms: [
{ id: "anthropicApiKey", name: "Anthropic" },
{ id: "openaiApiKey", name: "OpenAI" },
{ id: "googleApiKey", name: "Google Gen AI" },
],
infrastructure: [
{
id: "daytonaApiKey",
name: "Daytona",
description: "Users not required to set this if using the demo",
},
],
};
export function APIKeysTab() {
const [apiKeySections, setApiKeySections] = useState<
Record<string, ApiKeySection>
>({
llms: {
title: "LLMs",
keys: [
{
id: "anthropicApiKey",
name: "Anthropic",
value: "",
isVisible: false,
},
{ id: "openaiApiKey", name: "OpenAI", value: "", isVisible: false },
{
id: "googleApiKey",
name: "Google Gen AI",
value: "",
isVisible: false,
},
],
},
infrastructure: {
title: "Infrastructure",
keys: [
{ id: "daytonaApiKey", name: "Daytona", value: "", isVisible: false },
],
},
});
const { getConfig, updateConfig } = useConfigStore();
const config = getConfig(DEFAULT_CONFIG_KEY);
const toggleKeyVisibility = (sectionKey: string, keyId: string) => {
setApiKeySections((prev) => ({
const [visibilityState, setVisibilityState] = useState<
Record<string, boolean>
>({});
const toggleKeyVisibility = (keyId: string) => {
setVisibilityState((prev) => ({
...prev,
[sectionKey]: {
...prev[sectionKey],
keys: prev[sectionKey].keys.map((key) =>
key.id === keyId ? { ...key, isVisible: !key.isVisible } : key,
),
},
[keyId]: !prev[keyId],
}));
};
const updateApiKey = (sectionKey: string, keyId: string, value: string) => {
setApiKeySections((prev) => ({
...prev,
[sectionKey]: {
...prev[sectionKey],
keys: prev[sectionKey].keys.map((key) =>
key.id === keyId ? { ...key, value } : key,
),
},
}));
const updateApiKey = (keyId: string, value: string) => {
const currentApiKeys = config.apiKeys || {};
updateConfig(DEFAULT_CONFIG_KEY, "apiKeys", {
...currentApiKeys,
[keyId]: value,
});
};
const deleteApiKey = (sectionKey: string, keyId: string) => {
setApiKeySections((prev) => ({
...prev,
[sectionKey]: {
...prev[sectionKey],
keys: prev[sectionKey].keys.map((key) =>
key.id === keyId ? { ...key, value: "" } : key,
),
},
}));
const deleteApiKey = (keyId: string) => {
const currentApiKeys = config.apiKeys || {};
const updatedApiKeys = { ...currentApiKeys };
delete updatedApiKeys[keyId];
updateConfig(DEFAULT_CONFIG_KEY, "apiKeys", updatedApiKeys);
};
const getApiKeySections = (): Record<string, ApiKeySection> => {
const sections: Record<string, ApiKeySection> = {};
const apiKeys = config.apiKeys || {};
Object.entries(API_KEY_SECTIONS).forEach(([sectionKey, sectionInfo]) => {
sections[sectionKey] = {
...sectionInfo,
keys: API_KEY_DEFINITIONS[
sectionKey as keyof typeof API_KEY_DEFINITIONS
].map((keyDef) => ({
...keyDef,
value: apiKeys[keyDef.id] || "",
isVisible: visibilityState[keyDef.id] || false,
})),
};
});
return sections;
};
const apiKeySections = getApiKeySections();
return (
<div className="space-y-8">
{Object.entries(apiKeySections).map(([sectionKey, section]) => (
@ -148,13 +160,18 @@ export function APIKeysTab() {
>
API Key
</Label>
{apiKey.description && (
<p className="text-muted-foreground text-xs">
{apiKey.description}
</p>
)}
<div className="mt-1 flex items-center gap-2">
<Input
id={`${apiKey.id}-key`}
type={apiKey.isVisible ? "text" : "password"}
value={apiKey.value}
onChange={(e) =>
updateApiKey(sectionKey, apiKey.id, e.target.value)
updateApiKey(apiKey.id, e.target.value)
}
placeholder={`Enter your ${apiKey.name} API key`}
className="font-mono text-sm"
@ -162,9 +179,7 @@ export function APIKeysTab() {
<Button
variant="ghost"
size="sm"
onClick={() =>
toggleKeyVisibility(sectionKey, apiKey.id)
}
onClick={() => toggleKeyVisibility(apiKey.id)}
className="px-2"
>
{apiKey.isVisible ? (
@ -177,7 +192,7 @@ export function APIKeysTab() {
<Button
variant="ghost"
size="sm"
onClick={() => deleteApiKey(sectionKey, apiKey.id)}
onClick={() => deleteApiKey(apiKey.id)}
className={cn(
"px-2",
"text-destructive hover:bg-destructive/10 hover:text-destructive",
@ -192,16 +207,8 @@ export function APIKeysTab() {
<div className="flex items-center justify-between">
<p className="text-muted-foreground text-xs">
Your API key is stored securely and encrypted
Your API key is stored
</p>
{apiKey.value && (
<Button
size="sm"
variant="outline"
>
Test Connection
</Button>
)}
</div>
</div>
</div>

View file

@ -38,3 +38,6 @@ export const DEFAULT_MCP_SERVERS = {
stderr: "inherit" as const,
},
};
export const API_KEY_REQUIRED_MESSAGE =
"Unknown users must provide API keys to use the Open SWE demo application";

View file

@ -372,6 +372,11 @@ export const GraphConfigurationMetadata: {
"JSON configuration for custom MCP servers. LangGraph docs server is set by default. See the `mcpServers` field of the LangChain MCP Adapters `ClientConfig` type for information on this schema. [Documentation here](https://v03.api.js.langchain.com/types/_langchain_mcp_adapters.ClientConfig.html).",
},
},
apiKeys: {
x_open_swe_ui_config: {
type: "hidden",
},
},
[GITHUB_TOKEN_COOKIE]: {
x_open_swe_ui_config: {
type: "hidden",
@ -496,6 +501,12 @@ export const GraphConfiguration = z.object({
maxTokens: withLangGraph(z.number().optional(), {
metadata: GraphConfigurationMetadata.maxTokens,
}),
/**
* User defined API keys to use
*/
apiKeys: withLangGraph(z.record(z.string(), z.string()).optional(), {
metadata: GraphConfigurationMetadata.apiKeys,
}),
/**
* The user's GitHub access token. To be used in requests to get information about the user.
*/