feat(agent-generated): Add github oauth (#63)

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* Apply patch

* format and lint

* fix ui

* Apply patch (#68)

Co-authored-by: Harrison Chase <11986836+hwchase17@users.noreply.github.com>

* fix: Github auth

* update readme

* cr

* cr

* cr

* cr

---------

Co-authored-by: Harrison Chase <11986836+hwchase17@users.noreply.github.com>
Co-authored-by: Harrison Chase <hw.chase.17@gmail.com>
This commit is contained in:
Brace Sproul 2025-06-01 18:30:29 -07:00 • committed by GitHub
parent afbedeec0d
commit 887fc030a4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 2037 additions and 186 deletions

View file

@ -45,30 +45,53 @@ GOOGLE_API_KEY=""
# Daytona API key for accessing and modifying the code in the cloud sandbox.
DAYTONA_API_KEY=""
# Your GitHub PAT with access to the repositories you want to modify.
GITHUB_PAT=""
```
And the web `.env` file should contain the following variables:
```bash
NEXT_PUBLIC_API_URL=http://localhost:2024 # Change to production URL when deployed
NEXT_PUBLIC_ASSISTANT_ID=open-swe
NEXT_PUBLIC_ASSISTANT_ID="open-swe"
# For the GitHub OAuth flow
GITHUB_APP_CLIENT_ID=""
GITHUB_APP_CLIENT_SECRET=""
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
GITHUB_APP_NAME="open-swe-dev"
GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY=""
```
To generate the GitHub personal access token, you should:
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).
1. Go to [GitHub settings](https://github.com/settings/personal-access-tokens)
2. Click on `Generate new token` to generate a new fine grained token.
3. Give the token a name & description.
4. Choose `Only select repositories`, and select the repositories you want to give Open SWE access to.
5. Under `Permissions`, give it `Repository permission`:
- `Contents` - `Read and write`
- `Metadata` - `Read-only` (should be auto enabled after selecting `Contents`)
- `Pull requests` - `Read and write`
6. Click `Generate token` & copy the token.
7. Paste the token into the `GITHUB_PAT` variable in the agent `.env` file.
Give the app a name and description.
Under `Callback URL`, set it to: `http://localhost:3000/api/auth/github/callback` for local development. Then, uncheck `Expire user authorization tokens`, and check `Request user authorization (OAuth) during installation`.
Under `Post installation`, check `Redirect on update`.
Under `Webhook`, uncheck `Active`.
Under `Repository permissions`, give the app the following permissions:
- `Contents` - `Read & Write`
- `Metadata` - `Read & Write`
- `Pull requests` - `Read & Write`
- `Issues` - `Read & Write`
Finally, under `Where can this GitHub App be installed?` ensure `Any account` is selected.
After creating the app, you will be taken to the app's settings page. Copy/generate the following fields for your environment variables:
`App ID` - `GITHUB_APP_ID`
`Client ID` - `GITHUB_APP_CLIENT_ID`
`Client secrets` - Generate a new secret key, and set it under `GITHUB_APP_CLIENT_SECRET`
Scroll down to `Private keys`, and generate a new private key. This will download a file. Set the contents of this file under `GITHUB_APP_PRIVATE_KEY`.
Set `GITHUB_APP_REDIRECT_URI` to `http://localhost:3000/api/auth/github/callback` for local development.
Set `GITHUB_APP_NAME` to the name of your app.
That's it! You can now authenticate users with GitHub, and generate tokens for them.
## Running the graph
@ -114,3 +137,4 @@ Once you've accepted the plan, it will begin the execution flow. When the agent
## Accessing Changes
Open SWE will automatically create a branch whenever you create a new thread with a naming format of `open-swe/<threadId>`. Every time a file is created, modified, or deleted, the changes will be committed to this branch. You can access the changes in the repository by checking out this branch.

View file

@ -11,6 +11,3 @@ GOOGLE_API_KEY=""
# Daytona API key for accessing and modifying the code in the cloud sandbox.
DAYTONA_API_KEY=""
# Your GitHub PAT with access to the repositories you want to modify.
GITHUB_PAT=""

View file

@ -1,7 +1,3 @@
export const TIMEOUT_MS = 300_000; // 5 minutes
// Always extend timeouts by 5 min after each command execution.
export const TIMEOUT_EXTENSION_OPT = {
timeoutMs: TIMEOUT_MS,
};
export const TIMEOUT_SEC = 60; // 1 minute
export const SANDBOX_ROOT_DIR = "/home/daytona";
export const SNAPSHOT_NAME = "daytonaio/langchain-open-swe:0.1.0";

View file

@ -5,7 +5,7 @@ import {
applyPatchTool,
requestHumanHelpTool,
} from "../tools/index.js";
import { getRepoAbsolutePath } from "../utils/git/index.js";
import { getRepoAbsolutePath } from "../utils/git.js";
import { formatPlanPrompt } from "../utils/plan-prompt.js";
import { stopSandbox } from "../utils/sandbox.js";
import { createLogger, LogLevel } from "../utils/logger.js";

View file

@ -1,51 +1,19 @@
import { Sandbox } from "@daytonaio/sdk";
import { createLogger, LogLevel } from "../utils/logger.js";
import {
GraphState,
GraphConfig,
GraphUpdate,
TargetRepository,
} from "../types.js";
import { GraphState, GraphConfig, GraphUpdate } from "../types.js";
import {
checkoutBranch,
cloneRepo,
configureGitUserInRepo,
getBranchName,
getRepoAbsolutePath,
pullLatestChanges,
} from "../utils/git/index.js";
import { getSandboxErrorFields } from "../utils/sandbox-error-fields.js";
} from "../utils/git.js";
import { daytonaClient } from "../utils/sandbox.js";
import { SNAPSHOT_NAME } from "../constants.js";
import { getGitHubTokensFromConfig } from "../utils/github-tokens.js";
const logger = createLogger(LogLevel.INFO, "Initialize");
async function cloneRepo(sandbox: Sandbox, targetRepository: TargetRepository) {
if (!process.env.GITHUB_PAT) {
throw new Error("GITHUB_PAT environment variable not set.");
}
try {
const gitCloneCommand = ["git", "clone"];
const repoUrlWithToken = `https://${process.env.GITHUB_PAT}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`;
if (targetRepository.branch) {
gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken);
} else {
gitCloneCommand.push(repoUrlWithToken);
}
logger.info("Cloning repository", {
command: gitCloneCommand.join(" "),
});
return await sandbox.process.executeCommand(gitCloneCommand.join(" "));
} catch (e) {
const errorFields = getSandboxErrorFields(e);
logger.error("Failed to clone repository", errorFields ?? e);
throw e;
}
}
/**
* Initializes the session. This ensures there's an active VM session, and that
* the proper credentials are provided for taking actions on GitHub.
@ -56,16 +24,8 @@ export async function initialize(
state: GraphState,
config: GraphConfig,
): Promise<GraphUpdate> {
if (!config.configurable) {
throw new Error("Configuration object not found.");
}
const { sandboxSessionId } = state;
const { targetRepository } = state;
if (!targetRepository) {
throw new Error(
"Missing required target repository. Please provide a git repository in state or configuration.",
);
}
const { githubToken, githubAccessToken } = getGitHubTokensFromConfig(config);
const { sandboxSessionId, targetRepository } = state;
const absoluteRepoDir = getRepoAbsolutePath(targetRepository);
if (sandboxSessionId) {
@ -90,7 +50,7 @@ export async function initialize(
image: SNAPSHOT_NAME,
});
const res = await cloneRepo(sandbox, targetRepository);
const res = await cloneRepo(sandbox, targetRepository, { githubToken });
if (res.exitCode !== 0) {
// TODO: This should probably be an interrupt.
logger.error("Failed to clone repository", res.result);
@ -99,7 +59,12 @@ export async function initialize(
logger.info("Repository cloned successfully.");
logger.info(`Configuring git user for repository at "${absoluteRepoDir}"...`);
await configureGitUserInRepo(absoluteRepoDir, sandbox);
await configureGitUserInRepo(absoluteRepoDir, sandbox, {
githubToken,
githubAccessToken,
owner: targetRepository.owner,
repo: targetRepository.repo,
});
logger.info("Git user configured successfully.");
const checkoutBranchRes = await checkoutBranch(

View file

@ -5,7 +5,7 @@ import {
getBranchName,
getChangedFilesStatus,
getRepoAbsolutePath,
} from "../utils/git/index.js";
} from "../utils/git.js";
import { createLogger, LogLevel } from "../utils/logger.js";
import { z } from "zod";
import { loadModel, Task } from "../utils/load-model.js";
@ -13,6 +13,7 @@ import { formatPlanPromptWithSummaries } from "../utils/plan-prompt.js";
import { isHumanMessage, ToolMessage } from "@langchain/core/messages";
import { getMessageContentString } from "../utils/message/content.js";
import { daytonaClient } from "../utils/sandbox.js";
import { getGitHubTokensFromConfig } from "../utils/github-tokens.js";
const logger = createLogger(LogLevel.INFO, "Open PR");
@ -65,6 +66,7 @@ export async function openPullRequest(
"Failed to open pull request: No sandbox session ID found in state.",
);
}
const { githubToken } = getGitHubTokensFromConfig(config);
const sandbox = await daytonaClient().get(sandboxSessionId);
@ -133,6 +135,7 @@ export async function openPullRequest(
headBranch: branchName ?? getBranchName(config),
title,
body,
githubToken,
});
return {

View file

@ -10,7 +10,7 @@ import {
checkoutBranchAndCommit,
getChangedFilesStatus,
getRepoAbsolutePath,
} from "../utils/git/index.js";
} from "../utils/git.js";
import {
formatBadArgsError,
zodSchemaToString,

View file

@ -361,6 +361,28 @@ export const GraphConfiguration = z.object({
"The maximum number of tokens to generate in an individual generation",
},
}),
/**
* The user's GitHub installation token. To be used to take actions on behalf of the user.
*/
"x-github-installation-token": z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "hidden",
},
}),
/**
* The user's GitHub access token. To be used in requests to get information about the user.
*/
"x-github-access-token": z
.string()
.optional()
.langgraph.metadata({
x_oap_ui_config: {
type: "hidden",
},
}),
});
export type GraphConfig = LangGraphRunnableConfig<

