🔐 feat: Mint Code API Auth Tokens (#13028)

* feat: Mint CodeAPI auth tokens

* style: Format CodeAPI download route

* fix: Prune CodeAPI token cache

* fix: Propagate CodeAPI managed auth

* test: Mock CodeAPI auth in traversal suite

* fix: Pass auth context to invoked skill cache

* feat: Mint CodeAPI plan context

* chore: Refresh CodeAPI auth guidance

* fix: Guard OpenID JWT fallback

* fix: Default CodeAPI JWT tenant in single-tenant mode

* chore: Update @librechat/agents to version 3.1.84 in package-lock.json and package.json files

* chore: Standardize references to Code API in comments and tests
This commit is contained in:
Danny Avila 2026-05-09 16:09:10 -04:00 committed by GitHub
parent 8a654dc8b1
commit c67e2b54dc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 973 additions and 58 deletions

View file

@ -98,7 +98,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.1.83",
"@librechat/agents": "^3.1.84",
"@librechat/data-schemas": "*",
"@modelcontextprotocol/sdk": "^1.29.0",
"@smithy/node-http-handler": "^4.4.5",

View file

@ -104,7 +104,7 @@ export interface ToolExecuteOptions {
files: Array<{ fileId: string; filename: string }>;
}>;
/** Checks if a code env file is still active. Returns lastModified or null. */
getSessionInfo?: (ref: CodeEnvRef) => Promise<string | null>;
getSessionInfo?: (ref: CodeEnvRef, req?: ServerRequest) => Promise<string | null>;
/** 23-hour freshness check */
checkIfActive?: (dateString: string) => boolean;
/** Persists `codeEnvRef` on skill files after upload */
@ -147,6 +147,7 @@ export interface ToolExecuteOptions {
file_path: string;
session_id?: string;
files?: Array<{ id: string; name: string; session_id?: string }>;
req?: ServerRequest;
}) => Promise<{ content: string } | null>;
}
@ -332,6 +333,7 @@ async function handleSandboxFileFallback(
tc: ToolCallRequest,
filePath: string,
options: ToolExecuteOptions,
req?: ServerRequest,
): Promise<ToolExecuteResult> {
const ext = lowercaseExtension(filePath);
if (BINARY_EXTENSIONS_NEVER_READABLE.has(ext)) {
@ -361,6 +363,7 @@ async function handleSandboxFileFallback(
file_path: filePath,
session_id: ctx?.session_id,
files: ctx?.files,
...(req ? { req } : {}),
});
if (!result || result.content == null) {
return {
@ -450,7 +453,7 @@ async function handleReadFileCall(
*/
if (args.file_path.startsWith('/mnt/data/')) {
if (codeEnvAvailable) {
return handleSandboxFileFallback(tc, args.file_path, options);
return handleSandboxFileFallback(tc, args.file_path, options, req);
}
return {
toolCallId: tc.id,
@ -463,7 +466,7 @@ async function handleReadFileCall(
const slashIdx = args.file_path.indexOf('/');
if (slashIdx < 1) {
if (codeEnvAvailable) {
return handleSandboxFileFallback(tc, args.file_path, options);
return handleSandboxFileFallback(tc, args.file_path, options, req);
}
return {
toolCallId: tc.id,
@ -483,7 +486,7 @@ async function handleReadFileCall(
* dead-ending with a skill-centric error message.
*/
if (codeEnvAvailable) {
return handleSandboxFileFallback(tc, args.file_path, options);
return handleSandboxFileFallback(tc, args.file_path, options, req);
}
return {
toolCallId: tc.id,
@ -502,7 +505,7 @@ async function handleReadFileCall(
*/
if (!skillsEffectivelyEnabled) {
if (codeEnvAvailable) {
return handleSandboxFileFallback(tc, args.file_path, options);
return handleSandboxFileFallback(tc, args.file_path, options, req);
}
return {
toolCallId: tc.id,
@ -544,7 +547,7 @@ async function handleReadFileCall(
const activeSkillNames = mergedConfigurable?.activeSkillNames as Set<string> | undefined;
if (activeSkillNames && !activeSkillNames.has(skillName) && !isPrimedThisTurn) {
if (codeEnvAvailable) {
return handleSandboxFileFallback(tc, args.file_path, options);
return handleSandboxFileFallback(tc, args.file_path, options, req);
}
return {
toolCallId: tc.id,

View file

@ -274,17 +274,28 @@ describe('primeInvokedSkills — execute_code capability gate', () => {
},
]);
const batchUploadCodeEnvFiles = jest.fn();
const getSessionInfo = jest.fn().mockResolvedValue('2026-05-06T00:00:00Z');
const deps = makeDeps({
codeEnvAvailable: true,
listSkillFiles,
batchUploadCodeEnvFiles,
getSessionInfo: jest.fn().mockResolvedValue('2026-05-06T00:00:00Z'),
getSessionInfo,
checkIfActive: jest.fn().mockReturnValue(true),
});
const result = await primeInvokedSkills(deps);
expect(batchUploadCodeEnvFiles).not.toHaveBeenCalled();
expect(getSessionInfo).toHaveBeenCalledWith(
{
kind: 'skill',
id: SKILL_ID.toString(),
storage_session_id: 'session-cached',
file_id: 'file-cached',
version: SKILL_VERSION,
},
deps.req,
);
const codeSession = result.initialSessions?.get('execute_code');
expect(codeSession?.files).toEqual([
{

View file

@ -51,7 +51,7 @@ export interface PrimeSkillFilesParams {
files: Array<{ fileId: string; filename: string }>;
}>;
/** Checks if a code env file is still active. Returns lastModified timestamp or null. */
getSessionInfo?: (ref: CodeEnvRef) => Promise<string | null>;
getSessionInfo?: (ref: CodeEnvRef, req?: ServerRequest) => Promise<string | null>;
/** 23-hour freshness check */
checkIfActive?: (dateString: string) => boolean;
/** Persists `codeEnvRef` on skill files after upload. Implementations
@ -128,7 +128,7 @@ export async function primeSkillFiles(
try {
const checkResults = await Promise.all(
Array.from(refsBySession.values()).map(async (ref) => {
const lastModified = await getSessionInfo(ref);
const lastModified = await getSessionInfo(ref, req);
return !!(lastModified && checkIfActive(lastModified));
}),
);
@ -421,7 +421,7 @@ export async function primeInvokedSkills(
const checkResults = await Promise.all(
Array.from(refsBySession.values()).map(async (ref) => {
try {
const lastModified = await deps.getSessionInfo?.(ref);
const lastModified = await deps.getSessionInfo?.(ref, deps.req);
return !!(lastModified && deps.checkIfActive?.(lastModified));
} catch {
return false;
@ -470,7 +470,7 @@ export async function primeInvokedSkills(
// Per-skill upload: each skill gets its own storage session keyed
// by `(kind: 'skill', id: skillId, version: skill.version)`.
// primeSkillFiles handles freshness caching per-skill, so only
// expired skills re-upload. CodeAPI handles mixed
// expired skills re-upload. Code API handles mixed
// storage_session_ids natively.
const allPrimedFiles: Array<{
id: string;

View file

@ -0,0 +1,281 @@
import { createHash, generateKeyPairSync, verify as cryptoVerify } from 'crypto';
import type { KeyObject } from 'crypto';
import type { ServerRequest } from '~/types';
import { getCodeApiAuthHeaders, mintCodeApiToken } from './codeapi';
jest.mock(
'@librechat/data-schemas',
() => ({
getTenantId: jest.fn(),
}),
{ virtual: true },
);
jest.mock('~/utils', () => ({
isEnabled: (value?: string) => value === 'true' || value === '1',
}));
const mockGetTenantId = jest.requireMock('@librechat/data-schemas').getTenantId as jest.Mock;
const ENV_KEYS = [
'CODEAPI_AUTH_PROVIDER',
'CODEAPI_JWT_ENABLED',
'CODEAPI_JWT_PRIVATE_KEY',
'CODEAPI_JWT_PRIVATE_KEY_BASE64',
'CODEAPI_JWT_PRIVATE_JWK_JSON',
'CODEAPI_JWT_ALGORITHM',
'CODEAPI_JWT_KID',
'CODEAPI_JWT_KEY_ID',
'CODEAPI_JWT_ISSUER',
'CODEAPI_JWT_AUDIENCE',
'CODEAPI_JWT_TTL_SECONDS',
'CODEAPI_JWT_MINT_CACHE_SECONDS',
'CODEAPI_JWT_SINGLE_TENANT_ID',
'TENANT_ISOLATION_STRICT',
'OPENID_REUSE_TOKENS',
] as const;
type Claims = Record<string, unknown>;
function baseRequest(overrides: Record<string, unknown> = {}): ServerRequest {
return {
user: {
_id: { toString: () => 'user_123' },
tenantId: 'tenant_abc',
role: 'USER',
provider: 'local',
...overrides,
},
body: { tenant_id: 'spoofed_tenant' },
headers: { 'x-tenant-id': 'spoofed_header_tenant' },
} as unknown as ServerRequest;
}
function decodeToken(token: string): {
header: Claims;
claims: Claims;
signingInput: string;
signature: Buffer;
} {
const [header, payload, signature] = token.split('.') as [string, string, string];
return {
header: JSON.parse(Buffer.from(header, 'base64url').toString('utf8')) as Claims,
claims: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Claims,
signingInput: `${header}.${payload}`,
signature: Buffer.from(signature, 'base64url'),
};
}
function expectedContextHash(input: {
userId: string;
tenantId: string;
role: string;
principalSource: string;
orgId?: string;
serviceId?: string;
chcUserId?: string;
}): string {
return createHash('sha256')
.update(
JSON.stringify({
chc_user_id: input.chcUserId ?? '',
org_id: input.orgId ?? '',
principal_source: input.principalSource,
role: input.role,
service_id: input.serviceId ?? '',
sub: input.userId,
tenant_id: input.tenantId,
}),
)
.digest('hex');
}
describe('Code API JWT minting', () => {
const originalEnv = new Map<string, string | undefined>();
let publicKey: KeyObject;
beforeAll(() => {
for (const key of ENV_KEYS) {
originalEnv.set(key, process.env[key]);
}
});
beforeEach(() => {
const keyPair = generateKeyPairSync('ed25519');
publicKey = keyPair.publicKey;
process.env.CODEAPI_AUTH_PROVIDER = 'librechat-jwt';
process.env.CODEAPI_JWT_PRIVATE_JWK_JSON = JSON.stringify(
keyPair.privateKey.export({ format: 'jwk' }),
);
process.env.CODEAPI_JWT_ALGORITHM = 'EdDSA';
process.env.CODEAPI_JWT_KID = 'test-kid';
process.env.CODEAPI_JWT_ISSUER = 'librechat';
process.env.CODEAPI_JWT_AUDIENCE = 'codeapi';
process.env.CODEAPI_JWT_TTL_SECONDS = '300';
process.env.CODEAPI_JWT_MINT_CACHE_SECONDS = '30';
delete process.env.CODEAPI_JWT_PRIVATE_KEY;
delete process.env.CODEAPI_JWT_PRIVATE_KEY_BASE64;
delete process.env.CODEAPI_JWT_SINGLE_TENANT_ID;
delete process.env.TENANT_ISOLATION_STRICT;
delete process.env.OPENID_REUSE_TOKENS;
mockGetTenantId.mockReset();
});
afterAll(() => {
for (const key of ENV_KEYS) {
const value = originalEnv.get(key);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
it('mints a Code API-scoped token from canonical LibreChat JWT context', async () => {
jest.spyOn(Date, 'now').mockReturnValue(1_778_250_000_000);
const token = await mintCodeApiToken(baseRequest());
const decoded = decodeToken(token);
expect(decoded.header).toEqual({
alg: 'EdDSA',
typ: 'JWT',
kid: 'test-kid',
});
expect(
cryptoVerify(null, Buffer.from(decoded.signingInput), publicKey, decoded.signature),
).toBe(true);
expect(decoded.claims).toMatchObject({
iss: 'librechat',
aud: 'codeapi',
sub: 'user_123',
iat: 1_778_250_000,
nbf: 1_778_250_000,
exp: 1_778_250_300,
tenant_id: 'tenant_abc',
role: 'USER',
principal_source: 'librechat_jwt',
});
expect(decoded.claims.auth_context_hash).toBe(
expectedContextHash({
userId: 'user_123',
tenantId: 'tenant_abc',
role: 'USER',
principalSource: 'librechat_jwt',
}),
);
expect(decoded.claims).not.toHaveProperty('refresh_token');
expect(decoded.claims).not.toHaveProperty('openid_token');
});
it('marks OpenID reuse callers without forwarding upstream credentials', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
const req = baseRequest({
provider: 'openid',
idOnTheSource: 'chc_user_123',
refreshToken: 'do-not-forward',
accessToken: 'do-not-forward',
});
req.authStrategy = 'openidJwt';
const token = await mintCodeApiToken(req);
const { claims } = decodeToken(token);
expect(claims).toMatchObject({
principal_source: 'openid_reuse',
chc_user_id: 'chc_user_123',
});
expect(JSON.stringify(claims)).not.toContain('do-not-forward');
});
it('marks OpenID users authenticated by LibreChat JWT as LibreChat JWT callers', async () => {
process.env.OPENID_REUSE_TOKENS = 'true';
const token = await mintCodeApiToken(baseRequest({ provider: 'openid' }));
const { claims } = decodeToken(token);
expect(claims.principal_source).toBe('librechat_jwt');
});
it('includes optional plan context when present without trusting caller input', async () => {
const token = await mintCodeApiToken(
baseRequest({
subscription: { planId: 'prod_plan_123' },
}),
);
const { claims } = decodeToken(token);
expect(claims.plan_id).toBe('prod_plan_123');
expect(claims).not.toHaveProperty('planId');
});
it('uses the single-tenant namespace when tenant context is absent outside strict mode', async () => {
mockGetTenantId.mockReturnValue(undefined);
const token = await mintCodeApiToken(baseRequest({ tenantId: undefined }));
const { claims } = decodeToken(token);
expect(claims.tenant_id).toBe('legacy');
expect(claims.auth_context_hash).toBe(
expectedContextHash({
userId: 'user_123',
tenantId: 'legacy',
role: 'USER',
principalSource: 'librechat_jwt',
}),
);
});
it('supports overriding the single-tenant namespace for local deployments', async () => {
process.env.CODEAPI_JWT_SINGLE_TENANT_ID = 'local-single-tenant';
mockGetTenantId.mockReturnValue(undefined);
const token = await mintCodeApiToken(baseRequest({ tenantId: undefined }));
const { claims } = decodeToken(token);
expect(claims.tenant_id).toBe('local-single-tenant');
});
it('rejects minting without tenant context in strict tenant mode', async () => {
process.env.TENANT_ISOLATION_STRICT = 'true';
mockGetTenantId.mockReturnValue(undefined);
await expect(mintCodeApiToken(baseRequest({ tenantId: undefined }))).rejects.toThrow(
'Code API JWT auth requires tenant context',
);
});
it('ignores caller-supplied tenant spoofing fields', async () => {
const token = await mintCodeApiToken(baseRequest({ tenantId: 'tenant_canonical' }));
const { claims } = decodeToken(token);
expect(claims.tenant_id).toBe('tenant_canonical');
expect(JSON.stringify(claims)).not.toContain('spoofed_tenant');
expect(JSON.stringify(claims)).not.toContain('spoofed_header_tenant');
});
it('caches minted tokens for at most the configured 30 second window', async () => {
const now = jest.spyOn(Date, 'now').mockReturnValue(1_778_250_000_000);
const req = baseRequest();
const first = await mintCodeApiToken(req);
const second = await mintCodeApiToken(req);
now.mockReturnValue(1_778_250_031_000);
const afterCacheWindow = await mintCodeApiToken(req);
expect(second).toBe(first);
expect(afterCacheWindow).not.toBe(first);
});
it('returns Authorization headers only when a request and managed auth are present', async () => {
await expect(getCodeApiAuthHeaders()).resolves.toEqual({});
await expect(getCodeApiAuthHeaders(baseRequest())).resolves.toEqual({
Authorization: expect.stringMatching(/^Bearer /),
});
process.env.CODEAPI_AUTH_PROVIDER = 'legacy-api-key';
delete process.env.CODEAPI_JWT_ENABLED;
await expect(getCodeApiAuthHeaders(baseRequest())).resolves.toEqual({});
});
});

View file

@ -0,0 +1,380 @@
import { getTenantId } from '@librechat/data-schemas';
import { createHash, createPrivateKey, randomUUID, sign as cryptoSign } from 'crypto';
import type { KeyObject, JsonWebKey } from 'crypto';
import type { ServerRequest } from '~/types';
import { isEnabled } from '~/utils';
type CodeApiJwtAlg = 'EdDSA' | 'RS256';
type PrincipalSource = 'librechat_jwt' | 'openid_reuse';
interface CodeApiUserContext {
id?: string;
_id?: { toString(): string };
role?: string;
tenantId?: string | { toString(): string };
provider?: string;
orgId?: string;
serviceId?: string;
chcUserId?: string;
idOnTheSource?: string;
planId?: string;
subscription?: {
planId?: string;
};
}
interface CodeApiClaims {
iss: string;
aud: string;
sub: string;
iat: number;
nbf: number;
exp: number;
jti: string;
tenant_id: string;
role: string;
principal_source: PrincipalSource;
org_id?: string;
service_id?: string;
chc_user_id?: string;
plan_id?: string;
auth_context_hash: string;
}
interface SigningConfig {
alg: CodeApiJwtAlg;
kid: string;
issuer: string;
audience: string;
ttlSeconds: number;
cacheSeconds: number;
key: KeyObject;
rawKey: string;
}
interface CachedToken {
token: string;
expiresAt: number;
cachedUntil: number;
}
const DEFAULT_ISSUER = 'librechat';
const DEFAULT_AUDIENCE = 'codeapi';
const DEFAULT_KID = 'lc-codeapi-2026-05';
const DEFAULT_SINGLE_TENANT_ID = 'legacy';
const DEFAULT_TTL_SECONDS = 300;
const DEFAULT_CACHE_SECONDS = 30;
const MAX_TTL_SECONDS = 300;
const MAX_CACHE_SECONDS = 30;
const TOKEN_REUSE_SAFETY_WINDOW_SECONDS = 30;
const TOKEN_CACHE_PRUNE_INTERVAL_SECONDS = 30;
let signingConfigCache: SigningConfig | null = null;
const tokenCache = new Map<string, CachedToken>();
let tokenCacheLastPrunedAt = 0;
function base64Url(value: Buffer | string): string {
return Buffer.from(value).toString('base64url');
}
function normalizePem(value: string): string {
return value.replace(/\\n/g, '\n').trim();
}
function getPrivateKeyRaw(): string {
const inlineKey = process.env.CODEAPI_JWT_PRIVATE_KEY;
if (inlineKey != null && inlineKey.trim() !== '') {
return normalizePem(inlineKey);
}
const base64Key = process.env.CODEAPI_JWT_PRIVATE_KEY_BASE64;
if (base64Key != null && base64Key.trim() !== '') {
return normalizePem(Buffer.from(base64Key, 'base64').toString('utf8'));
}
const jwkJson = process.env.CODEAPI_JWT_PRIVATE_JWK_JSON;
if (jwkJson != null && jwkJson.trim() !== '') {
return jwkJson.trim();
}
throw new Error('Code API JWT signing key is not configured');
}
function parseAlg(value: string | undefined): CodeApiJwtAlg {
if (value === 'RS256') {
return 'RS256';
}
if (value === undefined || value === '' || value === 'EdDSA') {
return 'EdDSA';
}
throw new Error(`Unsupported Code API JWT algorithm: ${value}`);
}
function parseCappedSeconds(value: string | undefined, fallback: number, max: number): number {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed <= 0) {
return fallback;
}
return Math.min(Math.floor(parsed), max);
}
function createSigningKey(rawKey: string): KeyObject {
if (rawKey.startsWith('{')) {
return createPrivateKey({ key: JSON.parse(rawKey) as JsonWebKey, format: 'jwk' });
}
return createPrivateKey(rawKey);
}
function getSigningConfig(): SigningConfig {
const rawKey = getPrivateKeyRaw();
const alg = parseAlg(process.env.CODEAPI_JWT_ALGORITHM);
const kid = process.env.CODEAPI_JWT_KID ?? process.env.CODEAPI_JWT_KEY_ID ?? DEFAULT_KID;
const issuer = process.env.CODEAPI_JWT_ISSUER ?? DEFAULT_ISSUER;
const audience = process.env.CODEAPI_JWT_AUDIENCE ?? DEFAULT_AUDIENCE;
const ttlSeconds = parseCappedSeconds(
process.env.CODEAPI_JWT_TTL_SECONDS,
DEFAULT_TTL_SECONDS,
MAX_TTL_SECONDS,
);
const cacheSeconds = parseCappedSeconds(
process.env.CODEAPI_JWT_MINT_CACHE_SECONDS,
DEFAULT_CACHE_SECONDS,
MAX_CACHE_SECONDS,
);
if (
signingConfigCache &&
signingConfigCache.rawKey === rawKey &&
signingConfigCache.alg === alg &&
signingConfigCache.kid === kid &&
signingConfigCache.issuer === issuer &&
signingConfigCache.audience === audience &&
signingConfigCache.ttlSeconds === ttlSeconds &&
signingConfigCache.cacheSeconds === cacheSeconds
) {
return signingConfigCache;
}
signingConfigCache = {
alg,
kid,
issuer,
audience,
ttlSeconds,
cacheSeconds,
rawKey,
key: createSigningKey(rawKey),
};
tokenCache.clear();
tokenCacheLastPrunedAt = 0;
return signingConfigCache;
}
function stringifyClaimValue(value: unknown): string | undefined {
if (typeof value === 'string' && value.trim() !== '') {
return value;
}
if (value && typeof value === 'object' && 'toString' in value) {
const stringValue = value.toString();
return stringValue.trim() === '' ? undefined : stringValue;
}
return undefined;
}
function resolveUser(req: ServerRequest): CodeApiUserContext {
const user = req.user as CodeApiUserContext | undefined;
if (!user) {
throw new Error('Code API token minting requires an authenticated user');
}
return user;
}
function resolveUserId(user: CodeApiUserContext): string {
const userId = stringifyClaimValue(user.id) ?? stringifyClaimValue(user._id);
if (!userId) {
throw new Error('Code API token minting requires a canonical user id');
}
return userId;
}
function resolveSingleTenantId(): string {
const configured = process.env.CODEAPI_JWT_SINGLE_TENANT_ID;
if (configured != null && configured.trim() !== '') {
return configured.trim();
}
return DEFAULT_SINGLE_TENANT_ID;
}
function resolveTenantId(user: CodeApiUserContext): string | undefined {
const tenantId = stringifyClaimValue(user.tenantId) ?? getTenantId();
if (tenantId) {
return tenantId;
}
if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) {
return undefined;
}
return resolveSingleTenantId();
}
function isManagedCodeApiJwtMode(): boolean {
const provider = process.env.CODEAPI_AUTH_PROVIDER;
return provider === 'librechat-jwt' || provider === 'both';
}
export function isCodeApiJwtAuthEnabled(): boolean {
return isManagedCodeApiJwtMode() || isEnabled(process.env.CODEAPI_JWT_ENABLED);
}
function resolvePrincipalSource(req: ServerRequest): PrincipalSource {
if (req.authStrategy === 'openidJwt') {
return 'openid_reuse';
}
return 'librechat_jwt';
}
function canonicalContextHash(input: {
userId: string;
tenantId: string;
role: string;
principalSource: PrincipalSource;
orgId?: string;
serviceId?: string;
chcUserId?: string;
}): string {
const canonical = {
chc_user_id: input.chcUserId ?? '',
org_id: input.orgId ?? '',
principal_source: input.principalSource,
role: input.role,
service_id: input.serviceId ?? '',
sub: input.userId,
tenant_id: input.tenantId,
};
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
}
function buildClaims(req: ServerRequest, config: SigningConfig, now: number): CodeApiClaims {
const user = resolveUser(req);
const userId = resolveUserId(user);
const tenantId = resolveTenantId(user);
if (!tenantId) {
throw new Error('Code API JWT auth requires tenant context');
}
const role = user.role ?? 'USER';
const principalSource = resolvePrincipalSource(req);
const orgId = stringifyClaimValue(user.orgId);
const serviceId = stringifyClaimValue(user.serviceId);
const chcUserId = stringifyClaimValue(user.chcUserId) ?? stringifyClaimValue(user.idOnTheSource);
const planId = stringifyClaimValue(user.planId) ?? stringifyClaimValue(user.subscription?.planId);
const authContextHash = canonicalContextHash({
userId,
tenantId,
role,
principalSource,
orgId,
serviceId,
chcUserId,
});
return {
iss: config.issuer,
aud: config.audience,
sub: userId,
iat: now,
nbf: now,
exp: now + config.ttlSeconds,
jti: randomUUID(),
tenant_id: tenantId,
role,
principal_source: principalSource,
...(orgId ? { org_id: orgId } : {}),
...(serviceId ? { service_id: serviceId } : {}),
...(chcUserId ? { chc_user_id: chcUserId } : {}),
...(planId ? { plan_id: planId } : {}),
auth_context_hash: authContextHash,
};
}
function signJwt(config: SigningConfig, claims: CodeApiClaims): string {
const header = {
alg: config.alg,
typ: 'JWT',
kid: config.kid,
};
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`;
const signature = cryptoSign(
config.alg === 'RS256' ? 'RSA-SHA256' : null,
Buffer.from(signingInput),
config.key,
);
return `${signingInput}.${base64Url(signature)}`;
}
function cacheKey(config: SigningConfig, claims: CodeApiClaims): string {
return [
config.alg,
config.kid,
claims.sub,
claims.tenant_id,
claims.role,
claims.principal_source,
claims.org_id ?? '',
claims.service_id ?? '',
claims.chc_user_id ?? '',
claims.plan_id ?? '',
claims.auth_context_hash,
].join(':');
}
function pruneTokenCache(now: number): void {
if (tokenCache.size === 0) {
return;
}
if (now - tokenCacheLastPrunedAt < TOKEN_CACHE_PRUNE_INTERVAL_SECONDS) {
return;
}
tokenCacheLastPrunedAt = now;
for (const [key, cached] of tokenCache) {
if (cached.cachedUntil <= now || cached.expiresAt <= now + TOKEN_REUSE_SAFETY_WINDOW_SECONDS) {
tokenCache.delete(key);
}
}
}
export async function mintCodeApiToken(req: ServerRequest): Promise<string> {
if (!isCodeApiJwtAuthEnabled()) {
return '';
}
const config = getSigningConfig();
const now = Math.floor(Date.now() / 1000);
pruneTokenCache(now);
const claims = buildClaims(req, config, now);
const key = cacheKey(config, claims);
const cached = tokenCache.get(key);
if (
cached &&
cached.cachedUntil > now &&
cached.expiresAt > now + TOKEN_REUSE_SAFETY_WINDOW_SECONDS
) {
return cached.token;
}
const token = signJwt(config, claims);
tokenCache.set(key, {
token,
expiresAt: claims.exp,
cachedUntil: Math.min(
now + config.cacheSeconds,
claims.exp - TOKEN_REUSE_SAFETY_WINDOW_SECONDS,
),
});
return token;
}
export async function getCodeApiAuthHeaders(req?: ServerRequest): Promise<Record<string, string>> {
if (!req || !isCodeApiJwtAuthEnabled()) {
return {};
}
const token = await mintCodeApiToken(req);
return token ? { Authorization: `Bearer ${token}` } : {};
}

View file

@ -5,3 +5,4 @@ export * from './refresh';
export * from './agent';
export * from './password';
export * from './invite';
export * from './codeapi';

View file

@ -187,6 +187,8 @@ export interface BuildToolClassificationParams {
deferredToolsEnabled?: boolean;
/** When true, skip creating tool instances (for event-driven mode) */
definitionsOnly?: boolean;
/** Optional host-supplied Code API auth headers for remote programmatic execution. */
authHeaders?: () => Promise<Record<string, string>> | Record<string, string>;
}
/** Result from building tool classification */
@ -251,6 +253,7 @@ export async function buildToolClassification(
agentToolOptions,
definitionsOnly = false,
deferredToolsEnabled = true,
authHeaders,
} = params;
const additionalTools: GenericTool[] = [];
@ -345,7 +348,9 @@ export async function buildToolClassification(
}
try {
const ptcTool = createProgrammaticToolCallingTool({});
const ptcTool = createProgrammaticToolCallingTool({ authHeaders } as Parameters<
typeof createProgrammaticToolCallingTool
>[0] & { authHeaders?: BuildToolClassificationParams['authHeaders'] });
additionalTools.push(ptcTool);
/** Add PTC definition for event-driven mode */

View file

@ -25,4 +25,6 @@ export type ServerRequest = Request<unknown, unknown, RequestBody> & {
conversationCreatedAt?: string;
/** Conversation loaded while resolving the prompt timestamp anchor, reused by save logic. */
resolvedConversation?: Partial<TConversation> | null;
/** Passport strategy that populated req.user for this request. */
authStrategy?: string;
};