diff --git a/apps/docs/setup/authentication.mdx b/apps/docs/setup/authentication.mdx index c4353966..891ba47b 100644 --- a/apps/docs/setup/authentication.mdx +++ b/apps/docs/setup/authentication.mdx @@ -35,6 +35,47 @@ The Next.js application includes a proxy route (`apps/web/src/app/api/[..._path] with the LangGraph server. +## Simple API Key (Bearer) Authentication + +For local development and scripted access, the LangGraph server also supports a simple bearer token scheme. When a request includes an `Authorization: Bearer ` header, this path is used and the rest of the auth flow is skipped. + +### Setup (development) + +- **Generate a token** (32+ bytes, URL-safe): + - OpenSSL: `openssl rand -hex 32` +- **Add to your `.env` for the LangGraph server**: + +```bash +API_BEARER_TOKEN= +``` + +- For multiple/rotation: use comma-separated tokens + +```bash +API_BEARER_TOKENS=,, +``` + +Restart the LangGraph server after updating environment variables. + +### Usage + +```typescript +import { Client } from "@langchain/langgraph-sdk"; + +const client = new Client({ + apiUrl: process.env.LANGGRAPH_API_URL, + defaultHeaders: { + authorization: `Bearer ${process.env.API_BEARER_TOKEN}`, + }, +}); +``` + +### Notes + +- **Precedence**: If the `Authorization` header is present, bearer auth is used; otherwise the existing GitHub-based flow applies. +- **Rotation**: Add a new token to `API_BEARER_TOKENS`, deploy/restart, migrate clients, then remove old tokens and redeploy. +- **Scope**: All bearer tokens currently map to the same internal identity and permissions. If you need per-token identities/quotas, reach out to adjust the configuration. + ### Header Injection System The proxy route automatically injects the following encrypted headers into each request: diff --git a/apps/open-swe/package.json b/apps/open-swe/package.json index a4341b2b..a2ae1209 100644 --- a/apps/open-swe/package.json +++ b/apps/open-swe/package.json @@ -39,6 +39,7 @@ "@octokit/rest": "^22.0.0", "@octokit/webhooks": "^14.0.2", "@open-swe/shared": "*", + "bcrypt": "^6.0.0", "diff": "^8.0.1", "hono": "^4.8.3", "jsonwebtoken": "^9.0.2", @@ -53,11 +54,12 @@ "@jest/globals": "^29.7.0", "@langchain/langgraph-cli": "^0.0.47", "@tsconfig/recommended": "^1.0.8", + "@types/bcrypt": "^6.0.0", "@types/commander": "^2.12.5", - "commander": "^14.0.0", "@types/jest": "^29.5.0", "@types/jsonwebtoken": "^9.0.10", "@types/node": "^22.13.5", + "commander": "^14.0.0", "dotenv": "^16.4.7", "eslint": "^9.19.0", "eslint-config-prettier": "^8.8.0", diff --git a/apps/open-swe/scripts/get-trace-urls.ts b/apps/open-swe/scripts/get-trace-urls.ts index 482d2919..68d24d3a 100644 --- a/apps/open-swe/scripts/get-trace-urls.ts +++ b/apps/open-swe/scripts/get-trace-urls.ts @@ -18,19 +18,19 @@ interface TraceUrls { async function getTraceUrls(managerThreadId: string): Promise { const { LANGGRAPH_API_URL: apiUrl, - LANGCHAIN_API_KEY: apiKey, LANGSMITH_WORKSPACE_ID: orgId, LANGSMITH_PROJECT_ID: projectId, + API_BEARER_TOKEN: apiBearerToken, } = process.env; - const missing = [apiUrl, apiKey, orgId, projectId] + const missing = [apiUrl, orgId, projectId, apiBearerToken] .map((val, i) => !val ? [ "LANGGRAPH_API_URL", - "LANGCHAIN_API_KEY", "LANGSMITH_WORKSPACE_ID", "LANGSMITH_PROJECT_ID", + "API_BEARER_TOKEN", ][i] : null, ) @@ -44,9 +44,8 @@ async function getTraceUrls(managerThreadId: string): Promise { const client = new Client({ apiUrl: apiUrl!, - apiKey: apiKey!, defaultHeaders: { - "x-auth-scheme": "langsmith", + authorization: `Bearer ${apiBearerToken!}`, }, }); const constructUrl = (runId: string) => diff --git a/apps/open-swe/src/security/auth.ts b/apps/open-swe/src/security/auth.ts index 2e7ba40a..f9a4c578 100644 --- a/apps/open-swe/src/security/auth.ts +++ b/apps/open-swe/src/security/auth.ts @@ -18,6 +18,7 @@ import { verifyGitHubWebhookOrThrow } from "./github.js"; import { createWithOwnerMetadata, createOwnerFilter } from "./utils.js"; import { LANGGRAPH_USER_PERMISSIONS } from "../constants.js"; import { getGitHubPatFromRequest } from "../utils/github-pat.js"; +import { validateApiBearerToken } from "./custom.js"; // TODO: Export from LangGraph SDK export interface BaseAuthReturn { @@ -64,10 +65,22 @@ export const auth = new Auth() }; } - const ghSecretHashHeader = request.headers.get("X-Hub-Signature-256"); - if (ghSecretHashHeader) { - // This will either return a valid user, or throw an error - return await verifyGitHubWebhookOrThrow(request); + // Bearer token auth (simple API key) — only when header is present + const authorizationHeader = request.headers.get("authorization"); + if ( + authorizationHeader && + authorizationHeader.toLowerCase().startsWith("bearer ") + ) { + const token = authorizationHeader.slice(7).trim(); + if (!token) { + throw new HTTPException(401, { message: "Missing bearer token" }); + } + + const user = validateApiBearerToken(token); + if (user) { + return user; + } + throw new HTTPException(401, { message: "Invalid API token" }); } const encryptionKey = process.env.SECRETS_ENCRYPTION_KEY; @@ -75,6 +88,12 @@ export const auth = new Auth() throw new Error("Missing SECRETS_ENCRYPTION_KEY environment variable."); } + const ghSecretHashHeader = request.headers.get("X-Hub-Signature-256"); + if (ghSecretHashHeader) { + // This will either return a valid user, or throw an error + return await verifyGitHubWebhookOrThrow(request); + } + // Check for GitHub PAT authentication (simpler mode for evals, etc.) const githubPat = getGitHubPatFromRequest(request, encryptionKey); if (githubPat && !isProd) { diff --git a/apps/open-swe/src/security/custom.ts b/apps/open-swe/src/security/custom.ts new file mode 100644 index 00000000..58a59488 --- /dev/null +++ b/apps/open-swe/src/security/custom.ts @@ -0,0 +1,63 @@ +import { STUDIO_USER_ID } from "./utils.js"; +import { LANGGRAPH_USER_PERMISSIONS } from "../constants.js"; +import * as bcrypt from "bcrypt"; + +function bcryptHash(value: string): string { + // Use 12 salt rounds for reasonable security + return bcrypt.hashSync(value, 12); +} + +function getConfiguredApiTokens(): string[] { + const single = process.env.API_BEARER_TOKEN || ""; + const many = process.env.API_BEARER_TOKENS || ""; // comma-separated + + const tokens: string[] = []; + + if (single.trim()) { + tokens.push(single.trim()); + } + + if (many.trim()) { + for (const t of many.split(",")) { + const v = t.trim(); + if (v) tokens.push(v); + } + } + + return tokens; +} + +// Pre-hash configured tokens for constant length comparisons +let cachedAllowedTokenHashes: string[] | null = null; +function getAllowedTokenHashes(): string[] { + if (cachedAllowedTokenHashes) { + return cachedAllowedTokenHashes; + } + + const tokens = getConfiguredApiTokens(); + cachedAllowedTokenHashes = tokens.map((t) => bcryptHash(t)); + return cachedAllowedTokenHashes; +} + +export function validateApiBearerToken(token: string) { + const allowed = getAllowedTokenHashes(); + if (allowed.length === 0) { + // Not configured; treat as invalid + return null; + } + + // Compare the token against each allowed hash using bcrypt + const isValid = allowed.some((h) => bcrypt.compareSync(token, h)); + if (isValid) { + return { + identity: STUDIO_USER_ID, + is_authenticated: true, + display_name: STUDIO_USER_ID, + metadata: { + installation_name: "api-key-auth", + }, + permissions: LANGGRAPH_USER_PERMISSIONS, + }; + } + return null; +} diff --git a/apps/open-swe/src/security/utils.ts b/apps/open-swe/src/security/utils.ts index 3f02b4c4..f1966ec3 100644 --- a/apps/open-swe/src/security/utils.ts +++ b/apps/open-swe/src/security/utils.ts @@ -1,4 +1,4 @@ -const STUDIO_USER_ID = "langgraph-studio-user"; +export const STUDIO_USER_ID = "langgraph-studio-user"; // Helper function to check if user is studio user export function isStudioUser(userIdentity: string): boolean { diff --git a/yarn.lock b/yarn.lock index af88930e..01766633 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4112,10 +4112,12 @@ __metadata: "@octokit/webhooks": ^14.0.2 "@open-swe/shared": "*" "@tsconfig/recommended": ^1.0.8 + "@types/bcrypt": ^6.0.0 "@types/commander": ^2.12.5 "@types/jest": ^29.5.0 "@types/jsonwebtoken": ^9.0.10 "@types/node": ^22.13.5 + bcrypt: ^6.0.0 commander: ^14.0.0 diff: ^8.0.1 dotenv: ^16.4.7 @@ -6697,6 +6699,15 @@ __metadata: languageName: node linkType: hard +"@types/bcrypt@npm:^6.0.0": + version: 6.0.0 + resolution: "@types/bcrypt@npm:6.0.0" + dependencies: + "@types/node": "*" + checksum: 628844016504c0d36047ed6c8e97385423c7b6ec6c208569ade8f6f2c685489a5134a38ab61d2ead2c6059983c8804e802d48155d56995805962004ff65c3b6b + languageName: node + linkType: hard + "@types/chai@npm:^5.2.2": version: 5.2.2 resolution: "@types/chai@npm:5.2.2" @@ -8327,6 +8338,17 @@ __metadata: languageName: node linkType: hard +"bcrypt@npm:^6.0.0": + version: 6.0.0 + resolution: "bcrypt@npm:6.0.0" + dependencies: + node-addon-api: ^8.3.0 + node-gyp: latest + node-gyp-build: ^4.8.4 + checksum: 004e1920fab7a7183b15467257f3010b45f5db73ad91448fb108c9070dfe56c4e35cb16e12987dd88b9e03b997f08a6070494802cb5fd2a90e1664279ab8aef5 + languageName: node + linkType: hard + "before-after-hook@npm:^4.0.0": version: 4.0.0 resolution: "before-after-hook@npm:4.0.0" @@ -15813,6 +15835,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^8.3.0": + version: 8.5.0 + resolution: "node-addon-api@npm:8.5.0" + dependencies: + node-gyp: latest + checksum: 4d05f2ec4f62234232fbb8e0318d796c1968833f115c9c566a4d80c19684d1a10c06cb5f185512284f7dda028a61dbf01eac38993d49259ba3f182ce63d89411 + languageName: node + linkType: hard + "node-domexception@npm:^1.0.0": version: 1.0.0 resolution: "node-domexception@npm:1.0.0" @@ -15859,6 +15890,17 @@ __metadata: languageName: node linkType: hard +"node-gyp-build@npm:^4.8.4": + version: 4.8.4 + resolution: "node-gyp-build@npm:4.8.4" + bin: + node-gyp-build: bin.js + node-gyp-build-optional: optional.js + node-gyp-build-test: build-test.js + checksum: 8b81ca8ffd5fa257ad8d067896d07908a36918bc84fb04647af09d92f58310def2d2b8614d8606d129d9cd9b48890a5d2bec18abe7fcff54818f72bedd3a7d74 + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 11.2.0 resolution: "node-gyp@npm:11.2.0"