View file

@ -1,9 +1,9 @@
import { Octokit } from "@octokit/rest";
import { Sandbox } from "@daytonaio/sdk";
import { createLogger, LogLevel } from "../logger.js";
import { GraphConfig, TargetRepository } from "../../types.js";
import { TIMEOUT_MS, SANDBOX_ROOT_DIR } from "../../constants.js";
import { getSandboxErrorFields } from "../sandbox-error-fields.js";
import { createLogger, LogLevel } from "./logger.js";
import { GraphConfig, TargetRepository } from "../types.js";
import { TIMEOUT_SEC, SANDBOX_ROOT_DIR } from "../constants.js";
import { getSandboxErrorFields } from "./sandbox-error-fields.js";
import { ExecuteResponse } from "@daytonaio/sdk/dist/types/ExecuteResponse.js";
const logger = createLogger(LogLevel.INFO, "GitUtil");
@ -40,7 +40,7 @@ export async function checkoutBranch(
"git branch --show-current",
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
if (getCurrentBranchOutput.exitCode !== 0) {
@ -80,7 +80,7 @@ export async function checkoutBranch(
`git rev-parse --verify --quiet "refs/heads/${branchName}"`,
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
if (checkBranchExistsOutput.exitCode === 0) {
@ -116,7 +116,7 @@ export async function checkoutBranch(
checkoutCommand,
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
if (gitCheckoutOutput.exitCode !== 0) {
@ -152,29 +152,24 @@ interface GitHubUserResponse {
email: string | null;
}
async function getGitUserDetailsFromGitHub(): Promise<{
async function getGitUserDetailsFromGitHub(githubToken: string): Promise<{
userName?: string;
userEmail?: string;
}> {
const githubToken = process.env.GITHUB_PAT;
if (!githubToken) {
logger.warn(
"GITHUB_PAT environment variable is not set. Cannot fetch user details from GitHub.",
);
return {};
}
try {
// Try with Bearer token first (for GitHub App installation tokens)
const response = await fetch("https://api.github.com/user", {
headers: {
Authorization: `token ${githubToken}`,
Authorization: `Bearer ${githubToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenSWE",
},
});
if (!response.ok) {
logger.error(`Failed to fetch GitHub user info`, {
response,
status: response.status,
statusText: response.statusText,
});
return {};
}
@ -198,6 +193,10 @@ async function getGitUserDetailsFromGitHub(): Promise<{
if (!finalUserEmail) {
logger.warn("Could not determine GitHub user email from API response.");
}
logger.info("Successfully fetched GitHub user info", {
userName: finalUserName,
userEmail: finalUserEmail,
});
return { userName: finalUserName, userEmail: finalUserEmail };
} catch (e) {
logger.error(`Error fetching GitHub user info`, {
@ -214,20 +213,27 @@ async function getGitUserDetailsFromGitHub(): Promise<{
export async function configureGitUserInRepo(
absoluteRepoDir: string,
sandbox: Sandbox,
args: {
githubToken: string;
githubAccessToken: string;
owner: string;
repo: string;
},
): Promise<void> {
const { githubToken, githubAccessToken, owner, repo } = args;
let needsGitConfig = false;
try {
const nameCheck = await sandbox.process.executeCommand(
"git config user.name",
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
const emailCheck = await sandbox.process.executeCommand(
"git config user.email",
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
if (
@ -249,39 +255,70 @@ export async function configureGitUserInRepo(
needsGitConfig = true;
}
if (needsGitConfig) {
const { userName, userEmail } = await getGitUserDetailsFromGitHub();
// Configure git to use the token for authentication with GitHub by updating the remote URL
logger.info(
"Configuring git to use token for GitHub authentication via remote URL...",
);
try {
// Set the remote URL with the token using the provided owner and repo
const setRemoteOutput = await sandbox.process.executeCommand(
`git remote set-url origin https://x-access-token:${githubToken}@github.com/${owner}/${repo}.git`,
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
if (userName) {
const configUserNameOutput = await sandbox.process.executeCommand(
`git config user.name "${userName}"`,
absoluteRepoDir,
undefined,
TIMEOUT_MS,
);
if (configUserNameOutput.exitCode !== 0) {
logger.error(`Failed to set git user.name`, {
configUserNameOutput,
});
} else {
logger.info(`Set git user.name to '${userName}' successfully.`);
}
if (setRemoteOutput.exitCode !== 0) {
logger.error(`Failed to set remote URL with token`, {
setRemoteOutput,
});
} else {
logger.info("Git remote URL updated with token successfully.");
}
} catch (authError) {
logger.error(`Error configuring git authentication for GitHub`, {
...(authError instanceof Error && {
name: authError.name,
message: authError.message,
stack: authError.stack,
}),
});
}
if (needsGitConfig) {
const { userName, userEmail } =
await getGitUserDetailsFromGitHub(githubAccessToken);
// Set user name - use fetched name or fallback to "GitHub App User"
const nameToUse = userName || "GitHub App User";
const configUserNameOutput = await sandbox.process.executeCommand(
`git config user.name "${nameToUse}"`,
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
if (configUserNameOutput.exitCode !== 0) {
logger.error(`Failed to set git user.name`, {
configUserNameOutput,
});
} else {
logger.info(`Set git user.name to '${nameToUse}' successfully.`);
}
if (userEmail) {
const configUserEmailOutput = await sandbox.process.executeCommand(
`git config user.email "${userEmail}"`,
absoluteRepoDir,
undefined,
TIMEOUT_MS,
);
if (configUserEmailOutput.exitCode !== 0) {
logger.error(`Failed to set git user.email`, {
configUserEmailOutput,
});
} else {
logger.info(`Set git user.email to '${userEmail}' successfully.`);
}
// Set user email - use fetched email or fallback to a generic noreply address
const emailToUse = userEmail || "open-swe-bot@noreply.github.com";
const configUserEmailOutput = await sandbox.process.executeCommand(
`git config user.email "${emailToUse}"`,
absoluteRepoDir,
undefined,
TIMEOUT_SEC,
);
if (configUserEmailOutput.exitCode !== 0) {
logger.error(`Failed to set git user.email`, {
configUserEmailOutput,
});
} else {
logger.info(`Set git user.email to '${emailToUse}' successfully.`);
}
} else {
logger.info(
@ -300,7 +337,7 @@ export async function commitAll(
`git add -A && git commit -m "${message}"`,
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
if (gitAddOutput.exitCode !== 0) {
@ -330,7 +367,9 @@ export async function commitAllAndPush(
): Promise<ExecuteResponse | false> {
try {
const commitOutput = await commitAll(absoluteRepoDir, message, sandbox);
logger.info(
"Committed changes to git repository successfully. Now pushing...",
);
const pushCurrentBranchCmd =
"git push -u origin $(git rev-parse --abbrev-ref HEAD)";
@ -342,7 +381,7 @@ export async function commitAllAndPush(
pushCurrentBranchCmd,
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
if (gitPushOutput.exitCode !== 0) {
@ -375,7 +414,7 @@ export async function getChangedFilesStatus(
"git status --porcelain",
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
if (gitStatusOutput.exitCode !== 0) {
@ -418,21 +457,17 @@ export async function createPullRequest({
headBranch,
title,
body = "",
githubToken,
}: {
owner: string;
repo: string;
headBranch: string;
title: string;
body?: string;
githubToken: string;
}) {
// Initialize Octokit with the personal access token
const token = process.env.GITHUB_PAT;
if (!token) {
throw new Error("GITHUB_PAT environment variable is not set");
}
const octokit = new Octokit({
auth: token,
auth: githubToken,
});
try {
@ -476,7 +511,7 @@ export async function pullLatestChanges(
"git pull",
absoluteRepoDir,
undefined,
TIMEOUT_MS,
TIMEOUT_SEC,
);
return gitPullOutput;
} catch (e) {
@ -492,3 +527,35 @@ export async function pullLatestChanges(
return false;
}
}
export async function cloneRepo(
sandbox: Sandbox,
targetRepository: TargetRepository,
args: {
githubToken: string;
},
) {
try {
const gitCloneCommand = ["git", "clone"];
// Use x-access-token format for better GitHub authentication
const repoUrlWithToken = `https://x-access-token:${args.githubToken}@github.com/${targetRepository.owner}/${targetRepository.repo}.git`;
if (targetRepository.branch) {
gitCloneCommand.push("-b", targetRepository.branch, repoUrlWithToken);
} else {
gitCloneCommand.push(repoUrlWithToken);
}
logger.info("Cloning repository", {
// Don't log the full command with token for security reasons
repoPath: `${targetRepository.owner}/${targetRepository.repo}`,
branch: targetRepository.branch || "default",
});
return await sandbox.process.executeCommand(gitCloneCommand.join(" "));
} catch (e) {
const errorFields = getSandboxErrorFields(e);
logger.error("Failed to clone repository", errorFields ?? e);
throw e;
}
}

View file

@ -0,0 +1,21 @@
import { GraphConfig } from "../types.js";
export function getGitHubTokensFromConfig(config: GraphConfig): {
githubToken: string;
githubAccessToken: string;
} {
if (!config.configurable) {
throw new Error("No configurable object found in graph config.");
}
const githubToken = config.configurable["x-github-installation-token"];
const githubAccessToken = config.configurable["x-github-access-token"];
if (!githubToken) {
throw new Error(
"Missing required x-github-installation-token in configuration.",
);
}
if (!githubAccessToken) {
throw new Error("Missing required x-github-access-token in configuration.");
}
return { githubToken, githubAccessToken };
}

View file

@ -1,13 +1,11 @@
# LangGraph Configuration
NEXT_PUBLIC_API_URL=http://localhost:2024
NEXT_PUBLIC_ASSISTANT_ID=agent
# Do NOT prefix this with "NEXT_PUBLIC_" as we do not want this exposed in the client.
LANGSMITH_API_KEY=
NEXT_PUBLIC_API_URL="http://localhost:2024"
NEXT_PUBLIC_ASSISTANT_ID="open-swe"
# Production LangGraph Configuration (quickstart) - Uncomment to use
# NEXT_PUBLIC_ASSISTANT_ID="agent"
# This should be the deployment URL of your LangGraph server
# LANGGRAPH_API_URL="https://my-agent.default.us.langgraph.app"
# This should be the URL of your website + "/api". This is how you connect to the API proxy
# NEXT_PUBLIC_API_URL="https://my-website.com/api"
# LANGSMITH_API_KEY="lsv2_..."
# For the GitHub OAuth flow
GITHUB_APP_CLIENT_ID=""
GITHUB_APP_CLIENT_SECRET=""
GITHUB_APP_REDIRECT_URI="http://localhost:3000/api/auth/github/callback"
GITHUB_APP_NAME="Open SWE Dev"
GITHUB_APP_ID=""
GITHUB_APP_PRIVATE_KEY=""

View file

@ -38,6 +38,7 @@
"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.0.4",
"lodash": "^4.17.21",
@ -62,6 +63,7 @@
"devDependencies": {
"@eslint/js": "^9.19.0",
"@tailwindcss/postcss": "^4.0.13",
"@types/jsonwebtoken": "^9.0.9",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.5",
"@types/react": "^19.0.8",

View file

@ -0,0 +1,133 @@
import {
GITHUB_AUTH_STATE_COOKIE,
GITHUB_INSTALLATION_ID_COOKIE,
GITHUB_TOKEN_COOKIE,
GITHUB_TOKEN_TYPE_COOKIE,
} from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const code = searchParams.get("code");
const state = searchParams.get("state");
const error = searchParams.get("error");
const installationId = searchParams.get("installation_id");
// Handle GitHub App errors
if (error) {
return NextResponse.redirect(
new URL(`/?error=${encodeURIComponent(error)}`, request.url),
);
}
// Validate required parameters
if (!code) {
return NextResponse.redirect(
new URL("/?error=missing_code_parameter", request.url),
);
}
// Verify state parameter to prevent CSRF attacks
const storedState = request.cookies.get(GITHUB_AUTH_STATE_COOKIE)?.value;
if (storedState && state !== storedState) {
return NextResponse.redirect(
new URL("/?error=invalid_state", request.url),
);
}
const clientId = process.env.GITHUB_APP_CLIENT_ID;
const clientSecret = process.env.GITHUB_APP_CLIENT_SECRET;
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
if (!clientId || !clientSecret || !redirectUri) {
return NextResponse.redirect(
new URL("/?error=configuration_missing", request.url),
);
}
// Exchange authorization code for access token
const tokenResponse = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: clientId,
client_secret: clientSecret,
code: code,
redirect_uri: redirectUri,
}),
},
);
if (!tokenResponse.ok) {
console.error("Token exchange failed:", await tokenResponse.text());
return NextResponse.redirect(
new URL("/?error=token_exchange_failed", request.url),
);
}
const tokenData = await tokenResponse.json();
if (tokenData.error) {
return NextResponse.redirect(
new URL(`/?error=${encodeURIComponent(tokenData.error)}`, request.url),
);
}
// Create the success response
const response = NextResponse.redirect(
new URL("/?auth=success", request.url),
);
// Clear the state cookie as it's no longer needed
response.cookies.set(GITHUB_AUTH_STATE_COOKIE, "", {
expires: new Date(0),
path: "/",
});
// Set token cookies directly on the response
response.cookies.set(GITHUB_TOKEN_COOKIE, tokenData.access_token, {
// httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
response.cookies.set(
GITHUB_TOKEN_TYPE_COOKIE,
tokenData.token_type || "bearer",
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
},
);
// If there's an installation_id, store that as well for future API calls
if (installationId) {
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
}
return response;
} catch (error) {
console.error("GitHub App callback error:", error);
return NextResponse.redirect(
new URL("/?error=callback_failed", request.url),
);
}
}

View file

@ -0,0 +1,45 @@
import { GITHUB_AUTH_STATE_COOKIE } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
export async function GET(_request: NextRequest) {
try {
const clientId = process.env.GITHUB_APP_CLIENT_ID;
const redirectUri = process.env.GITHUB_APP_REDIRECT_URI;
if (!clientId || !redirectUri) {
return NextResponse.json(
{ error: "GitHub App configuration missing" },
{ status: 500 },
);
}
// Generate a random state parameter for security
const state = crypto.randomUUID();
// Build the GitHub App authorization URL
const authUrl = new URL("https://github.com/login/oauth/authorize");
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("allow_signup", "true");
authUrl.searchParams.set("state", state);
// Create response with redirect and store state in a cookie
const response = NextResponse.redirect(authUrl.toString());
response.cookies.set(GITHUB_AUTH_STATE_COOKIE, state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 10, // 10 minutes
path: "/",
});
return response;
} catch (error) {
console.error("GitHub App login error:", error);
return NextResponse.json(
{ error: "Failed to initiate GitHub App authentication flow" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { clearGitHubToken } from "@/lib/auth";
/**
* API route to handle GitHub logout
*/
export async function POST(request: NextRequest) {
try {
const response = NextResponse.json({ success: true });
clearGitHubToken(response);
return response;
} catch (error) {
console.error("Error during logout:", error);
return NextResponse.json(
{ success: false, error: "Failed to logout" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { isAuthenticated } from "@/lib/auth";
/**
* API route to check GitHub authentication status
*/
export async function GET(request: NextRequest) {
try {
const authenticated = isAuthenticated(request);
return NextResponse.json({ authenticated });
} catch (error) {
console.error("Error checking auth status:", error);
return NextResponse.json(
{ authenticated: false, error: "Failed to check authentication status" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,73 @@
import {
GITHUB_INSTALLATION_ID_COOKIE,
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
GITHUB_INSTALLATION_STATE_COOKIE,
} from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
/**
* Handles callbacks from GitHub App installations
* This endpoint is called by GitHub after a user installs or configures the GitHub App
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const setupAction = searchParams.get("setup_action");
// Get the return URL from cookies
const returnTo =
request.cookies.get(GITHUB_INSTALLATION_RETURN_TO_COOKIE)?.value || "/";
// Verify state parameter to prevent CSRF attacks
// GitHub App installation doesn't return the state directly, but we included it in our callback URL
const customState = searchParams.get("custom_state");
const storedState = request.cookies.get(
GITHUB_INSTALLATION_STATE_COOKIE,
)?.value;
// Validate state if it exists
if (storedState && customState && storedState !== customState) {
console.warn("Invalid installation state detected");
// We'll still proceed but log the warning
}
// Create the response that will redirect back to the app
const response = NextResponse.redirect(returnTo);
// Clear cookies as they're no longer needed
const expiredCookieOptions = {
expires: new Date(0),
path: "/",
};
response.cookies.set(
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
"",
expiredCookieOptions,
);
response.cookies.set(
GITHUB_INSTALLATION_STATE_COOKIE,
"",
expiredCookieOptions,
);
// If we have an installation ID, store it in a cookie
if (installationId) {
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
}
return response;
} catch (error) {
console.error("GitHub App installation callback error:", error);
return NextResponse.redirect(
new URL("/?error=installation_callback_failed", request.url),
);
}
}

View file

@ -0,0 +1,40 @@
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
/**
* Handles the callback from GitHub App installation
* After a user installs the app and selects repositories, GitHub redirects here
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
const installationId = searchParams.get("installation_id");
const setupAction = searchParams.get("setup_action");
if (!installationId) {
return NextResponse.redirect(
new URL("/?error=missing_installation_id", request.url),
);
}
// Store the installation ID in a cookie for future API calls
const response = NextResponse.redirect(
new URL("/?installation=success", request.url),
);
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, installationId, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 30, // 30 days
path: "/",
});
return response;
} catch (error) {
console.error("GitHub App installation callback error:", error);
return NextResponse.redirect(
new URL("/?error=installation_callback_failed", request.url),
);
}
}

View file

@ -0,0 +1,84 @@
import {
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
GITHUB_INSTALLATION_STATE_COOKIE,
GITHUB_TOKEN_COOKIE,
} from "@/lib/auth";
import { NextRequest, NextResponse } from "next/server";
import { randomBytes } from "crypto";
/**
* Initiates the GitHub App installation flow
* This redirects users to the GitHub App installation page where they can
* select which repositories to grant access to
*/
export async function GET(request: NextRequest) {
try {
const accessToken = request.cookies.get(GITHUB_TOKEN_COOKIE)?.value;
if (!accessToken) {
return NextResponse.json(
{ error: "GitHub access token not found" },
{ status: 401 },
);
}
// Get GitHub App name from environment variables
const githubAppName = process.env.GITHUB_APP_NAME;
if (!githubAppName) {
return NextResponse.json(
{ error: "GitHub App name not configured" },
{ status: 500 },
);
}
// Check for existing state or generate a new one
let state = request.cookies.get(GITHUB_INSTALLATION_STATE_COOKIE)?.value;
// If no state exists or we want to ensure a fresh state, generate a new one
if (!state) {
state = randomBytes(16).toString("hex");
}
// Create a response that will redirect to the GitHub App installation page
// Include the callback URL as a parameter to ensure GitHub redirects back to our app
// Add the state as a custom parameter in the callback URL
const baseCallbackUrl = `${request.nextUrl.origin}/api/github/installation-callback`;
const callbackUrl = `${baseCallbackUrl}?custom_state=${encodeURIComponent(state)}`;
const response = NextResponse.redirect(
`https://github.com/apps/${githubAppName}/installations/new?redirect_uri=${encodeURIComponent(callbackUrl)}`,
);
// Cookie options for security and proper expiration
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: 60 * 10, // 10 minutes
path: "/",
};
// Store the state in a cookie for validation when GitHub redirects back
response.cookies.set(
GITHUB_INSTALLATION_STATE_COOKIE,
state,
cookieOptions,
);
// Store the current URL as the return_to URL so we can redirect back after installation
const returnTo = request.headers.get("referer") || "/";
response.cookies.set(
GITHUB_INSTALLATION_RETURN_TO_COOKIE,
returnTo,
cookieOptions,
);
return response;
} catch (error) {
console.error("Error initiating GitHub App installation:", error);
return NextResponse.json(
{ error: "Failed to initiate GitHub App installation" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import {
getInstallationToken,
getInstallationRepositories,
Repository,
} from "@/utils/github";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
/**
* Fetches repositories accessible to the GitHub App installation
* Requires a valid GitHub installation ID in the cookies
*/
export async function GET(request: NextRequest) {
try {
// Get the installation ID from cookies
const installationId = request.cookies.get(
GITHUB_INSTALLATION_ID_COOKIE,
)?.value;
if (!installationId) {
return NextResponse.json(
{
error:
"GitHub installation ID not found. Please install the app first.",
},
{ status: 401 },
);
}
// Get GitHub App credentials from environment variables
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(
/\\n/g,
"\n",
);
if (!appId || !privateKey) {
return NextResponse.json(
{ error: "GitHub App configuration missing" },
{ status: 500 },
);
}
// Get an installation access token
let installationToken: string;
try {
installationToken = await getInstallationToken(
installationId,
appId,
privateKey,
);
} catch (error) {
console.error("Failed to get installation token:", error);
return NextResponse.json(
{ error: "Failed to get installation token" },
{ status: 401 },
);
}
// Fetch repositories accessible to this installation
let repositories: Repository[];
try {
repositories = await getInstallationRepositories(installationToken);
} catch (error) {
console.error("Failed to fetch repositories:", error);
return NextResponse.json(
{ error: "Failed to fetch repositories" },
{ status: 500 },
);
}
// Transform the response to include only the data we need
const transformedRepos = repositories.map((repo) => ({
id: repo.id,
name: repo.name,
full_name: repo.full_name,
description: repo.description,
private: repo.private,
html_url: repo.html_url,
default_branch: repo.default_branch,
permissions: repo.permissions,
}));
return NextResponse.json({ repositories: transformedRepos });
} catch (error) {
console.error("Error fetching GitHub repositories:", error);
return NextResponse.json(
{ error: "Failed to fetch repositories" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,66 @@
import { NextRequest, NextResponse } from "next/server";
import { getInstallationToken } from "@/utils/github";
import { GITHUB_INSTALLATION_ID_COOKIE } from "@/lib/auth";
/**
* Returns a GitHub installation token that can be used for Git operations
* This endpoint is intended for internal use by the AI coding agent
*/
export async function GET(request: NextRequest) {
try {
// Get the installation ID from cookies
const installationId = request.cookies.get(
GITHUB_INSTALLATION_ID_COOKIE,
)?.value;
if (!installationId) {
return NextResponse.json(
{
error:
"GitHub installation ID not found. Please install the app first.",
},
{ status: 401 },
);
}
// Get GitHub App credentials from environment variables
const appId = process.env.GITHUB_APP_ID;
const privateKey = process.env.GITHUB_APP_PRIVATE_KEY?.replace(
/\\n/g,
"\n",
);
if (!appId || !privateKey) {
return NextResponse.json(
{ error: "GitHub App configuration missing" },
{ status: 500 },
);
}
// Get an installation access token
try {
const installationToken = await getInstallationToken(
installationId,
appId,
privateKey,
);
return NextResponse.json({
token: installationToken,
installation_id: installationId,
});
} catch (error) {
console.error("Failed to get installation token:", error);
return NextResponse.json(
{ error: "Failed to get installation token" },
{ status: 401 },
);
}
} catch (error) {
console.error("Error generating GitHub token:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,156 @@
"use client";
import { useState, useEffect } from "react";
import { Repository } from "@/utils/github";
export default function GitHubPage() {
const [repositories, setRepositories] = useState<Repository[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isInstalled, setIsInstalled] = useState(false);
useEffect(() => {
const checkInstallation = async () => {
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
setIsInstalled(true);
const data = await response.json();
setRepositories(data.repositories || []);
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setIsInstalled(false);
} else {
setError(errorData.error);
}
}
} catch (err) {
setError("Failed to check installation status");
}
};
checkInstallation();
}, []);
const handleInstall = async () => {
window.location.href = "/api/github/installation";
};
const handleRefreshRepositories = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
const data = await response.json();
setRepositories(data.repositories || []);
} else {
const errorData = await response.json();
setError(errorData.error);
}
} catch (err) {
setError("Failed to fetch repositories");
} finally {
setLoading(false);
}
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="mb-6 text-3xl font-bold">GitHub Repository Access</h1>
{error && (
<div className="mb-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700">
{error}
</div>
)}
{!isInstalled ? (
<div className="mb-6 rounded-lg bg-white p-6 shadow-md">
<h2 className="mb-4 text-xl font-semibold">Install GitHub App</h2>
<p className="mb-4">
To access your GitHub repositories, you need to install our GitHub
App and grant it access to the repositories you want to use.
</p>
<button
onClick={handleInstall}
className="rounded bg-black px-4 py-2 text-white hover:bg-gray-800"
>
Install GitHub App
</button>
</div>
) : (
<div className="mb-6 rounded-lg bg-white p-6 shadow-md">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">Your Repositories</h2>
<button
onClick={handleRefreshRepositories}
disabled={loading}
className="rounded bg-gray-200 px-4 py-2 hover:bg-gray-300 disabled:opacity-50"
>
{loading ? "Refreshing..." : "Refresh"}
</button>
</div>
{repositories.length === 0 ? (
<p>
No repositories found. Make sure you've granted access to at least
one repository.
</p>
) : (
<ul className="divide-y">
{repositories.map((repo) => (
<li
key={repo.id}
className="py-4"
>
<div className="flex items-start">
<div>
<h3 className="font-medium">
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{repo.full_name}
</a>
</h3>
<p className="mt-1 text-sm text-gray-600">
{repo.description || "No description"}
</p>
<div className="mt-2 flex items-center text-sm">
<span className="mr-4">
{repo.private ? "Private" : "Public"}
</span>
<span>Default branch: {repo.default_branch}</span>
</div>
</div>
</div>
</li>
))}
</ul>
)}
</div>
)}
<div className="rounded-lg bg-white p-6 shadow-md">
<h2 className="mb-4 text-xl font-semibold">Manage GitHub App</h2>
<p className="mb-4">
You can manage your GitHub App installation, including adding or
removing repositories, through GitHub.
</p>
<a
href="https://github.com/settings/installations"
target="_blank"
rel="noopener noreferrer"
className="inline-block rounded bg-gray-200 px-4 py-2 text-gray-800 hover:bg-gray-300"
>
Manage on GitHub
</a>
</div>
</div>
);
}

View file

@ -0,0 +1,93 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
export function GitHubOAuthButton() {
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
const response = await fetch("/api/auth/status");
const data = await response.json();
setIsAuth(data.authenticated);
} catch (error) {
console.error("Error checking auth status:", error);
setIsAuth(false);
}
};
const handleLogin = () => {
setIsLoading(true);
window.location.href = "/api/auth/github/login";
};
const handleLogout = async () => {
setIsLoading(true);
try {
const response = await fetch("/api/auth/logout", {
method: "POST",
});
if (response.ok) {
setIsAuth(false);
} else {
console.error("Logout failed");
}
} catch (error) {
console.error("Error during logout:", error);
} finally {
setIsLoading(false);
}
};
if (isAuth === null) {
return (
<Button
variant="outline"
disabled
>
<GitHubSVG
width="16"
height="16"
/>
Checking...
</Button>
);
}
if (isAuth) {
return (
<Button
variant="outline"
onClick={handleLogout}
disabled={isLoading}
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading ? "Disconnecting..." : "Disconnect GitHub"}
</Button>
);
}
return (
<Button
variant="outline"
onClick={handleLogin}
disabled={isLoading}
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading ? "Connecting..." : "Connect GitHub"}
</Button>
);
}

View file

@ -0,0 +1,151 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { CopyIcon, CheckIcon, RefreshCwIcon } from "lucide-react";
import { InstallAppButton } from "./InstallAppButton";
interface AgentTokenProviderProps {
className?: string;
}
/**
* Component to fetch and display a GitHub installation token for use with the AI agent
* This token can be passed to your agent service to perform Git operations on behalf of the user
*/
export function AgentTokenProvider({
className = "",
}: AgentTokenProviderProps) {
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [installationId, setInstallationId] = useState<string | null>(null);
const fetchToken = async () => {
setIsLoading(true);
setError(null);
setCopied(false);
try {
const response = await fetch("/api/github/token");
if (!response.ok) {
const errorData = await response.json();
setError(errorData.error || "Failed to fetch token");
setIsLoading(false);
return;
}
const data = await response.json();
setToken(data.token);
setInstallationId(data.installation_id);
setIsLoading(false);
} catch (err) {
setError("Network error when fetching token");
setIsLoading(false);
}
};
useEffect(() => {
fetchToken();
}, []);
const copyToken = () => {
if (token) {
navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
if (error && error.includes("installation")) {
return (
<div className={`rounded-md border p-4 ${className}`}>
<h3 className="mb-2 text-lg font-medium">GitHub App Not Installed</h3>
<p className="mb-4 text-sm text-gray-600">
You need to install our GitHub App to generate tokens for the AI
agent.
</p>
<InstallAppButton>Install GitHub App</InstallAppButton>
</div>
);
}
if (error) {
return (
<div className={`rounded-md border p-4 ${className}`}>
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
<Button
variant="outline"
onClick={fetchToken}
disabled={isLoading}
>
Try Again
</Button>
</div>
);
}
return (
<div className={`rounded-md border p-4 ${className}`}>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-medium">GitHub Token for AI Agent</h3>
<Button
variant="outline"
size="sm"
onClick={fetchToken}
disabled={isLoading}
>
<RefreshCwIcon className="mr-2 h-4 w-4" />
Refresh Token
</Button>
</div>
{isLoading ? (
<div className="animate-pulse space-y-3">
<div className="h-4 w-3/4 rounded bg-gray-200"></div>
<div className="h-10 rounded bg-gray-200"></div>
</div>
) : token ? (
<>
<p className="mb-2 text-sm text-gray-600">
This token expires in 1 hour. Use it to authenticate your AI agent
with GitHub.
</p>
<div className="relative">
<div className="mb-2 overflow-x-auto rounded-md border bg-gray-50 p-3 font-mono text-sm whitespace-nowrap">
{token}
</div>
<Button
size="sm"
variant="ghost"
className="absolute top-2 right-2"
onClick={copyToken}
>
{copied ? (
<CheckIcon className="h-4 w-4" />
) : (
<CopyIcon className="h-4 w-4" />
)}
</Button>
</div>
<div className="mt-4 space-y-2">
<p className="text-sm font-medium">How to use this token:</p>
<div className="rounded-md border bg-gray-50 p-3 font-mono text-xs">
{`export GITHUB_TOKEN=${token}`}
</div>
<p className="text-xs text-gray-500">
Pass this token to your agent service to perform Git operations on
behalf of the user.
</p>
</div>
</>
) : (
<p className="text-gray-600">Loading token...</p>
)}
</div>
);
}

View file

@ -0,0 +1,54 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
interface InstallAppButtonProps {
variant?:
| "default"
| "outline"
| "secondary"
| "ghost"
| "link"
| "destructive";
size?: "default" | "sm" | "lg" | "icon";
className?: string;
children?: React.ReactNode;
}
export function InstallAppButton({
variant = "default",
size = "default",
className = "",
children,
}: InstallAppButtonProps) {
const [isLoading, setIsLoading] = useState(false);
const handleInstall = () => {
setIsLoading(true);
window.location.href = "/api/github/installation";
};
return (
<Button
variant={variant}
size={size}
className={className}
onClick={handleInstall}
disabled={isLoading}
>
{isLoading ? (
"Installing..."
) : (
<>
<GitHubSVG
width="16"
height="16"
/>
{children || "Install GitHub App"}
</>
)}
</Button>
);
}

View file

@ -0,0 +1,148 @@
"use client";
import { useGitHubApp } from "@/hooks/useGitHubApp";
import { InstallAppButton } from "./InstallAppButton";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
interface RepositoryListProps {
className?: string;
}
export function RepositoryList({ className = "" }: RepositoryListProps) {
const { isInstalled, isLoading, error, repositories, refreshRepositories } =
useGitHubApp();
if (isLoading) {
return (
<div className={`p-4 ${className}`}>
<div className="mb-4 h-6 w-24 animate-pulse rounded bg-gray-200"></div>
{[1, 2, 3].map((i) => (
<div
key={i}
className="mb-4 rounded-md border p-4"
>
<div className="mb-2 h-5 w-48 animate-pulse rounded bg-gray-200"></div>
<div className="h-4 w-64 animate-pulse rounded bg-gray-100"></div>
</div>
))}
</div>
);
}
if (error) {
return (
<div className={`p-4 ${className}`}>
<div className="mb-4 rounded-md border border-red-200 bg-red-50 p-4">
<p className="text-sm text-red-800">{error}</p>
</div>
<Button
variant="outline"
onClick={refreshRepositories}
>
Try Again
</Button>
</div>
);
}
if (!isInstalled) {
return (
<div className={`p-4 ${className}`}>
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 p-4">
<h3 className="mb-2 font-medium">GitHub App Not Installed</h3>
<p className="mb-4 text-sm text-amber-800">
You need to install our GitHub App to grant access to your
repositories.
</p>
<InstallAppButton>Install GitHub App</InstallAppButton>
</div>
</div>
);
}
return (
<div className={`p-4 ${className}`}>
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-medium">Your GitHub Repositories</h2>
<Button
variant="outline"
size="sm"
onClick={refreshRepositories}
disabled={isLoading}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{repositories.length === 0 ? (
<div className="rounded-md border bg-gray-50 p-4 text-center">
<p className="mb-4 text-gray-600">
No repositories found. You may need to grant access to repositories
in GitHub.
</p>
<a
href="https://github.com/settings/installations"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-blue-600 hover:underline"
>
Manage GitHub App permissions
</a>
</div>
) : (
<div className="space-y-3">
{repositories.map((repo) => (
<div
key={repo.id}
className="rounded-md border p-4 transition-colors hover:bg-gray-50"
>
<div className="flex items-start justify-between">
<div>
<h3 className="font-medium">
<a
href={repo.html_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{repo.full_name}
</a>
</h3>
<p className="mt-1 text-sm text-gray-600">
{repo.description || "No description"}
</p>
<div className="mt-2 flex items-center text-xs">
<span
className={`rounded-full px-2 py-0.5 ${repo.private ? "bg-gray-100" : "bg-green-100 text-green-800"} mr-2`}
>
{repo.private ? "Private" : "Public"}
</span>
<span className="text-gray-500">
Default branch: {repo.default_branch}
</span>
</div>
</div>
</div>
</div>
))}
</div>
)}
<div className="mt-4 border-t pt-4 text-sm text-gray-500">
<p>
Need to add or remove repositories?{" "}
<a
href="https://github.com/settings/installations"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
Manage on GitHub
</a>
</p>
</div>
</div>
);
}

View file

@ -5,6 +5,7 @@ export const GitHubSVG = ({ width = "100%", height = "100%" }) => (
width={width}
height={height}
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
>
<title>GitHub</title>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />

View file

@ -22,7 +22,6 @@ import {
SquarePen,
XIcon,
Plus,
CircleX,
} from "lucide-react";
import { useQueryState, parseAsBoolean } from "nuqs";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
@ -31,13 +30,6 @@ import { toast } from "sonner";
import { useMediaQuery } from "@/hooks/useMediaQuery";
import { Label } from "../ui/label";
import { Switch } from "../ui/switch";
import { GitHubSVG } from "../icons/github";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "../ui/tooltip";
import { useFileUpload } from "@/hooks/use-file-upload";
import { ContentBlocksPreview } from "./ContentBlocksPreview";
import {
@ -46,6 +38,7 @@ import {
ArtifactTitle,
useArtifactContext,
} from "./artifact";
import { GitHubOAuthButton } from "../github-oauth-button";
function StickyToBottomContent(props: {
content: ReactNode;
@ -88,30 +81,6 @@ function ScrollToBottom(props: { className?: string }) {
);
}
function OpenGitHubRepo() {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<a
href="https://github.com/langchain-ai/open-swe"
target="_blank"
className="flex items-center justify-center"
>
<GitHubSVG
width="24"
height="24"
/>
</a>
</TooltipTrigger>
<TooltipContent side="left">
<p>Open GitHub repo</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
export function Thread() {
const [artifactContext, setArtifactContext] = useArtifactContext();
const [artifactOpen, closeArtifact] = useArtifactOpen();
@ -337,7 +306,7 @@ export function Thread() {
)}
</div>
<div className="absolute top-2 right-4 flex items-center">
<OpenGitHubRepo />
<GitHubOAuthButton />
</div>
</div>
)}
@ -383,7 +352,7 @@ export function Thread() {
<div className="flex items-center gap-4">
<div className="flex items-center">
<OpenGitHubRepo />
<GitHubOAuthButton />
</div>
<TooltipIconButton
size="lg"

View file

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:cursor-pointer",
{
variants: {
variant: {

View file

@ -0,0 +1,61 @@
import { useState, useEffect } from "react";
import { Repository } from "@/utils/github";
interface UseGitHubAppReturn {
isInstalled: boolean | null;
isLoading: boolean;
error: string | null;
repositories: Repository[];
refreshRepositories: () => Promise<void>;
}
export function useGitHubApp(): UseGitHubAppReturn {
const [isInstalled, setIsInstalled] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [repositories, setRepositories] = useState<Repository[]>([]);
const checkInstallation = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
const data = await response.json();
setRepositories(data.repositories || []);
setIsInstalled(true);
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setIsInstalled(false);
} else {
setError(errorData.error);
setIsInstalled(false);
}
}
} catch (err) {
setError("Failed to check GitHub App installation status");
setIsInstalled(false);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
checkInstallation();
}, []);
const refreshRepositories = async () => {
await checkInstallation();
};
return {
isInstalled,
isLoading,
error,
repositories,
refreshRepositories,
};
}

View file

@ -0,0 +1,59 @@
import { useState, useCallback } from "react";
interface TokenResponse {
token: string;
installation_id: string;
}
interface UseGitHubTokenReturn {
token: string | null;
installationId: string | null;
isLoading: boolean;
error: string | null;
fetchToken: () => Promise<string | null>;
}
/**
* Hook to fetch a GitHub installation token that can be used for Git operations
* This token can be passed to the agent service to perform Git operations on behalf of the user
*/
export function useGitHubToken(): UseGitHubTokenReturn {
const [token, setToken] = useState<string | null>(null);
const [installationId, setInstallationId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const fetchToken = useCallback(async (): Promise<string | null> => {
setIsLoading(true);
setError(null);
try {
const response = await fetch("/api/github/token");
if (!response.ok) {
const errorData = await response.json();
setError(errorData.error || "Failed to fetch token");
setIsLoading(false);
return null;
}
const data: TokenResponse = await response.json();
setToken(data.token);
setInstallationId(data.installation_id);
setIsLoading(false);
return data.token;
} catch (err) {
setError("Network error when fetching token");
setIsLoading(false);
return null;
}
}, []);
return {
token,
installationId,
isLoading,
error,
fetchToken,
};
}

114
apps/web/src/lib/auth.ts Normal file
View file

@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
// Prefix the access token with `x-` so that it's included in requests to the LangGraph server.
export const GITHUB_TOKEN_COOKIE = "x-github_access_token";
export const GITHUB_TOKEN_TYPE_COOKIE = "github_token_type";
export const GITHUB_INSTALLATION_ID_COOKIE = "github_installation_id";
export const GITHUB_AUTH_STATE_COOKIE = "github_auth_state";
export const GITHUB_INSTALLATION_STATE_COOKIE = "github_installation_state";
export const GITHUB_INSTALLATION_RETURN_TO_COOKIE = "installation_return_to";
export interface GitHubTokenData {
access_token: string;
token_type: string;
installation_id?: string;
}
/**
* Cookie options for GitHub token cookies
*/
function getCookieOptions(expires?: Date) {
return {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: expires ? undefined : 60 * 60 * 24 * 30, // 30 days
expires,
path: "/",
};
}
/**
* Stores GitHub OAuth token data in secure HTTP-only cookies
*
* @param tokenData The GitHub token data to store
* @param response NextResponse to set cookies on
*/
export function storeGitHubToken(
tokenData: GitHubTokenData,
response: NextResponse,
): void {
const cookieOptions = getCookieOptions();
// Store token components in separate cookies for better security
response.cookies.set(
GITHUB_TOKEN_COOKIE,
tokenData.access_token,
cookieOptions,
);
response.cookies.set(
GITHUB_TOKEN_TYPE_COOKIE,
tokenData.token_type || "bearer",
cookieOptions,
);
// Store installation ID if provided
if (tokenData.installation_id) {
response.cookies.set(
GITHUB_INSTALLATION_ID_COOKIE,
tokenData.installation_id,
cookieOptions,
);
}
}
/**
* Retrieves GitHub OAuth token data from cookies
*
* @param request NextRequest to get cookies from
*/
export function getGitHubToken(request: NextRequest): GitHubTokenData | null {
try {
const accessToken = request.cookies.get(GITHUB_TOKEN_COOKIE)?.value;
const tokenType = request.cookies.get(GITHUB_TOKEN_TYPE_COOKIE)?.value;
const installationId = request.cookies.get(
GITHUB_INSTALLATION_ID_COOKIE,
)?.value;
if (!accessToken) {
return null;
}
return {
access_token: accessToken,
token_type: tokenType || "bearer",
installation_id: installationId,
};
} catch (error) {
console.error("Error retrieving GitHub token:", error);
return null;
}
}
/**
* Removes GitHub OAuth token data from cookies (logout)
*
* @param response NextResponse to set cookies on
*/
export function clearGitHubToken(response: NextResponse): void {
const cookieOptions = getCookieOptions(new Date(0)); // Expire immediately
response.cookies.set(GITHUB_TOKEN_COOKIE, "", cookieOptions);
response.cookies.set(GITHUB_TOKEN_TYPE_COOKIE, "", cookieOptions);
response.cookies.set(GITHUB_INSTALLATION_ID_COOKIE, "", cookieOptions);
}
/**
* Checks if user has a valid GitHub token
*
* @param request NextRequest to get cookies from
*/
export function isAuthenticated(request: NextRequest): boolean {
const token = getGitHubToken(request);
return token !== null && token.access_token.length > 0;
}

View file

@ -1,4 +1,10 @@
import React, { createContext, useContext, ReactNode, useState } from "react";
import React, {
createContext,
useContext,
ReactNode,
useState,
useEffect,
} from "react";
import { useStream } from "@langchain/langgraph-sdk/react";
import { type Message } from "@langchain/langgraph-sdk";
import {
@ -12,9 +18,13 @@ import { useQueryState } from "nuqs";
import { LangGraphLogoSVG } from "@/components/icons/langgraph";
import { useThreads } from "./Thread";
import { TooltipIconButton } from "@/components/thread/tooltip-icon-button";
import { Copy, CopyCheck } from "lucide-react";
import { Copy, CopyCheck, ArrowRight } from "lucide-react";
import { motion } from "framer-motion";
import { Button } from "@/components/ui/button";
import { GitHubSVG } from "@/components/icons/github";
import { useGitHubToken } from "@/hooks/useGitHubToken";
type TargetRepository = { owner: string; repo: string };
export type StateType = {
messages: Message[];
@ -46,17 +56,28 @@ const StreamSession = ({
children,
apiUrl,
assistantId,
githubToken,
}: {
children: ReactNode;
apiUrl: string;
assistantId: string;
githubToken: string;
}) => {
const [threadId, setThreadId] = useQueryState("threadId");
const { getThreads, setThreads } = useThreads();
const githubAccessToken =
document.cookie
.split("; ")
.find((row) => row.startsWith("x-github_access_token="))
?.split("=")[1] || "";
const streamValue = useTypedStream({
apiUrl,
assistantId,
threadId: threadId ?? null,
defaultHeaders: {
"x-github-installation-token": githubToken,
"x-github-access-token": githubAccessToken,
},
onCustomEvent: (event, options) => {
if (isUIMessage(event) || isRemoveUIMessage(event)) {
options.mutate((prev) => {
@ -90,6 +111,71 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
const assistantId: string | undefined =
process.env.NEXT_PUBLIC_ASSISTANT_ID ?? "";
const [isAuth, setIsAuth] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasGitHubAppInstalled, setHasGitHubAppInstalled] = useState<
boolean | null
>(null);
const [isCheckingAppInstallation, setIsCheckingAppInstallation] =
useState(false);
const { token: githubToken, fetchToken: fetchGitHubToken } = useGitHubToken();
useEffect(() => {
checkAuthStatus();
}, []);
useEffect(() => {
if (isAuth) {
checkGitHubAppInstallation();
}
}, [isAuth]);
const checkAuthStatus = async () => {
try {
const response = await fetch("/api/auth/status");
const data = await response.json();
setIsAuth(data.authenticated);
} catch (error) {
console.error("Error checking auth status:", error);
setIsAuth(false);
}
};
const checkGitHubAppInstallation = async () => {
setIsCheckingAppInstallation(true);
try {
const response = await fetch("/api/github/repositories");
if (response.ok) {
setHasGitHubAppInstalled(true);
// If the app is installed, fetch a token
await fetchGitHubToken();
} else {
const errorData = await response.json();
if (errorData.error.includes("installation")) {
setHasGitHubAppInstalled(false);
} else {
// If there's a different error, we'll assume the app is not installed
setHasGitHubAppInstalled(false);
}
}
} catch (error) {
console.error("Error checking GitHub App installation:", error);
setHasGitHubAppInstalled(false);
} finally {
setIsCheckingAppInstallation(false);
}
};
const handleLogin = () => {
setIsLoading(true);
window.location.href = "/api/auth/github/login";
};
const handleInstallGitHubApp = () => {
setIsLoading(true);
window.location.href = "/api/github/installation";
};
if (!apiUrl || !assistantId) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
@ -154,10 +240,91 @@ export const StreamProvider: React.FC<{ children: ReactNode }> = ({
);
}
if (!isAuth) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
<div className="flex flex-col gap-4 border-b p-6">
<div className="flex flex-col items-start gap-2">
<LangGraphLogoSVG className="h-7" />
<h1 className="text-xl font-semibold tracking-tight">
Get started
</h1>
</div>
<p className="text-muted-foreground">
Connect your GitHub account to get started with Open SWE.
</p>
<Button
onClick={handleLogin}
disabled={isLoading}
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading ? "Connecting..." : "Connect GitHub"}
</Button>
</div>
</div>
</div>
);
}
// Step 2: GitHub App Installation (only show if authenticated but app not installed)
if ((isAuth && hasGitHubAppInstalled === false) || !githubToken) {
return (
<div className="flex min-h-screen w-full items-center justify-center p-4">
<div className="animate-in fade-in-0 zoom-in-95 flex w-full max-w-3xl flex-col rounded-lg border shadow-lg">
<div className="flex flex-col gap-4 border-b p-6">
<div className="flex flex-col items-start gap-2">
<LangGraphLogoSVG className="h-7" />
<h1 className="text-xl font-semibold tracking-tight">
One more step
</h1>
</div>
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
1. GitHub Login ✓
</span>
<ArrowRight className="h-3 w-3" />
<span className="rounded-full bg-blue-100 px-2 py-1 text-xs font-medium text-blue-800">
2. Repository Access
</span>
</div>
<p className="text-muted-foreground">
Great! Now we need access to your GitHub repositories. Install our
GitHub App to grant access to specific repositories.
</p>
<div className="rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
<p>
You'll be redirected to GitHub where you can select which
repositories to grant access to.
</p>
</div>
<Button
onClick={handleInstallGitHubApp}
disabled={isLoading || isCheckingAppInstallation}
className="bg-black hover:bg-gray-800"
>
<GitHubSVG
width="16"
height="16"
/>
{isLoading || isCheckingAppInstallation
? "Loading..."
: "Install GitHub App"}
</Button>
</div>
</div>
</div>
);
}
return (
<StreamSession
apiUrl={apiUrl}
assistantId={assistantId}
githubToken={githubToken}
>
{children}
</StreamSession>

View file

@ -0,0 +1,97 @@
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" });
}
/**
* 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": "YourAppName",
},
},
);
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
*/
export async function getInstallationRepositories(
installationToken: string,
): Promise<Repository[]> {
const response = await fetch(
"https://api.github.com/installation/repositories",
{
headers: {
Authorization: `Bearer ${installationToken}`,
Accept: "application/vnd.github.v3+json",
"User-Agent": "YourAppName",
},
},
);
if (!response.ok) {
const errorData = await response.json();
throw new Error(
`Failed to fetch repositories: ${JSON.stringify(errorData)}`,
);
}
const data = await response.json();
return data.repositories;
}
/**
* Repository interface representing GitHub repository data
*/
export interface Repository {
id: number;
name: string;
full_name: string;
description: string | null;
private: boolean;
html_url: string;
default_branch: string;
permissions: {
admin: boolean;
maintain: boolean;
push: boolean;
triage: boolean;
pull: boolean;
};
}

118
yarn.lock
View file

@ -1958,6 +1958,7 @@ __metadata:
"@radix-ui/react-switch": ^1.1.3
"@radix-ui/react-tooltip": ^1.1.8
"@tailwindcss/postcss": ^4.0.13
"@types/jsonwebtoken": ^9.0.9
"@types/lodash": ^4.17.16
"@types/node": ^22.13.5
"@types/react": ^19.0.8
@ -1978,6 +1979,7 @@ __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.0.4
lodash: ^4.17.21
@ -3288,6 +3290,16 @@ __metadata:
languageName: node
linkType: hard
"@types/jsonwebtoken@npm:^9.0.9":
version: 9.0.9
resolution: "@types/jsonwebtoken@npm:9.0.9"
dependencies:
"@types/ms": "*"
"@types/node": "*"
checksum: 9d564fc09fc83f66e319754dee0b039ae23f28fc38a9227c35283e69311ddf5a7493a060c7f956df8cd856416747c515b51fec20b8656865eacebf23b3c6bc62
languageName: node
linkType: hard
"@types/katex@npm:^0.16.0":
version: 0.16.7
resolution: "@types/katex@npm:0.16.7"
@ -4364,6 +4376,13 @@ __metadata:
languageName: node
linkType: hard
"buffer-equal-constant-time@npm:^1.0.1":
version: 1.0.1
resolution: "buffer-equal-constant-time@npm:1.0.1"
checksum: 80bb945f5d782a56f374b292770901065bad21420e34936ecbe949e57724b4a13874f735850dd1cc61f078773c4fb5493a41391e7bda40d1fa388d6bd80daaab
languageName: node
linkType: hard
"buffer-from@npm:^1.0.0":
version: 1.1.2
resolution: "buffer-from@npm:1.1.2"
@ -5218,6 +5237,15 @@ __metadata:
languageName: node
linkType: hard
"ecdsa-sig-formatter@npm:1.0.11":
version: 1.0.11
resolution: "ecdsa-sig-formatter@npm:1.0.11"
dependencies:
safe-buffer: ^5.0.1
checksum: 207f9ab1c2669b8e65540bce29506134613dd5f122cccf1e6a560f4d63f2732d427d938f8481df175505aad94583bcb32c688737bb39a6df0625f903d6d93c03
languageName: node
linkType: hard
"eciesjs@npm:^0.4.10":
version: 0.4.15
resolution: "eciesjs@npm:0.4.15"
@ -8181,6 +8209,24 @@ __metadata:
languageName: node
linkType: hard
"jsonwebtoken@npm:^9.0.2":
version: 9.0.2
resolution: "jsonwebtoken@npm:9.0.2"
dependencies:
jws: ^3.2.2
lodash.includes: ^4.3.0
lodash.isboolean: ^3.0.3
lodash.isinteger: ^4.0.4
lodash.isnumber: ^3.0.3
lodash.isplainobject: ^4.0.6
lodash.isstring: ^4.0.1
lodash.once: ^4.0.0
ms: ^2.1.1
semver: ^7.5.4
checksum: fc739a6a8b33f1974f9772dca7f8493ca8df4cc31c5a09dcfdb7cff77447dcf22f4236fb2774ef3fe50df0abeb8e1c6f4c41eba82f500a804ab101e2fbc9d61a
languageName: node
linkType: hard
"jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.3.5":
version: 3.3.5
resolution: "jsx-ast-utils@npm:3.3.5"
@ -8193,6 +8239,27 @@ __metadata:
languageName: node
linkType: hard
"jwa@npm:^1.4.1":
version: 1.4.2
resolution: "jwa@npm:1.4.2"
dependencies:
buffer-equal-constant-time: ^1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: ^5.0.1
checksum: fd1a6de6c649a4b16f0775439ac9173e4bc9aa0162c7f3836699af47736ae000fafe89f232a2345170de6c14021029cb94b488f7882c6caf61e6afef5fce6494
languageName: node
linkType: hard
"jws@npm:^3.2.2":
version: 3.2.2
resolution: "jws@npm:3.2.2"
dependencies:
jwa: ^1.4.1
safe-buffer: ^5.0.1
checksum: f0213fe5b79344c56cd443428d8f65c16bf842dc8cb8f5aed693e1e91d79c20741663ad6eff07a6d2c433d1831acc9814e8d7bada6a0471fbb91d09ceb2bf5c2
languageName: node
linkType: hard
"katex@npm:^0.16.0, katex@npm:^0.16.21":
version: 0.16.22
resolution: "katex@npm:0.16.22"
@ -8531,6 +8598,48 @@ __metadata:
languageName: node
linkType: hard
"lodash.includes@npm:^4.3.0":
version: 4.3.0
resolution: "lodash.includes@npm:4.3.0"
checksum: 71092c130515a67ab3bd928f57f6018434797c94def7f46aafa417771e455ce3a4834889f4267b17887d7f75297dfabd96231bf704fd2b8c5096dc4a913568b6
languageName: node
linkType: hard
"lodash.isboolean@npm:^3.0.3":
version: 3.0.3
resolution: "lodash.isboolean@npm:3.0.3"
checksum: b70068b4a8b8837912b54052557b21fc4774174e3512ed3c5b94621e5aff5eb6c68089d0a386b7e801d679cd105d2e35417978a5e99071750aa2ed90bffd0250
languageName: node
linkType: hard
"lodash.isinteger@npm:^4.0.4":
version: 4.0.4
resolution: "lodash.isinteger@npm:4.0.4"
checksum: 6034821b3fc61a2ffc34e7d5644bb50c5fd8f1c0121c554c21ac271911ee0c0502274852845005f8651d51e199ee2e0cfebfe40aaa49c7fe617f603a8a0b1691
languageName: node
linkType: hard
"lodash.isnumber@npm:^3.0.3":
version: 3.0.3
resolution: "lodash.isnumber@npm:3.0.3"
checksum: 913784275b565346255e6ae6a6e30b760a0da70abc29f3e1f409081585875105138cda4a429ff02577e1bc0a7ae2a90e0a3079a37f3a04c3d6c5aaa532f4cab2
languageName: node
linkType: hard
"lodash.isplainobject@npm:^4.0.6":
version: 4.0.6
resolution: "lodash.isplainobject@npm:4.0.6"
checksum: 29c6351f281e0d9a1d58f1a4c8f4400924b4c79f18dfc4613624d7d54784df07efaff97c1ff2659f3e085ecf4fff493300adc4837553104cef2634110b0d5337
languageName: node
linkType: hard
"lodash.isstring@npm:^4.0.1":
version: 4.0.1
resolution: "lodash.isstring@npm:4.0.1"
checksum: eaac87ae9636848af08021083d796e2eea3d02e80082ab8a9955309569cb3a463ce97fd281d7dc119e402b2e7d8c54a23914b15d2fc7fff56461511dc8937ba0
languageName: node
linkType: hard
"lodash.memoize@npm:^4.1.2":
version: 4.1.2
resolution: "lodash.memoize@npm:4.1.2"
@ -8545,6 +8654,13 @@ __metadata:
languageName: node
linkType: hard
"lodash.once@npm:^4.0.0":
version: 4.1.1
resolution: "lodash.once@npm:4.1.1"
checksum: d768fa9f9b4e1dc6453be99b753906f58990e0c45e7b2ca5a3b40a33111e5d17f6edf2f768786e2716af90a8e78f8f91431ab8435f761fef00f9b0c256f6d245
languageName: node
linkType: hard
"lodash@npm:^4.17.21":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
@ -10917,7 +11033,7 @@ __metadata:
languageName: node
linkType: hard
"safe-buffer@npm:~5.2.0":
"safe-buffer@npm:^5.0.1, safe-buffer@npm:~5.2.0":
version: 5.2.1
resolution: "safe-buffer@npm:5.2.1"
checksum: b99c4b41fdd67a6aaf280fcd05e9ffb0813654894223afb78a31f14a19ad220bba8aba1cb14eddce1fcfb037155fe6de4e861784eb434f7d11ed58d1e70dd491