refactor: Proxy req utils, rename GITHUB_TOKEN_ENCRYPTION_KEY to SECRETS_ENCRYPTION_KEY (#391)

* refactor: Proxy req utils, rename GITHUB_TOKEN_ENCRYPTION_KEY to SECRETS_ENCRYPTION_KEY

* cr
This commit is contained in:
Brace Sproul 2025-07-11 10:48:14 -07:00 • committed by GitHub
parent 0f5bb868d9
commit abc984409f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 207 additions and 208 deletions

View file

@ -46,10 +46,10 @@ GOOGLE_API_KEY=""
# Daytona API key for accessing and modifying the code in the cloud sandbox.
DAYTONA_API_KEY=""
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
# Encryption key for secrets (32-byte hex string for AES-256)
# Should be the same value as the one used in the web app.
# Can be generated via: `openssl rand -hex 32`
GITHUB_TOKEN_ENCRYPTION_KEY=""
SECRETS_ENCRYPTION_KEY=""
# Used for setting the git user name & email for commits.
GITHUB_APP_NAME="open-swe-dev"
@ -75,13 +75,13 @@ GITHUB_APP_NAME="open-swe-dev"
GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY=""
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
# Encryption key for secrets (32-byte hex string for AES-256)
# Should be the same value as the one used in the open-swe app.
# Can be generated via: `openssl rand -hex 32`
GITHUB_TOKEN_ENCRYPTION_KEY=""
SECRETS_ENCRYPTION_KEY=""
```
**REMINDER**: The `GITHUB_TOKEN_ENCRYPTION_KEY` environment variable must be the same in both the web and open-swe apps.
**REMINDER**: The `SECRETS_ENCRYPTION_KEY` environment variable must be the same in both the web and open-swe apps.
To get the GitHub App secrets, first create a new GitHub app (note: this is not the same as the OAuth app) in [the developer settings](https://github.com/settings/apps/new).

View file

@ -47,12 +47,12 @@ All headers are prefixed with `x-` to ensure they're included in LangGraph run c
### Token Encryption
Open SWE implements AES-256-GCM encryption for all GitHub tokens to prevent exposure in:
Open SWE implements AES-256-GCM encryption for all secrets passed to the LangGraph server to prevent exposure in:
- LangSmith trace metadata
- Run configurations
- Potential unauthorized access scenarios
The encryption process uses the `GITHUB_TOKEN_ENCRYPTION_KEY` environment variable and includes:
The encryption process uses the `SECRETS_ENCRYPTION_KEY` environment variable and includes:
- **Initialization Vector (IV)** for unique encryption per token
- **Authentication Tag** for data integrity verification
- **Base64 encoding** for safe transport
@ -135,5 +135,5 @@ This design ensures tokens remain encrypted in storage and traces while being av
</Note>
<Tip>
Always ensure the `GITHUB_TOKEN_ENCRYPTION_KEY` environment variable is identical between your web application and LangGraph agent deployments.
Always ensure the `SECRETS_ENCRYPTION_KEY` environment variable is identical between your web application and LangGraph agent deployments.
</Tip>

View file

@ -62,8 +62,8 @@ Before starting, ensure you have the following installed:
GITHUB_APP_CLIENT_SECRET=""
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
# Token encryption key (generate with: openssl rand -hex 32)
GITHUB_TOKEN_ENCRYPTION_KEY=""
# Encryption key for secrets (generate with: openssl rand -hex 32)
SECRETS_ENCRYPTION_KEY=""
# GitHub App details (will be filled after creating GitHub App)
GITHUB_APP_NAME="open-swe-dev"
@ -101,7 +101,6 @@ Before starting, ensure you have the following installed:
FIRECRAWL_API_KEY="" # For URL content extraction
# GitHub App settings (same as web app)
GITHUB_TOKEN_ENCRYPTION_KEY="" # Must match web app value
GITHUB_APP_NAME="open-swe-dev"
GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
@ -113,10 +112,11 @@ Before starting, ensure you have the following installed:
# Server configuration
PORT="2024"
OPEN_SWE_APP_URL="http://localhost:3000"
SECRETS_ENCRYPTION_KEY="" # Must match web app value
```
<Tip>
Generate the `GITHUB_TOKEN_ENCRYPTION_KEY` using: `openssl rand -hex 32`. This key must be identical in both environment files.
Generate the `SECRETS_ENCRYPTION_KEY` using: `openssl rand -hex 32`. This key must be identical in both environment files.
</Tip>
</Step>

View file

@ -25,10 +25,6 @@ FIRECRAWL_API_KEY=""
# ------------------Github App Secrets-----------------
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
# Should be the same value as the one used in the web app, so that tokens
# encrypted in the web app can be decrypted in the agent.
GITHUB_TOKEN_ENCRYPTION_KEY=""
# Used for setting the git user name & email for commits.
# Can modify to whatever string you want.
GITHUB_APP_NAME="open-swe-dev"
@ -50,3 +46,7 @@ PORT="2024"
# Should be the URL of the web app. Localhost in dev, production URL
# in production.
OPEN_SWE_APP_URL="http://localhost:3000"
# Encryption key for secrets (32-byte hex string for AES-256)
# Should be the same value as the one used in the web app, so that secrets
# encrypted in the web app can be decrypted in the agent.
SECRETS_ENCRYPTION_KEY=""

View file

@ -12,7 +12,7 @@ import {
GITHUB_USER_LOGIN_HEADER,
MANAGER_GRAPH_ID,
} from "@open-swe/shared/constants";
import { encryptGitHubToken } from "@open-swe/shared/crypto";
import { encryptSecret } from "@open-swe/shared/crypto";
import { HumanMessage } from "@langchain/core/messages";
import {
getOpenSWEAutoAcceptLabel,
@ -86,10 +86,8 @@ const getHeaders = (
};
webhooks.on("issues.labeled", async ({ payload }) => {
if (!process.env.GITHUB_TOKEN_ENCRYPTION_KEY) {
throw new Error(
"GITHUB_TOKEN_ENCRYPTION_KEY environment variable is required",
);
if (!process.env.SECRETS_ENCRYPTION_KEY) {
throw new Error("SECRETS_ENCRYPTION_KEY environment variable is required");
}
const validOpenSWELabels = [getOpenSWELabel(), getOpenSWEAutoAcceptLabel()];
if (
@ -132,9 +130,9 @@ webhooks.on("issues.labeled", async ({ payload }) => {
const langGraphClient = createLangGraphClient({
defaultHeaders: {
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptGitHubToken(
[GITHUB_INSTALLATION_TOKEN_COOKIE]: encryptSecret(
token,
process.env.GITHUB_TOKEN_ENCRYPTION_KEY,
process.env.SECRETS_ENCRYPTION_KEY,
),
[GITHUB_INSTALLATION_NAME]: issueData.owner,
[GITHUB_USER_ID_HEADER]: issueData.userId.toString(),

View file

@ -11,7 +11,7 @@ import {
GITHUB_USER_ID_HEADER,
GITHUB_USER_LOGIN_HEADER,
} from "@open-swe/shared/constants";
import { decryptGitHubToken } from "@open-swe/shared/crypto";
import { decryptSecret } from "@open-swe/shared/crypto";
import { verifyGitHubWebhookOrThrow } from "./github.js";
import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js";
@ -49,11 +49,9 @@ export const auth = new Auth()
return await verifyGitHubWebhookOrThrow(request);
}
const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error(
"Missing GITHUB_TOKEN_ENCRYPTION_KEY environment variable.",
);
throw new Error("Missing SECRETS_ENCRYPTION_KEY environment variable.");
}
const installationNameHeader = request.headers.get(
@ -90,14 +88,14 @@ export const auth = new Auth()
});
}
user = await verifyGithubUserId(
decryptGitHubToken(encryptedInstallationToken, encryptionKey),
decryptSecret(encryptedInstallationToken, encryptionKey),
Number(userIdHeader),
userLoginHeader,
);
} else {
// Ensure we decrypt the token before passing to the verification function.
user = await verifyGithubUser(
decryptGitHubToken(encryptedAccessToken, encryptionKey),
decryptSecret(encryptedAccessToken, encryptionKey),
);
}

View file

@ -3,7 +3,7 @@ import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
} from "@open-swe/shared/constants";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { decryptGitHubToken } from "@open-swe/shared/crypto";
import { decryptSecret } from "@open-swe/shared/crypto";
export function getGitHubTokensFromConfig(config: GraphConfig): {
githubAccessToken: string;
@ -22,18 +22,16 @@ export function getGitHubTokensFromConfig(config: GraphConfig): {
}
// Get the encryption key from environment variables
const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error(
"Missing GITHUB_TOKEN_ENCRYPTION_KEY environment variable.",
);
throw new Error("Missing SECRETS_ENCRYPTION_KEY environment variable.");
}
// Decrypt the GitHub token
const githubAccessToken = encryptedGitHubToken
? decryptGitHubToken(encryptedGitHubToken, encryptionKey)
? decryptSecret(encryptedGitHubToken, encryptionKey)
: "";
const githubInstallationToken = decryptGitHubToken(
const githubInstallationToken = decryptSecret(
encryptedInstallationToken,
encryptionKey,
);

View file

@ -7,10 +7,6 @@ GITHUB_APP_CLIENT_SECRET=""
# your GitHub app settings.
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
# Encryption key for GitHub tokens (32-byte hex string for AES-256)
# Should be the same value as the one used in the web app, so that tokens
# encrypted in the web app can be decrypted in the agent.
GITHUB_TOKEN_ENCRYPTION_KEY=""
# Used for setting the git user name & email for commits.
# Can modify to whatever string you want.
GITHUB_APP_NAME="open-swe-dev"
@ -29,3 +25,7 @@ NEXT_PUBLIC_API_URL="http://localhost:3000/api"
# The API URL of the LangGraph server. Used in the proxy route to forward
# requests to the LangGraph server.
LANGGRAPH_API_URL="http://localhost:2024"
# Encryption key for secrets (32-byte hex string for AES-256)
# Should be the same value as the one used in the web app, so that secrets
# encrypted in the web app can be decrypted in the agent.
SECRETS_ENCRYPTION_KEY=""

View file

@ -5,150 +5,11 @@ import {
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_INSTALLATION_NAME,
} from "@open-swe/shared/constants";
import { encryptGitHubToken } from "@open-swe/shared/crypto";
import { NextRequest } from "next/server";
import { getInstallationToken } from "@/utils/github";
import { App } from "@octokit/app";
import { validate } from "uuid";
function getGitHubAccessTokenOrThrow(
req: NextRequest,
encryptionKey: string,
): string {
const token = req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "";
if (!token) {
throw new Error(
"No GitHub access token found. User must authenticate first.",
);
}
return encryptGitHubToken(token, encryptionKey);
}
async function getGitHubInstallationTokenOrThrow(
installationIdCookie: string,
encryptionKey: string,
): Promise<string> {
const appId = process.env.GITHUB_APP_ID;
const privateAppKey = process.env.GITHUB_APP_PRIVATE_KEY;
if (!appId || !privateAppKey) {
throw new Error("GitHub App ID or Private App Key is not configured.");
}
const token = await getInstallationToken(
installationIdCookie,
appId,
privateAppKey,
);
return encryptGitHubToken(token, encryptionKey);
}
async function getInstallationName(installationId: string) {
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_APP_PRIVATE_KEY) {
throw new Error("GitHub App ID or Private App Key is not configured.");
}
const app = new App({
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
});
// Get installation details
const { data } = await app.octokit.request(
"GET /app/installations/{installation_id}",
{
installation_id: Number(installationId),
},
);
const installationName =
data.account && "name" in data.account
? data.account.name
: data.account?.login;
return installationName ?? "";
}
const isNewRunRequest = (reqUrlStr: string, reqMethod: string) => {
try {
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
const isCreateNewRunReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
validate(reqPathnameParts?.[3]) &&
reqPathnameParts?.[4] === "runs" &&
reqMethod.toLowerCase() === "post";
const isStreamRunReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
validate(reqPathnameParts?.[3]) &&
reqPathnameParts?.[4] === "runs" &&
validate(reqPathnameParts?.[5]) &&
reqPathnameParts?.[6]?.startsWith("stream") &&
reqMethod.toLowerCase() === "get";
return isCreateNewRunReq || isStreamRunReq;
} catch {
return false;
}
};
const isGetStateRequest = (reqUrlStr: string, reqMethod: string) => {
try {
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
const isGetStateReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
validate(reqPathnameParts?.[3]) &&
reqPathnameParts?.[4] === "state" &&
reqMethod.toLowerCase() === "get";
return isGetStateReq;
} catch {
return false;
}
};
const isSearchThreadsRequest = (reqUrlStr: string, reqMethod: string) => {
try {
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
const isGetStateReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
reqPathnameParts?.[3] === "search" &&
reqMethod.toLowerCase() === "post";
return isGetStateReq;
} catch {
return false;
}
};
async function getInstallationNameFromReq(
req: Request,
installationId: string,
): Promise<string> {
try {
const requestJson = await req.json();
const installationName = requestJson?.input?.targetRepository?.owner;
if (installationName) {
return installationName;
}
} catch {
// no-op
}
try {
if (
isNewRunRequest(req.url, req.method) ||
isGetStateRequest(req.url, req.method) ||
isSearchThreadsRequest(req.url, req.method)
) {
return await getInstallationName(installationId);
}
return "";
} catch {
return "";
}
}
import {
getGitHubInstallationTokenOrThrow,
getInstallationNameFromReq,
getGitHubAccessTokenOrThrow,
} from "./utils";
// 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.
@ -159,10 +20,10 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
runtime: "edge", // default
disableWarningLog: true,
headers: async (req) => {
const encryptionKey = process.env.GITHUB_TOKEN_ENCRYPTION_KEY;
const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY;
if (!encryptionKey) {
throw new Error(
"GITHUB_TOKEN_ENCRYPTION_KEY environment variable is required",
"SECRETS_ENCRYPTION_KEY environment variable is required",
);
}
const installationIdCookie = req.cookies.get(

View file

@ -0,0 +1,145 @@
import { getInstallationToken } from "@/utils/github";
import { App } from "@octokit/app";
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
import { encryptSecret } from "@open-swe/shared/crypto";
import { NextRequest } from "next/server";
import { validate } from "uuid";
export function getGitHubAccessTokenOrThrow(
req: NextRequest,
encryptionKey: string,
): string {
const token = req.cookies.get(GITHUB_TOKEN_COOKIE)?.value ?? "";
if (!token) {
throw new Error(
"No GitHub access token found. User must authenticate first.",
);
}
return encryptSecret(token, encryptionKey);
}
export async function getGitHubInstallationTokenOrThrow(
installationIdCookie: string,
encryptionKey: string,
): Promise<string> {
const appId = process.env.GITHUB_APP_ID;
const privateAppKey = process.env.GITHUB_APP_PRIVATE_KEY;
if (!appId || !privateAppKey) {
throw new Error("GitHub App ID or Private App Key is not configured.");
}
const token = await getInstallationToken(
installationIdCookie,
appId,
privateAppKey,
);
return encryptSecret(token, encryptionKey);
}
async function getInstallationName(installationId: string) {
if (!process.env.GITHUB_APP_ID || !process.env.GITHUB_APP_PRIVATE_KEY) {
throw new Error("GitHub App ID or Private App Key is not configured.");
}
const app = new App({
appId: process.env.GITHUB_APP_ID,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
});
// Get installation details
const { data } = await app.octokit.request(
"GET /app/installations/{installation_id}",
{
installation_id: Number(installationId),
},
);
const installationName =
data.account && "name" in data.account
? data.account.name
: data.account?.login;
return installationName ?? "";
}
function isNewRunRequest(reqUrlStr: string, reqMethod: string) {
try {
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
const isCreateNewRunReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
validate(reqPathnameParts?.[3]) &&
reqPathnameParts?.[4] === "runs" &&
reqMethod.toLowerCase() === "post";
const isStreamRunReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
validate(reqPathnameParts?.[3]) &&
reqPathnameParts?.[4] === "runs" &&
validate(reqPathnameParts?.[5]) &&
reqPathnameParts?.[6]?.startsWith("stream") &&
reqMethod.toLowerCase() === "get";
return isCreateNewRunReq || isStreamRunReq;
} catch {
return false;
}
}
function isGetStateRequest(reqUrlStr: string, reqMethod: string) {
try {
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
const isGetStateReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
validate(reqPathnameParts?.[3]) &&
reqPathnameParts?.[4] === "state" &&
reqMethod.toLowerCase() === "get";
return isGetStateReq;
} catch {
return false;
}
}
function isSearchThreadsRequest(reqUrlStr: string, reqMethod: string) {
try {
const reqPathnameParts = new URL(reqUrlStr).pathname.split("/");
const isGetStateReq =
reqPathnameParts?.[1] === "api" &&
reqPathnameParts?.[2] === "threads" &&
reqPathnameParts?.[3] === "search" &&
reqMethod.toLowerCase() === "post";
return isGetStateReq;
} catch {
return false;
}
}
export async function getInstallationNameFromReq(
req: Request,
installationId: string,
): Promise<string> {
try {
const requestJson = await req.json();
const installationName = requestJson?.input?.targetRepository?.owner;
if (installationName) {
return installationName;
}
} catch {
// no-op
}
try {
if (
isNewRunRequest(req.url, req.method) ||
isGetStateRequest(req.url, req.method) ||
isSearchThreadsRequest(req.url, req.method)
) {
return await getInstallationName(installationId);
}
return "";
} catch {
return "";
}
}

View file

@ -20,19 +20,16 @@ function deriveKey(encryptionKey: string): Buffer {
}
/**
* Encrypts a GitHub token using AES-256-GCM
* Encrypts a secret using AES-256-GCM
*
* @param token - The GitHub access token to encrypt
* @param secret - The secret to encrypt
* @param encryptionKey - The encryption key (will be hashed to 256 bits)
* @returns Base64 encoded encrypted data containing IV, encrypted token, and auth tag
* @throws Error if encryption fails or inputs are invalid
*/
export function encryptGitHubToken(
token: string,
encryptionKey: string,
): string {
if (!token || typeof token !== "string") {
throw new Error("Token must be a non-empty string");
export function encryptSecret(secret: string, encryptionKey: string): string {
if (!secret || typeof secret !== "string") {
throw new Error("Secret must be a non-empty string");
}
if (!encryptionKey || typeof encryptionKey !== "string") {
@ -49,9 +46,9 @@ export function encryptGitHubToken(
// Create cipher
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
// Encrypt the token
// Encrypt the secret
const encryptedBuffer = Buffer.concat([
cipher.update(token, "utf8"),
cipher.update(secret, "utf8"),
cipher.final(),
]);
@ -64,25 +61,25 @@ export function encryptGitHubToken(
return combined.toString("base64");
} catch (error) {
throw new Error(
`Failed to encrypt token: ${error instanceof Error ? error.message : "Unknown error"}`,
`Failed to encrypt secret: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
/**
* Decrypts a GitHub token using AES-256-GCM
* Decrypts a secret using AES-256-GCM
*
* @param encryptedToken - Base64 encoded encrypted data from encryptGitHubToken
* @param encryptedSecret - Base64 encoded encrypted data from encryptSecret
* @param encryptionKey - The encryption key used for encryption
* @returns The decrypted GitHub access token
* @returns The decrypted secret
* @throws Error if decryption fails or inputs are invalid
*/
export function decryptGitHubToken(
encryptedToken: string,
export function decryptSecret(
encryptedSecret: string,
encryptionKey: string,
): string {
if (!encryptedToken || typeof encryptedToken !== "string") {
throw new Error("Encrypted token must be a non-empty string");
if (!encryptedSecret || typeof encryptedSecret !== "string") {
throw new Error("Encrypted secret must be a non-empty string");
}
if (!encryptionKey || typeof encryptionKey !== "string") {
@ -91,11 +88,13 @@ export function decryptGitHubToken(
try {
// Decode the combined data
const combined = Buffer.from(encryptedToken, "base64");
const combined = Buffer.from(encryptedSecret, "base64");
// Minimum length: IV_LENGTH + TAG_LENGTH + 1 byte for data
if (combined.length < IV_LENGTH + TAG_LENGTH + 1) {
throw new Error("Invalid encrypted token format: too short or malformed");
throw new Error(
"Invalid encrypted secret format: too short or malformed",
);
}
// Extract IV, encrypted data, and tag
@ -126,7 +125,7 @@ export function decryptGitHubToken(
return decryptedBuffer.toString("utf8");
} catch (error) {
throw new Error(
`Failed to decrypt token: ${error instanceof Error ? error.message : "Unknown error"}`,
`Failed to decrypt secret: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}