feat: Add custom bearer token auth (#723)

* fear: Add custom bearer token auth

* cr

* fix sec issues

* cr
This commit is contained in:
Brace Sproul 2025-08-08 17:34:56 -07:00 • committed by GitHub
parent daa042add2
commit 210c848997
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 177 additions and 11 deletions

View file

@ -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:

View file

@ -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",

View file

@ -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) =>

View file

@ -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) {

View 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;
}

View file

@ -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 {

View file

@ -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"