fix: Handle expired tokens (#506)

* fix: Handle expired tokens

* cr
This commit is contained in:
Brace Sproul 2025-07-23 16:15:57 -07:00 • committed by GitHub
parent f6fa1fa4a4
commit 6ac4deacd6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 327 additions and 182 deletions

View file

@ -6,6 +6,7 @@ import {
} from "@open-swe/shared/github/verify-user";
import {
API_KEY_REQUIRED_MESSAGE,
GITHUB_INSTALLATION_ID,
GITHUB_INSTALLATION_NAME,
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_TOKEN_COOKIE,
@ -143,6 +144,12 @@ export const auth = new Auth()
message: "GitHub installation name header missing",
});
}
const installationIdHeader = request.headers.get(GITHUB_INSTALLATION_ID);
if (!installationIdHeader) {
throw new HTTPException(401, {
message: "GitHub installation ID header missing",
});
}
// We don't do anything with this token right now, but still confirm it
// exists as it will cause issues later on if it's not present.

View file

@ -6,6 +6,7 @@ import {
GITHUB_USER_LOGIN_HEADER,
GITHUB_INSTALLATION_NAME,
GITHUB_PAT,
GITHUB_INSTALLATION_ID,
} from "@open-swe/shared/constants";
export function getDefaultHeaders(config: GraphConfig): Record<string, string> {
@ -22,8 +23,13 @@ export function getDefaultHeaders(config: GraphConfig): Record<string, string> {
config.configurable?.[GITHUB_INSTALLATION_TOKEN_COOKIE];
const githubInstallationName =
config.configurable?.[GITHUB_INSTALLATION_NAME];
const githubInstallationId = config.configurable?.[GITHUB_INSTALLATION_ID];
if (!githubInstallationTokenCookie || !githubInstallationName) {
if (
!githubInstallationTokenCookie ||
!githubInstallationName ||
!githubInstallationId
) {
throw new Error("Missing required headers");
}
@ -36,6 +42,7 @@ export function getDefaultHeaders(config: GraphConfig): Record<string, string> {
// Required headers
[GITHUB_INSTALLATION_TOKEN_COOKIE]: githubInstallationTokenCookie,
[GITHUB_INSTALLATION_NAME]: githubInstallationName,
[GITHUB_INSTALLATION_ID]: githubInstallationId,
// Optional headers
[GITHUB_TOKEN_COOKIE]: githubTokenCookie,

View file

@ -1,6 +1,7 @@
import {
GITHUB_TOKEN_COOKIE,
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_INSTALLATION_ID,
} from "@open-swe/shared/constants";
import { GraphConfig } from "@open-swe/shared/open-swe/types";
import { decryptSecret } from "@open-swe/shared/crypto";
@ -9,6 +10,7 @@ import { getGitHubPatFromConfig } from "./github-pat.js";
export function getGitHubTokensFromConfig(config: GraphConfig): {
githubAccessToken: string;
githubInstallationToken: string;
installationId: string;
} {
if (!config.configurable) {
throw new Error("No configurable object found in graph config.");
@ -22,12 +24,20 @@ export function getGitHubTokensFromConfig(config: GraphConfig): {
const isProd = process.env.NODE_ENV === "production";
const installationId = config.configurable[GITHUB_INSTALLATION_ID];
if (!installationId) {
throw new Error(
`Missing required ${GITHUB_INSTALLATION_ID} in configuration.`,
);
}
const githubPat = getGitHubPatFromConfig(config.configurable, encryptionKey);
if (githubPat && !isProd) {
// check for PAT-only mode
return {
githubAccessToken: githubPat,
githubInstallationToken: githubPat,
installationId,
};
}
@ -49,5 +59,5 @@ export function getGitHubTokensFromConfig(config: GraphConfig): {
encryptionKey,
);
return { githubAccessToken, githubInstallationToken };
return { githubAccessToken, githubInstallationToken, installationId };
}

View file

@ -2,43 +2,114 @@ import { Octokit } from "@octokit/rest";
import { createLogger, LogLevel } from "../logger.js";
import { GitHubIssue, GitHubIssueComment, GitHubPullRequest } from "./types.js";
import { getOpenSWELabel } from "./label.js";
import { getInstallationToken } from "@open-swe/shared/github/auth";
import { getConfig } from "@langchain/langgraph";
import { GITHUB_INSTALLATION_ID } from "@open-swe/shared/constants";
import { updateConfig } from "../update-config.js";
import { encryptSecret } from "@open-swe/shared/crypto";
const logger = createLogger(LogLevel.INFO, "GitHub-API");
async function getInstallationTokenAndUpdateConfig() {
try {
const config = getConfig();
const encryptionSecret = process.env.SECRETS_ENCRYPTION_KEY;
if (!encryptionSecret) {
throw new Error("Secrets encryption key not found");
}
const installationId = config.configurable?.[GITHUB_INSTALLATION_ID];
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY;
if (!installationId || !appId || !privateKey) {
throw new Error(
"GitHub installation ID, app ID, or private key not found",
);
}
const token = await getInstallationToken(installationId, appId, privateKey);
const encryptedToken = encryptSecret(token, encryptionSecret);
updateConfig(GITHUB_INSTALLATION_ID, encryptedToken);
return token;
} catch (e) {
logger.error("Failed to get installation token and update config", {
error: e,
});
return null;
}
}
/**
* Generic utility for handling GitHub API calls with automatic retry on 401 errors
*/
async function withGitHubRetry<T>(
operation: (token: string) => Promise<T>,
initialToken: string,
errorMessage: string,
additionalLogFields?: Record<string, any>,
numRetries = 1,
): Promise<T | null> {
try {
return await operation(initialToken);
} catch (error) {
const errorFields =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: {};
// Retry with a max retries of 2
if (errorFields && errorFields.message?.includes("401") && numRetries < 2) {
const token = await getInstallationTokenAndUpdateConfig();
if (!token) {
return null;
}
return withGitHubRetry(
operation,
token,
errorMessage,
additionalLogFields,
numRetries + 1,
);
}
logger.error(errorMessage, {
...additionalLogFields,
...(errorFields ?? { error }),
});
return null;
}
}
async function getExistingPullRequest(
owner: string,
repo: string,
branchName: string,
githubToken: string,
numRetries = 1,
) {
try {
const octokit = new Octokit({
auth: githubToken,
});
return withGitHubRetry(
async (token: string) => {
const octokit = new Octokit({
auth: token,
});
const { data: pullRequests } = await octokit.pulls.list({
owner,
repo,
head: branchName,
});
const { data: pullRequests } = await octokit.pulls.list({
owner,
repo,
head: branchName,
});
if (pullRequests?.[0]) {
return pullRequests[0];
}
} catch (e) {
logger.error(`Failed to get existing pull request`, {
branch: branchName,
owner,
repo,
...(e instanceof Error && {
name: e.name,
message: e.message,
stack: e.stack,
}),
});
}
return null;
return pullRequests?.[0] || null;
},
githubToken,
"Failed to get existing pull request",
{ branch: branchName, owner, repo },
numRetries,
);
}
export async function createPullRequest({
@ -162,30 +233,33 @@ export async function getIssue({
repo,
issueNumber,
githubInstallationToken,
numRetries = 1,
}: {
owner: string;
repo: string;
issueNumber: number;
githubInstallationToken: string;
numRetries?: number;
}): Promise<GitHubIssue | null> {
const octokit = new Octokit({
auth: githubInstallationToken,
});
return withGitHubRetry(
async (token: string) => {
const octokit = new Octokit({
auth: token,
});
try {
const { data: issue } = await octokit.issues.get({
owner,
repo,
issue_number: issueNumber,
});
const { data: issue } = await octokit.issues.get({
owner,
repo,
issue_number: issueNumber,
});
return issue;
} catch (error) {
logger.error(`Failed to get issue`, {
error,
});
return null;
}
return issue;
},
githubInstallationToken,
"Failed to get issue",
undefined,
numRetries,
);
}
export async function getIssueComments({
@ -194,39 +268,42 @@ export async function getIssueComments({
issueNumber,
githubInstallationToken,
filterBotComments = true,
numRetries = 1,
}: {
owner: string;
repo: string;
issueNumber: number;
githubInstallationToken: string;
filterBotComments?: boolean;
numRetries?: number;
}): Promise<GitHubIssueComment[] | null> {
const octokit = new Octokit({
auth: githubInstallationToken,
});
return withGitHubRetry(
async (token: string) => {
const octokit = new Octokit({
auth: token,
});
try {
const { data: comments } = await octokit.issues.listComments({
owner,
repo,
issue_number: issueNumber,
});
const { data: comments } = await octokit.issues.listComments({
owner,
repo,
issue_number: issueNumber,
});
if (!filterBotComments) {
return comments;
}
if (!filterBotComments) {
return comments;
}
return comments.filter((comment) => {
return (
comment.user?.type !== "Bot" || !comment.user?.name?.includes("[bot]")
);
});
} catch (error) {
logger.error(`Failed to get issue comments`, {
error,
});
return null;
}
return comments.filter((comment) => {
return (
comment.user?.type !== "Bot" || !comment.user?.name?.includes("[bot]")
);
});
},
githubInstallationToken,
"Failed to get issue comments",
undefined,
numRetries,
);
}
export async function createIssue({
@ -256,9 +333,15 @@ export async function createIssue({
return issue;
} catch (error) {
logger.error(`Failed to create issue`, {
error,
});
const errorFields =
error instanceof Error
? {
name: error.name,
message: error.message,
stack: error.stack,
}
: { error };
logger.error(`Failed to create issue`, errorFields);
return null;
}
}
@ -270,6 +353,7 @@ export async function updateIssue({
githubInstallationToken,
body,
title,
numRetries = 1,
}: {
owner: string;
repo: string;
@ -277,31 +361,33 @@ export async function updateIssue({
githubInstallationToken: string;
body?: string;
title?: string;
numRetries?: number;
}) {
if (!body && !title) {
throw new Error("Must provide either body or title to update issue");
}
const octokit = new Octokit({
auth: githubInstallationToken,
});
return withGitHubRetry(
async (token: string) => {
const octokit = new Octokit({
auth: token,
});
try {
const { data: issue } = await octokit.issues.update({
owner,
repo,
issue_number: issueNumber,
...(body && { body }),
...(title && { title }),
});
const { data: issue } = await octokit.issues.update({
owner,
repo,
issue_number: issueNumber,
...(body && { body }),
...(title && { title }),
});
return issue;
} catch (error) {
logger.error(`Failed to update issue`, {
error,
});
return null;
}
return issue;
},
githubInstallationToken,
"Failed to update issue",
undefined,
numRetries,
);
}
export async function createIssueComment({
@ -310,6 +396,7 @@ export async function createIssueComment({
issueNumber,
body,
githubToken,
numRetries = 1,
}: {
owner: string;
repo: string;
@ -320,26 +407,28 @@ export async function createIssueComment({
* or an access token if creating a user comment.
*/
githubToken: string;
numRetries?: number;
}): Promise<GitHubIssueComment | null> {
const octokit = new Octokit({
auth: githubToken,
});
return withGitHubRetry(
async (token: string) => {
const octokit = new Octokit({
auth: token,
});
try {
const { data: comment } = await octokit.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
const { data: comment } = await octokit.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body,
});
return comment;
} catch (error) {
logger.error(`Failed to create issue comment`, {
error,
});
return null;
}
return comment;
},
githubToken,
"Failed to create issue comment",
undefined,
numRetries,
);
}
export async function updateIssueComment({
@ -348,30 +437,33 @@ export async function updateIssueComment({
commentId,
body,
githubInstallationToken,
numRetries = 1,
}: {
owner: string;
repo: string;
commentId: number;
body: string;
githubInstallationToken: string;
numRetries?: number;
}): Promise<GitHubIssueComment | null> {
const octokit = new Octokit({
auth: githubInstallationToken,
});
return withGitHubRetry(
async (token: string) => {
const octokit = new Octokit({
auth: token,
});
try {
const { data: comment } = await octokit.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
});
const { data: comment } = await octokit.issues.updateComment({
owner,
repo,
comment_id: commentId,
body,
});
return comment;
} catch (error) {
logger.error(`Failed to update issue comment`, {
error,
});
return null;
}
return comment;
},
githubInstallationToken,
"Failed to update issue comment",
undefined,
numRetries,
);
}

View file

@ -0,0 +1,14 @@
import { getConfig } from "@langchain/langgraph";
export function updateConfig(key: string, value: unknown) {
try {
const config = getConfig();
if (!config.configurable) {
throw new Error("No configurable object found");
}
config.configurable[key] = value;
} catch {
// no-op
return;
}
}

View file

@ -46,7 +46,6 @@
"esbuild": "^0.25.0",
"esbuild-plugin-tailwindcss": "^2.0.1",
"framer-motion": "^12.4.9",
"jsonwebtoken": "^9.0.2",
"katex": "^0.16.21",
"langgraph-nextjs-api-passthrough": "^0.1.3",
"lodash": "^4.17.21",

View file

@ -4,6 +4,7 @@ import {
GITHUB_INSTALLATION_ID_COOKIE,
GITHUB_INSTALLATION_TOKEN_COOKIE,
GITHUB_INSTALLATION_NAME,
GITHUB_INSTALLATION_ID,
} from "@open-swe/shared/constants";
import {
getGitHubInstallationTokenOrThrow,
@ -72,6 +73,7 @@ export const { GET, POST, PUT, PATCH, DELETE, OPTIONS, runtime } =
[GITHUB_TOKEN_COOKIE]: getGitHubAccessTokenOrThrow(req, encryptionKey),
[GITHUB_INSTALLATION_TOKEN_COOKIE]: installationToken,
[GITHUB_INSTALLATION_NAME]: installationName,
[GITHUB_INSTALLATION_ID]: installationIdCookie,
};
},
});

View file

@ -1,4 +1,4 @@
import { getInstallationToken } from "@/utils/github";
import { getInstallationToken } from "@open-swe/shared/github/auth";
import { App } from "@octokit/app";
import { GITHUB_TOKEN_COOKIE } from "@open-swe/shared/constants";
import { encryptSecret } from "@open-swe/shared/crypto";

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getInstallationToken } from "../../../../../utils/github"; // Adjusted path
import { getInstallationToken } from "@open-swe/shared/github/auth";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants";
const GITHUB_API_URL = "https://api.github.com";

View file

@ -1,9 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import {
getInstallationToken,
getInstallationRepositories,
Repository,
} from "@/utils/github";
import { getInstallationToken } from "@open-swe/shared/github/auth";
import { getInstallationRepositories, Repository } from "@/utils/github";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants";
/**

View file

@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getInstallationToken } from "@/utils/github";
import { getInstallationToken } from "@open-swe/shared/github/auth";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@open-swe/shared/constants";
/**

View file

@ -1,5 +1,3 @@
import * as jwt from "jsonwebtoken";
function getBaseApiUrl(): string {
let baseApiUrl = new URL(
process.env.NEXT_PUBLIC_API_URL || "http://localhost:3000/api",
@ -8,54 +6,6 @@ function getBaseApiUrl(): string {
return baseApiUrl;
}
/**
* Generates a JWT for GitHub App authentication
*/
export function generateJWT(appId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now,
exp: now + 10 * 60,
iss: appId,
};
return jwt.sign(payload, privateKey, { algorithm: "RS256" });
}
/**
* Gets an installation access token for a GitHub App installation
*/
export async function getInstallationToken(
installationId: string,
appId: string,
privateKey: string,
): Promise<string> {
const jwtToken = generateJWT(appId, privateKey);
const response = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
Authorization: `Bearer ${jwtToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to get installation token: ${JSON.stringify(errorData)}`,
);
}
const data = await response.json();
return data.token;
}
/**
* Fetches repositories accessible to a GitHub App installation
*/

View file

@ -21,6 +21,7 @@
"@langchain/langgraph": "^0.3.8",
"@langchain/langgraph-sdk": "^0.0.95",
"@octokit/rest": "^22.0.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.25.32"
},
"devDependencies": {

View file

@ -10,6 +10,7 @@ export const GITHUB_TOKEN_COOKIE = "x-github-access-token";
export const GITHUB_INSTALLATION_TOKEN_COOKIE = "x-github-installation-token";
export const GITHUB_INSTALLATION_NAME = "x-github-installation-name";
export const GITHUB_PAT = "x-github-pat";
export const GITHUB_INSTALLATION_ID = "x-github-installation-id";
export const DO_NOT_RENDER_ID_PREFIX = "do-not-render-";
export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state";

View file

@ -0,0 +1,37 @@
import { generateJWT } from "../jwt.js";
/**
* Gets an installation access token for a GitHub App installation
*/
export async function getInstallationToken(
installationId: string,
appId: string,
privateKey: string,
): Promise<string> {
const jwtToken = generateJWT(appId, privateKey);
const response = await fetch(
`https://api.github.com/app/installations/${installationId}/access_tokens`,
{
method: "POST",
headers: {
Authorization: `Bearer ${jwtToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE-Agent",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to get installation token: ${JSON.stringify(errorData)}`,
);
}
const data = await response.json();
if (typeof data !== "object" || !data || !("token" in data)) {
throw new Error("No token returned after fetching installation token");
}
return data.token as string;
}

View file

@ -0,0 +1,16 @@
import * as jwt from "jsonwebtoken";
/**
* Generates a JWT for GitHub App authentication
*/
export function generateJWT(appId: string, privateKey: string): string {
const now = Math.floor(Date.now() / 1000);
const payload = {
iat: now,
exp: now + 10 * 60,
iss: appId,
};
return jwt.sign(payload, privateKey, { algorithm: "RS256" });
}

View file

@ -21,6 +21,7 @@ import {
GITHUB_USER_LOGIN_HEADER,
GITHUB_PAT,
DEFAULT_MCP_SERVERS,
GITHUB_INSTALLATION_ID,
} from "../constants.js";
import { withLangGraph } from "@langchain/langgraph/zod";
import { BaseMessage } from "@langchain/core/messages";
@ -417,6 +418,11 @@ export const GraphConfigurationMetadata: {
type: "hidden",
},
},
[GITHUB_INSTALLATION_ID]: {
x_open_swe_ui_config: {
type: "hidden",
},
},
[GITHUB_PAT]: {
x_open_swe_ui_config: {
type: "hidden",
@ -553,6 +559,12 @@ export const GraphConfiguration = z.object({
[GITHUB_INSTALLATION_NAME]: withLangGraph(z.string().optional(), {
metadata: GraphConfigurationMetadata[GITHUB_INSTALLATION_NAME],
}),
/**
* The installation ID of the GitHub app the user is using to create the run.
*/
[GITHUB_INSTALLATION_ID]: withLangGraph(z.string().optional(), {
metadata: GraphConfigurationMetadata[GITHUB_INSTALLATION_ID],
}),
/**
* GitHub Personal Access Token. Used for simpler authentication in environments like evals
* where GitHub App installation tokens are not available or needed.

View file

@ -4194,6 +4194,7 @@ __metadata:
eslint-plugin-import: ^2.27.5
eslint-plugin-no-instanceof: ^1.0.1
eslint-plugin-prettier: ^4.2.1
jsonwebtoken: ^9.0.2
prettier: ^3.5.2
typescript: ~5.7.2
typescript-eslint: ^8.22.0
@ -4249,7 +4250,6 @@ __metadata:
eslint-plugin-react-refresh: ^0.4.18
framer-motion: ^12.4.9
globals: ^15.14.0
jsonwebtoken: ^9.0.2
katex: ^0.16.21
langgraph-nextjs-api-passthrough: ^0.1.3
lodash: ^4.17.21