mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 18:33:15 +00:00
feat: Add custom bearer token auth (#723)
* fear: Add custom bearer token auth * cr * fix sec issues * cr
This commit is contained in:
parent
daa042add2
commit
210c848997
7 changed files with 177 additions and 11 deletions
|
|
@ -35,6 +35,47 @@ The Next.js application includes a proxy route (`apps/web/src/app/api/[..._path]
|
|||
with the LangGraph server.
|
||||
</Tip>
|
||||
|
||||
## 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 <token>` 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=<your-generated-token>
|
||||
```
|
||||
|
||||
- For multiple/rotation: use comma-separated tokens
|
||||
|
||||
```bash
|
||||
API_BEARER_TOKENS=<token1>,<token2>,<token3>
|
||||
```
|
||||
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -18,19 +18,19 @@ interface TraceUrls {
|
|||
async function getTraceUrls(managerThreadId: string): Promise<TraceUrls> {
|
||||
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<TraceUrls> {
|
|||
|
||||
const client = new Client({
|
||||
apiUrl: apiUrl!,
|
||||
apiKey: apiKey!,
|
||||
defaultHeaders: {
|
||||
"x-auth-scheme": "langsmith",
|
||||
authorization: `Bearer ${apiBearerToken!}`,
|
||||
},
|
||||
});
|
||||
const constructUrl = (runId: string) =>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
63
apps/open-swe/src/security/custom.ts
Normal file
63
apps/open-swe/src/security/custom.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
42
yarn.lock
42
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue