From f68acf095486d3692f2b972103e1de0c5dc8190d Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 19:06:34 +0200 Subject: [PATCH 1/5] fix(auth): bind JWT trust by issuer --- docker-compose.yaml | 1 + helm/codeapi/README.md | 15 +- service/src/auth/librechat-jwt.test.ts | 136 ++++++++++++- service/src/auth/librechat-jwt.ts | 258 ++++++++++++++++++++++--- 4 files changed, 378 insertions(+), 32 deletions(-) diff --git a/docker-compose.yaml b/docker-compose.yaml index ac98d917..a3a637a8 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -12,6 +12,7 @@ services: - CODEAPI_HARDENED_SANDBOX_MODE=${CODEAPI_HARDENED_SANDBOX_MODE:-true} - CODEAPI_AUTH_PROVIDER=${CODEAPI_AUTH_PROVIDER:-} - CODEAPI_ALLOW_AUTH_PROVIDER_NONE=${CODEAPI_ALLOW_AUTH_PROVIDER_NONE:-} + - CODEAPI_JWT_TRUST_ENTRIES_JSON - CODEAPI_JWT_ISSUER=${CODEAPI_JWT_ISSUER:-} - CODEAPI_JWT_AUDIENCE=${CODEAPI_JWT_AUDIENCE:-} - CODEAPI_JWT_ALLOWED_ALGS=${CODEAPI_JWT_ALLOWED_ALGS:-} diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index 9c9d0f1d..78bfc29f 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -109,17 +109,30 @@ api: extraEnv: - name: CODEAPI_AUTH_PROVIDER value: librechat-jwt + - name: CODEAPI_JWT_TRUST_ENTRIES_JSON + value: '[{"issuer":"librechat","audiences":["codeapi"],"keyIds":["librechat-2026"],"allowedAlgorithms":["EdDSA"],"principalSources":["librechat_jwt","openid_reuse"]}]' - name: CODEAPI_JWT_PUBLIC_KEY # single PEM/base64-DER verifier key valueFrom: secretKeyRef: name: codeapi-jwt-verifier key: public-key - name: CODEAPI_JWT_KID - value: my-key-id + value: librechat-2026 ``` `CODEAPI_JWT_PUBLIC_KEYS_DIR` (a mounted directory of PEM files) and `CODEAPI_JWT_JWKS_JSON` (inline JWKS) are also supported for key rotation. +Each modern trust entry binds one exact issuer to accepted audiences, key IDs, +algorithms, and principal sources. Key IDs must be globally unique across +entries, and every loaded key must belong to exactly one entry. External +issuers use a lowercase `external:` principal source configured for that +entry. + +When `CODEAPI_JWT_TRUST_ENTRIES_JSON` is absent, the verifier preserves the +legacy single-LibreChat behavior from `CODEAPI_JWT_ISSUER`, +`CODEAPI_JWT_AUDIENCE`, and `CODEAPI_JWT_ALLOWED_ALGS`. Do not set those three +legacy variables together with the modern trust table. An empty or malformed +trust table fails startup. For development only, `LOCAL_MODE=true` bypasses authentication — see `values-local.yaml`. diff --git a/service/src/auth/librechat-jwt.test.ts b/service/src/auth/librechat-jwt.test.ts index 2030b2e7..c8721f5e 100644 --- a/service/src/auth/librechat-jwt.test.ts +++ b/service/src/auth/librechat-jwt.test.ts @@ -3,13 +3,14 @@ import { generateKeyPairSync, sign as cryptoSign } from 'crypto'; import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import type { KeyObject } from 'crypto'; +import type { JsonWebKey, KeyObject } from 'crypto'; import { CodeApiJwtAuthError, verifyLibreChatJwt } from './librechat-jwt'; const ENV_KEYS = [ 'CODEAPI_JWT_ISSUER', 'CODEAPI_JWT_AUDIENCE', 'CODEAPI_JWT_ALLOWED_ALGS', + 'CODEAPI_JWT_TRUST_ENTRIES_JSON', 'CODEAPI_JWT_CLOCK_SKEW_SECONDS', 'CODEAPI_JWT_MAX_TTL_SECONDS', 'CODEAPI_JWT_KEY_CACHE_TTL_SECONDS', @@ -52,6 +53,7 @@ type JwtClaims = { const originalEnv = new Map(); let privateKey: KeyObject; +let publicJwk: JsonWebKey; function base64Url(value: Buffer | string): string { return Buffer.from(value).toString('base64url'); @@ -111,6 +113,24 @@ function expectJwtReason(token: string, reason: string): void { } } +function setModernTrustEntries(entries: unknown[]): void { + delete process.env.CODEAPI_JWT_ISSUER; + delete process.env.CODEAPI_JWT_AUDIENCE; + delete process.env.CODEAPI_JWT_ALLOWED_ALGS; + process.env.CODEAPI_JWT_TRUST_ENTRIES_JSON = JSON.stringify(entries); +} + +function trustEntry(overrides: Record = {}): Record { + return { + issuer: 'librechat', + audiences: ['codeapi'], + keyIds: ['test-kid'], + allowedAlgorithms: ['EdDSA'], + principalSources: ['librechat_jwt', 'openid_reuse'], + ...overrides, + }; +} + beforeEach(() => { if (originalEnv.size === 0) { for (const key of ENV_KEYS) { @@ -120,7 +140,7 @@ beforeEach(() => { const { publicKey, privateKey: generatedPrivateKey } = generateKeyPairSync('ed25519'); privateKey = generatedPrivateKey; - const jwk = publicKey.export({ format: 'jwk' }); + publicJwk = publicKey.export({ format: 'jwk' }); process.env.CODEAPI_JWT_ISSUER = 'librechat'; process.env.CODEAPI_JWT_AUDIENCE = 'codeapi'; @@ -129,8 +149,9 @@ beforeEach(() => { process.env.CODEAPI_JWT_MAX_TTL_SECONDS = '300'; process.env.CODEAPI_JWT_KEY_CACHE_TTL_SECONDS = '30'; process.env.CODEAPI_JWT_JWKS_JSON = JSON.stringify({ - keys: [{ ...jwk, kid: 'test-kid', alg: 'EdDSA' }], + keys: [{ ...publicJwk, kid: 'test-kid', alg: 'EdDSA' }], }); + delete process.env.CODEAPI_JWT_TRUST_ENTRIES_JSON; delete process.env.CODEAPI_JWT_PUBLIC_KEYS_DIR; delete process.env.CODEAPI_JWT_PUBLIC_KEY; delete process.env.CODEAPI_JWT_KID; @@ -217,6 +238,115 @@ describe('LibreChat JWT auth provider', () => { expect(principal.tenantId).toBe('tenant_abc'); }); + test('binds issuer, key, audience, algorithm, and principal source in modern mode', () => { + const partner = generateKeyPairSync('ed25519'); + const partnerJwk = partner.publicKey.export({ format: 'jwk' }); + process.env.CODEAPI_JWT_JWKS_JSON = JSON.stringify({ + keys: [ + { ...publicJwk, kid: 'test-kid', alg: 'EdDSA' }, + { ...partnerJwk, kid: 'partner-kid', alg: 'EdDSA' }, + ], + }); + setModernTrustEntries([ + trustEntry(), + trustEntry({ + issuer: 'partner', + audiences: ['partner-codeapi'], + keyIds: ['partner-kid'], + principalSources: ['external:partner'], + }), + ]); + + expect(verifyLibreChatJwt(signJwt(baseClaims())).principalSource).toBe('openid_reuse'); + const partnerClaims = baseClaims({ + iss: 'partner', + aud: 'partner-codeapi', + principal_source: 'external:partner', + }); + expect( + verifyLibreChatJwt( + signJwt(partnerClaims, { kid: 'partner-kid' }, partner.privateKey), + ).principalSource, + ).toBe('external:partner'); + + expectJwtReason(signJwt(partnerClaims), 'unknown_kid'); + expectJwtReason( + signJwt({ ...partnerClaims, principal_source: 'openid_reuse' }, { kid: 'partner-kid' }, partner.privateKey), + 'malformed_claims', + ); + expectJwtReason( + signJwt({ ...partnerClaims, aud: 'codeapi' }, { kid: 'partner-kid' }, partner.privateKey), + 'wrong_audience', + ); + expectJwtReason( + signJwt(baseClaims(), { kid: 'partner-kid' }, partner.privateKey), + 'unknown_kid', + ); + }); + + test('rejects malformed, ambiguous, and incomplete modern trust configuration', () => { + const valid = trustEntry(); + const invalidEntries: unknown[][] = [ + [], + [{ ...valid, unknown: true }], + [{ ...valid, audiences: ['codeapi', 'codeapi'] }], + [{ ...valid, allowedAlgorithms: ['ES256'] }], + [{ ...valid, principalSources: ['api_key'] }], + [{ ...valid, principalSources: ['external:api_key'] }], + [{ ...valid, principalSources: ['external:none'] }], + [{ ...valid, principalSources: ['external:synthetic_test'] }], + [{ ...valid, principalSources: ['external:Partner'] }], + [{ ...valid, principalSources: ['external:-partner'] }], + [{ ...valid, principalSources: ['external:partner-'] }], + [valid, { ...valid }], + [{ ...valid, keyIds: ['missing-kid'] }], + [{ ...valid, allowedAlgorithms: ['RS256'] }], + ]; + + for (const entries of invalidEntries) { + setModernTrustEntries(entries); + expectJwtReason(signJwt(baseClaims()), 'config'); + } + + setModernTrustEntries([valid]); + process.env.CODEAPI_JWT_ISSUER = 'stale-issuer'; + expectJwtReason(signJwt(baseClaims()), 'config'); + + delete process.env.CODEAPI_JWT_ISSUER; + process.env.CODEAPI_JWT_PUBLIC_KEY = JSON.stringify(publicJwk); + process.env.CODEAPI_JWT_KID = 'test-kid'; + expectJwtReason(signJwt(baseClaims()), 'config'); + }); + + test('rejects orphan and cross-entry key assignments in modern mode', () => { + const second = generateKeyPairSync('ed25519'); + const secondJwk = second.publicKey.export({ format: 'jwk' }); + process.env.CODEAPI_JWT_JWKS_JSON = JSON.stringify({ + keys: [ + { ...publicJwk, kid: 'test-kid', alg: 'EdDSA' }, + { ...secondJwk, kid: 'second-kid', alg: 'EdDSA' }, + ], + }); + + setModernTrustEntries([trustEntry()]); + expectJwtReason(signJwt(baseClaims()), 'config'); + + setModernTrustEntries([ + trustEntry(), + trustEntry({ issuer: 'second', keyIds: ['test-kid'] }), + ]); + expectJwtReason(signJwt(baseClaims()), 'config'); + }); + + test('reloads modern trust metadata immediately when its fingerprint changes', () => { + setModernTrustEntries([trustEntry()]); + const token = signJwt(baseClaims()); + expect(verifyLibreChatJwt(token).principalSource).toBe('openid_reuse'); + + setModernTrustEntries([trustEntry({ principalSources: ['librechat_jwt'] })]); + expectJwtReason(token, 'malformed_claims'); + }); + test('defaults missing tenant_id to the single-tenant namespace outside strict mode', () => { const principal = verifyLibreChatJwt(signJwt(baseClaims({ tenant_id: undefined }))); diff --git a/service/src/auth/librechat-jwt.ts b/service/src/auth/librechat-jwt.ts index 1e7e8079..457c5aa7 100644 --- a/service/src/auth/librechat-jwt.ts +++ b/service/src/auth/librechat-jwt.ts @@ -12,7 +12,8 @@ import type { AuthProvider } from './provider'; import type { CodeApiPrincipal } from './principal'; type JwtAlg = 'EdDSA' | 'RS256' | 'HS256'; -type LibreChatPrincipalSource = 'librechat_jwt' | 'openid_reuse'; +type InternalJwtPrincipalSource = 'librechat_jwt' | 'openid_reuse'; +type JwtPrincipalSource = InternalJwtPrincipalSource | `external:${string}`; interface JwtHeader { alg?: string; @@ -46,10 +47,16 @@ interface PublicKeyEntry { key: KeyObject | Buffer; } -interface VerificationConfig { +interface JwtTrustEntry { issuer: string; - audience: string; + audiences: Set; + keyIds: Set; allowedAlgs: Set; + principalSources: Set; +} + +interface VerificationConfig { + trustEntries: Map; clockSkewSeconds: number; maxTokenLifetimeSeconds: number; keys: Map; @@ -72,10 +79,20 @@ const MAX_KEY_CACHE_TTL_SECONDS = 300; const DEFAULT_MAX_TOKEN_LIFETIME_SECONDS = 300; const MAX_TOKEN_LIFETIME_SECONDS = 300; const DEFAULT_SINGLE_TENANT_ID = 'legacy'; -const TRUSTED_PRINCIPAL_SOURCES = new Set([ +const SUPPORTED_ALGORITHMS = new Set(['EdDSA', 'RS256', 'HS256']); +const INTERNAL_PRINCIPAL_SOURCES = new Set([ 'librechat_jwt', 'openid_reuse', ]); +const EXTERNAL_PRINCIPAL_SOURCE = /^external:([a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?)$/; +const RESERVED_EXTERNAL_SOURCE_SLUGS = new Set(['synthetic_test', 'none', 'api_key']); +const TRUST_ENTRY_FIELDS = new Set([ + 'issuer', + 'audiences', + 'keyIds', + 'allowedAlgorithms', + 'principalSources', +]); function base64UrlDecode(value: string): Buffer { try { @@ -120,6 +137,44 @@ function parseAllowedAlgs(): Set { return allowed; } +function assertUniqueStrings(value: unknown, name: string): string[] { + if (!Array.isArray(value) || value.length === 0) { + throw new CodeApiJwtAuthError('config', `${name} must be a non-empty array`); + } + const result: string[] = []; + const seen = new Set(); + for (const item of value) { + if (typeof item !== 'string' || item.trim() === '') { + throw new CodeApiJwtAuthError('config', `${name} must contain non-empty strings`); + } + if (item !== item.trim()) { + throw new CodeApiJwtAuthError('config', `${name} values must not contain surrounding whitespace`); + } + if (seen.has(item)) { + throw new CodeApiJwtAuthError('config', `${name} must not contain duplicate values`); + } + seen.add(item); + result.push(item); + } + return result; +} + +function assertNoUnknownFields(value: Record, name: string): void { + for (const field of Object.keys(value)) { + if (!TRUST_ENTRY_FIELDS.has(field)) { + throw new CodeApiJwtAuthError('config', `${name} contains unknown field ${field}`); + } + } +} + +function isSupportedPrincipalSource(value: string): value is JwtPrincipalSource { + if (INTERNAL_PRINCIPAL_SOURCES.has(value as InternalJwtPrincipalSource)) { + return true; + } + const match = EXTERNAL_PRINCIPAL_SOURCE.exec(value); + return match !== null && !RESERVED_EXTERNAL_SOURCE_SLUGS.has(match[1]); +} + function parseClockSkew(): number { const parsed = Number(process.env.CODEAPI_JWT_CLOCK_SKEW_SECONDS); if (!Number.isFinite(parsed) || parsed < 0) { @@ -148,6 +203,17 @@ function publicKeyFromValue(value: string): KeyObject { } } +function addKey( + keys: Map, + kid: string, + entry: PublicKeyEntry, +): void { + if (keys.has(kid)) { + throw new CodeApiJwtAuthError('config', `Duplicate CodeAPI JWT key ID: ${kid}`); + } + keys.set(kid, entry); +} + function loadJwks(keys: Map, raw: string): void { let parsed: { keys?: Array }; try { @@ -163,7 +229,7 @@ function loadJwks(keys: Map, raw: string): void { continue; } try { - keys.set(jwk.kid, { + addKey(keys, jwk.kid, { alg: jwk.alg === 'EdDSA' || jwk.alg === 'RS256' ? jwk.alg : undefined, key: createPublicKey({ key: jwk, format: 'jwk' }), }); @@ -187,7 +253,7 @@ function loadPublicKeyDir(keys: Map, dir: string): void if (!kid) { continue; } - keys.set(kid, { key: publicKeyFromValue(readFileSync(fullPath, 'utf8')) }); + addKey(keys, kid, { key: publicKeyFromValue(readFileSync(fullPath, 'utf8')) }); } } catch (error) { if (error instanceof CodeApiJwtAuthError) { @@ -215,13 +281,13 @@ function loadKeys(): Map { if (!kid) { throw new CodeApiJwtAuthError('config', 'CODEAPI_JWT_KID is required with CODEAPI_JWT_PUBLIC_KEY'); } - keys.set(kid, { key: publicKeyFromValue(publicKey) }); + addKey(keys, kid, { key: publicKeyFromValue(publicKey) }); } const hsSecret = process.env.CODEAPI_JWT_HS256_SECRET; if (hsSecret != null && hsSecret !== '') { const kid = process.env.CODEAPI_JWT_HS256_KID ?? process.env.CODEAPI_JWT_KID ?? 'hs256-dev'; - keys.set(kid, { alg: 'HS256', key: Buffer.from(hsSecret) }); + addKey(keys, kid, { alg: 'HS256', key: Buffer.from(hsSecret) }); } if (keys.size === 0) { @@ -230,11 +296,140 @@ function loadKeys(): Map { return keys; } +function keyAlgorithm(key: PublicKeyEntry): JwtAlg | undefined { + if (key.alg) { + return key.alg; + } + if (Buffer.isBuffer(key.key)) { + return 'HS256'; + } + if (key.key.asymmetricKeyType === 'ed25519') { + return 'EdDSA'; + } + if (key.key.asymmetricKeyType === 'rsa') { + return 'RS256'; + } + return undefined; +} + +function parseModernTrustEntries(keys: Map, raw: string): Map { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + throw new CodeApiJwtAuthError('config', 'CODEAPI_JWT_TRUST_ENTRIES_JSON is not valid JSON'); + } + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new CodeApiJwtAuthError( + 'config', + 'CODEAPI_JWT_TRUST_ENTRIES_JSON must be a non-empty array', + ); + } + + for (const legacyName of [ + 'CODEAPI_JWT_ISSUER', + 'CODEAPI_JWT_AUDIENCE', + 'CODEAPI_JWT_ALLOWED_ALGS', + ]) { + if ((process.env[legacyName] ?? '').trim() !== '') { + throw new CodeApiJwtAuthError( + 'config', + `${legacyName} cannot be combined with CODEAPI_JWT_TRUST_ENTRIES_JSON`, + ); + } + } + + const entries = new Map(); + const assignedKeyIds = new Set(); + for (const [index, value] of parsed.entries()) { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + throw new CodeApiJwtAuthError('config', `JWT trust entry ${index} must be an object`); + } + const record = value as Record; + assertNoUnknownFields(record, `JWT trust entry ${index}`); + const issuer = typeof record.issuer === 'string' ? record.issuer : ''; + if (issuer === '' || issuer !== issuer.trim()) { + throw new CodeApiJwtAuthError('config', `JWT trust entry ${index} issuer is invalid`); + } + if (entries.has(issuer)) { + throw new CodeApiJwtAuthError('config', `Duplicate JWT trust issuer: ${issuer}`); + } + + const audiences = assertUniqueStrings(record.audiences, `JWT trust entry ${index} audiences`); + const keyIds = assertUniqueStrings(record.keyIds, `JWT trust entry ${index} keyIds`); + const algorithmValues = assertUniqueStrings( + record.allowedAlgorithms, + `JWT trust entry ${index} allowedAlgorithms`, + ); + const sourceValues = assertUniqueStrings( + record.principalSources, + `JWT trust entry ${index} principalSources`, + ); + if (!algorithmValues.every((value): value is JwtAlg => SUPPORTED_ALGORITHMS.has(value as JwtAlg))) { + throw new CodeApiJwtAuthError('config', `JWT trust entry ${index} has an unsupported algorithm`); + } + if (!sourceValues.every(isSupportedPrincipalSource)) { + throw new CodeApiJwtAuthError('config', `JWT trust entry ${index} has an unsupported principal source`); + } + const allowedAlgs = new Set(algorithmValues); + for (const keyId of keyIds) { + if (assignedKeyIds.has(keyId)) { + throw new CodeApiJwtAuthError('config', `JWT key ID is assigned to multiple trust entries: ${keyId}`); + } + const key = keys.get(keyId); + if (!key) { + throw new CodeApiJwtAuthError('config', `JWT trust entry references unknown key ID: ${keyId}`); + } + const algorithm = keyAlgorithm(key); + if (!algorithm || !allowedAlgs.has(algorithm)) { + throw new CodeApiJwtAuthError( + 'config', + `JWT key ID ${keyId} is incompatible with its trust entry algorithms`, + ); + } + assignedKeyIds.add(keyId); + } + entries.set(issuer, { + issuer, + audiences: new Set(audiences), + keyIds: new Set(keyIds), + allowedAlgs, + principalSources: new Set(sourceValues), + }); + } + + for (const keyId of keys.keys()) { + if (!assignedKeyIds.has(keyId)) { + throw new CodeApiJwtAuthError('config', `CodeAPI JWT key ID is not assigned to a trust entry: ${keyId}`); + } + } + return entries; +} + +function buildTrustEntries(keys: Map): Map { + const modern = process.env.CODEAPI_JWT_TRUST_ENTRIES_JSON; + if (modern !== undefined) { + return parseModernTrustEntries(keys, modern); + } + const issuer = process.env.CODEAPI_JWT_ISSUER ?? 'librechat'; + const audience = process.env.CODEAPI_JWT_AUDIENCE ?? 'codeapi'; + return new Map([ + [issuer, { + issuer, + audiences: new Set([audience]), + keyIds: new Set(keys.keys()), + allowedAlgs: parseAllowedAlgs(), + principalSources: new Set(['librechat_jwt', 'openid_reuse']), + }], + ]); +} + function rawConfigFingerprint(): string { return JSON.stringify({ issuer: process.env.CODEAPI_JWT_ISSUER, audience: process.env.CODEAPI_JWT_AUDIENCE, allowedAlgs: process.env.CODEAPI_JWT_ALLOWED_ALGS, + trustEntries: process.env.CODEAPI_JWT_TRUST_ENTRIES_JSON, skew: process.env.CODEAPI_JWT_CLOCK_SKEW_SECONDS, maxTokenLifetime: process.env.CODEAPI_JWT_MAX_TTL_SECONDS, keyCacheTtl: process.env.CODEAPI_JWT_KEY_CACHE_TTL_SECONDS, @@ -259,19 +454,18 @@ function getConfig(): VerificationConfig { DEFAULT_KEY_CACHE_TTL_SECONDS, MAX_KEY_CACHE_TTL_SECONDS, ); + const keys = loadKeys(); configCache = { rawConfig, reloadAt: now + keyCacheTtlSeconds * 1000, - issuer: process.env.CODEAPI_JWT_ISSUER ?? 'librechat', - audience: process.env.CODEAPI_JWT_AUDIENCE ?? 'codeapi', - allowedAlgs: parseAllowedAlgs(), + trustEntries: buildTrustEntries(keys), clockSkewSeconds: parseClockSkew(), maxTokenLifetimeSeconds: parseCappedSeconds( process.env.CODEAPI_JWT_MAX_TTL_SECONDS, DEFAULT_MAX_TOKEN_LIFETIME_SECONDS, MAX_TOKEN_LIFETIME_SECONDS, ), - keys: loadKeys(), + keys, }; return configCache; } @@ -311,9 +505,9 @@ function assertString(value: unknown, name: string): string { return value; } -function assertAudience(value: unknown, expected: string): void { +function assertAudience(value: unknown, accepted: Set): void { if (typeof value === 'string' && value.trim() !== '') { - if (value !== expected) { + if (!accepted.has(value)) { throw new CodeApiJwtAuthError('wrong_audience', 'JWT audience is not accepted'); } return; @@ -323,7 +517,7 @@ function assertAudience(value: unknown, expected: string): void { if (!value.every((audience) => typeof audience === 'string')) { throw new CodeApiJwtAuthError('malformed_claims', 'aud must contain only strings'); } - if (value.includes(expected)) { + if (value.some((audience) => accepted.has(audience))) { return; } throw new CodeApiJwtAuthError('wrong_audience', 'JWT audience is not accepted'); @@ -372,19 +566,19 @@ function resolveTenantIdClaim(value: unknown): string { return resolveSingleTenantId(); } -function isTrustedPrincipalSource(value: string): value is LibreChatPrincipalSource { - return TRUSTED_PRINCIPAL_SOURCES.has(value as LibreChatPrincipalSource); -} - -function assertPrincipalSource(value: unknown): LibreChatPrincipalSource { +function assertPrincipalSource(value: unknown, accepted: Set): JwtPrincipalSource { const principalSource = assertString(value, 'principal_source'); - if (isTrustedPrincipalSource(principalSource)) { - return principalSource; + if (accepted.has(principalSource as JwtPrincipalSource)) { + return principalSource as JwtPrincipalSource; } throw new CodeApiJwtAuthError('malformed_claims', 'principal_source is not accepted'); } -function validateClaims(claims: LibreChatJwtClaims, config: VerificationConfig): CodeApiPrincipal { +function validateClaims( + claims: LibreChatJwtClaims, + config: VerificationConfig, + trustEntry: JwtTrustEntry, +): CodeApiPrincipal { const now = Math.floor(Date.now() / 1000); const issuer = assertString(claims.iss, 'iss'); const userId = assertString(claims.sub, 'sub'); @@ -394,16 +588,16 @@ function validateClaims(claims: LibreChatJwtClaims, config: VerificationConfig): const nbf = assertNumericDate(claims.nbf, 'nbf'); const exp = assertNumericDate(claims.exp, 'exp'); const planId = optionalString(claims.plan_id, 'plan_id'); - const principalSource = assertPrincipalSource(claims.principal_source); + const principalSource = assertPrincipalSource(claims.principal_source, trustEntry.principalSources); const authContextHash = assertString(claims.auth_context_hash, 'auth_context_hash'); if (jti.length > 256) { throw new CodeApiJwtAuthError('malformed_claims', 'jti is too long'); } - if (issuer !== config.issuer) { + if (issuer !== trustEntry.issuer) { throw new CodeApiJwtAuthError('wrong_issuer', 'JWT issuer is not trusted'); } - assertAudience(claims.aud, config.audience); + assertAudience(claims.aud, trustEntry.audiences); if (exp <= now - config.clockSkewSeconds) { throw new CodeApiJwtAuthError('expired', 'JWT is expired'); } @@ -449,17 +643,25 @@ export function verifyLibreChatJwt(token: string): CodeApiPrincipal { const [encodedHeader, encodedPayload, encodedSignature] = parts; const header = parseJsonSegment(encodedHeader, 'JWT header'); const claims = parseJsonSegment(encodedPayload, 'JWT payload'); + const issuer = assertString(claims.iss, 'iss'); + const trustEntry = config.trustEntries.get(issuer); + if (!trustEntry) { + throw new CodeApiJwtAuthError('wrong_issuer', 'JWT issuer is not trusted'); + } const alg = header.alg; if (alg !== 'EdDSA' && alg !== 'RS256' && alg !== 'HS256') { throw new CodeApiJwtAuthError('wrong_alg', 'JWT alg is not supported'); } - if (!config.allowedAlgs.has(alg)) { + if (!trustEntry.allowedAlgs.has(alg)) { throw new CodeApiJwtAuthError('wrong_alg', 'JWT alg is not allowed'); } if (header.typ !== undefined && header.typ !== 'JWT') { throw new CodeApiJwtAuthError('malformed', 'JWT typ must be JWT'); } const kid = assertString(header.kid, 'kid'); + if (!trustEntry.keyIds.has(kid)) { + throw new CodeApiJwtAuthError('unknown_kid', 'JWT kid is not configured for issuer'); + } const key = config.keys.get(kid); if (!key) { throw new CodeApiJwtAuthError('unknown_kid', 'JWT kid is not configured'); @@ -472,7 +674,7 @@ export function verifyLibreChatJwt(token: string): CodeApiPrincipal { if (!verifySignature(alg, key, signingInput, signature)) { throw new CodeApiJwtAuthError('bad_signature', 'JWT signature is invalid'); } - return validateClaims(claims, config); + return validateClaims(claims, config, trustEntry); } export class LibreChatJwtAuthProvider implements AuthProvider { From dd5436b2e90ff90a503af92ba947d33822da139f Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 19:10:01 +0200 Subject: [PATCH 2/5] docs(fork): record issuer trust patch --- docs/fork/patches.md | 46 ++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index d1c6802a..820a6d28 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -29,6 +29,7 @@ States: Active, Review on sync, Draft, History only, Retired. | Keep PVC package initialization Argo-safe | Active | `646ed2e`, `12d3760`, `c1509a8` | Upstream `packages.source=pvc` mode | | Recover job completion when BullMQ events lag | Active | `b66e87e` | Upstream execution profiles and completion timeout | | Reconnect the egress ledger after Redis outages | Active | `5e459dd` | Managed Redis | +| Bind JWT trust to verified issuers | Active | `f68acf0` | JWT verification keys and issuer configuration | ## Publish exact-SHA UZH images @@ -186,12 +187,9 @@ Required behavior: scale-to-zero sandbox pool on every sync. - Keep upstream's baked-image package source as the default. -Owned paths: - -- `helm/codeapi/README.md` - Shared paths: +- `helm/codeapi/README.md` — also documents issuer-scoped JWT trust. - `helm/codeapi/templates/package-init-job.yaml` — also supports the split sandbox namespace. - `helm/codeapi/templates/pvc.yaml` — also supports the split sandbox @@ -280,6 +278,46 @@ Replay and drop condition: recreates the Redis client after terminal disconnect, with a readiness recovery test covering an outage longer than five attempts. +## Bind JWT trust to verified issuers + +Required behavior: + +- Select a trust entry by unverified issuer only to locate policy, then verify + its key, algorithm, issuer, audience, and principal source before accepting a + principal. +- Fail startup for malformed or ambiguous modern trust configuration and keep + each loaded key assigned to exactly one issuer entry. +- Preserve the legacy single-issuer environment contract when no modern trust + table is configured. +- Support reusable external principal sources through the bounded lowercase + `external:` namespace without embedding a consumer-specific source. + +Owned paths: + +- `docker-compose.yaml` +- `service/src/auth/librechat-jwt.test.ts` +- `service/src/auth/librechat-jwt.ts` + +Shared paths: + +- `helm/codeapi/README.md` — also documents the retained PVC package mode. + +Source and current-upstream evidence: + +- Commit `f68acf095486d3692f2b972103e1de0c5dc8190d` defines the issuer-scoped + trust behavior and its negative tests. +- Upstream `297fead1a0cd997b0e3e6e55f77fbe83b376be1a` and the reconciled UZH + baseline `83c4f7b105b6b3e69eda12701ad4ec437acba08f` retain only one effective + issuer policy. + +Replay and drop condition: + +- Reapply the trust-table seam around the current upstream verifier rather than + replacing later claim, key-loading, or cache behavior. +- Drop when upstream supports equivalent issuer-keyed trust, exact key + assignment, fail-closed configuration, bounded external sources, and legacy + fallback with matching positive and cross-entry negative tests. + ## Retired debris - Merge commit `356123a` is history-only transport for the package-init fix; From d9f64830532b80b069729b3819caf7eb48c8e823 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 19:14:13 +0200 Subject: [PATCH 3/5] refactor(auth): remove duplicate issuer check --- service/src/auth/librechat-jwt.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/service/src/auth/librechat-jwt.ts b/service/src/auth/librechat-jwt.ts index 457c5aa7..18edf37f 100644 --- a/service/src/auth/librechat-jwt.ts +++ b/service/src/auth/librechat-jwt.ts @@ -580,7 +580,6 @@ function validateClaims( trustEntry: JwtTrustEntry, ): CodeApiPrincipal { const now = Math.floor(Date.now() / 1000); - const issuer = assertString(claims.iss, 'iss'); const userId = assertString(claims.sub, 'sub'); const tenantId = resolveTenantIdClaim(claims.tenant_id); const jti = assertString(claims.jti, 'jti'); @@ -594,9 +593,6 @@ function validateClaims( if (jti.length > 256) { throw new CodeApiJwtAuthError('malformed_claims', 'jti is too long'); } - if (issuer !== trustEntry.issuer) { - throw new CodeApiJwtAuthError('wrong_issuer', 'JWT issuer is not trusted'); - } assertAudience(claims.aud, trustEntry.audiences); if (exp <= now - config.clockSkewSeconds) { throw new CodeApiJwtAuthError('expired', 'JWT is expired'); From 8ea7c58e0ce2c719b72ab82ae2ebadae8da7c03c Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 19:19:01 +0200 Subject: [PATCH 4/5] test(auth): name duplicate key guard --- service/src/auth/librechat-jwt.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/service/src/auth/librechat-jwt.test.ts b/service/src/auth/librechat-jwt.test.ts index c8721f5e..8a73265f 100644 --- a/service/src/auth/librechat-jwt.test.ts +++ b/service/src/auth/librechat-jwt.test.ts @@ -311,8 +311,9 @@ describe('LibreChat JWT auth provider', () => { setModernTrustEntries([valid]); process.env.CODEAPI_JWT_ISSUER = 'stale-issuer'; expectJwtReason(signJwt(baseClaims()), 'config'); + }); - delete process.env.CODEAPI_JWT_ISSUER; + test('rejects duplicate key IDs across verification key sources', () => { process.env.CODEAPI_JWT_PUBLIC_KEY = JSON.stringify(publicJwk); process.env.CODEAPI_JWT_KID = 'test-kid'; expectJwtReason(signJwt(baseClaims()), 'config'); From 40cde7dc0285061ba92871aeaae4715b3e067418 Mon Sep 17 00:00:00 2001 From: Roland Schlaefli Date: Mon, 31 Aug 2026 19:33:18 +0200 Subject: [PATCH 5/5] fix(auth): isolate external tenant namespaces --- docs/fork/patches.md | 11 +++++++---- helm/codeapi/README.md | 4 +++- service/src/auth/librechat-jwt.test.ts | 21 ++++++++++++++++----- service/src/auth/librechat-jwt.ts | 21 ++++++++++++++++++++- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/fork/patches.md b/docs/fork/patches.md index 820a6d28..b3ed2e88 100644 --- a/docs/fork/patches.md +++ b/docs/fork/patches.md @@ -290,7 +290,8 @@ Required behavior: - Preserve the legacy single-issuer environment contract when no modern trust table is configured. - Support reusable external principal sources through the bounded lowercase - `external:` namespace without embedding a consumer-specific source. + `external:` namespace without embedding a consumer-specific source, + and isolate their tenant storage namespaces by that validated source. Owned paths: @@ -333,6 +334,8 @@ Replay and drop condition: - Every one of the 23 paths in the active merge-base-to-fork final-tree diff is assigned above. The chart values, package resources, worker deployment, queue module, and two routers are named shared seams in every contributing patch. -- Fork-authored non-merge commits were collapsed into the seven logical final - behaviors above. The only fork merge commit is classified as history-only; - no fork-authored final-tree path is left unowned. +- Fork-authored non-merge commits were collapsed into the eight logical final + behaviors above. The issuer-trust package adds three owned paths outside the + original 23-path audit and shares the existing Helm README path. The only + fork merge commit is classified as history-only; no fork-authored final-tree + path is left unowned. diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index 78bfc29f..8dbeab1c 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -126,7 +126,9 @@ Each modern trust entry binds one exact issuer to accepted audiences, key IDs, algorithms, and principal sources. Key IDs must be globally unique across entries, and every loaded key must belong to exactly one entry. External issuers use a lowercase `external:` principal source configured for that -entry. +entry. Each external source may belong to only one trust entry and prefixes the +verified tenant namespace, preventing identities from different issuers from +sharing storage or session keys. When `CODEAPI_JWT_TRUST_ENTRIES_JSON` is absent, the verifier preserves the legacy single-LibreChat behavior from `CODEAPI_JWT_ISSUER`, diff --git a/service/src/auth/librechat-jwt.test.ts b/service/src/auth/librechat-jwt.test.ts index 8a73265f..233ba702 100644 --- a/service/src/auth/librechat-jwt.test.ts +++ b/service/src/auth/librechat-jwt.test.ts @@ -263,11 +263,11 @@ describe('LibreChat JWT auth provider', () => { aud: 'partner-codeapi', principal_source: 'external:partner', }); - expect( - verifyLibreChatJwt( - signJwt(partnerClaims, { kid: 'partner-kid' }, partner.privateKey), - ).principalSource, - ).toBe('external:partner'); + const partnerPrincipal = verifyLibreChatJwt( + signJwt(partnerClaims, { kid: 'partner-kid' }, partner.privateKey), + ); + expect(partnerPrincipal.principalSource).toBe('external:partner'); + expect(partnerPrincipal.tenantId).toBe('external:partner:tenant_abc'); expectJwtReason(signJwt(partnerClaims), 'unknown_kid'); expectJwtReason( @@ -337,6 +337,17 @@ describe('LibreChat JWT auth provider', () => { trustEntry({ issuer: 'second', keyIds: ['test-kid'] }), ]); expectJwtReason(signJwt(baseClaims()), 'config'); + + setModernTrustEntries([ + trustEntry({ principalSources: ['external:shared'] }), + trustEntry({ + issuer: 'second', + audiences: ['second-codeapi'], + keyIds: ['second-kid'], + principalSources: ['external:shared'], + }), + ]); + expectJwtReason(signJwt(baseClaims()), 'config'); }); test('reloads modern trust metadata immediately when its fingerprint changes', () => { diff --git a/service/src/auth/librechat-jwt.ts b/service/src/auth/librechat-jwt.ts index 18edf37f..e17d2be3 100644 --- a/service/src/auth/librechat-jwt.ts +++ b/service/src/auth/librechat-jwt.ts @@ -341,6 +341,7 @@ function parseModernTrustEntries(keys: Map, raw: string) const entries = new Map(); const assignedKeyIds = new Set(); + const assignedExternalSources = new Set(); for (const [index, value] of parsed.entries()) { if (value === null || typeof value !== 'object' || Array.isArray(value)) { throw new CodeApiJwtAuthError('config', `JWT trust entry ${index} must be an object`); @@ -371,6 +372,18 @@ function parseModernTrustEntries(keys: Map, raw: string) if (!sourceValues.every(isSupportedPrincipalSource)) { throw new CodeApiJwtAuthError('config', `JWT trust entry ${index} has an unsupported principal source`); } + for (const source of sourceValues) { + if (!source.startsWith('external:')) { + continue; + } + if (assignedExternalSources.has(source)) { + throw new CodeApiJwtAuthError( + 'config', + `External principal source is assigned to multiple trust entries: ${source}`, + ); + } + assignedExternalSources.add(source); + } const allowedAlgs = new Set(algorithmValues); for (const keyId of keyIds) { if (assignedKeyIds.has(keyId)) { @@ -574,6 +587,12 @@ function assertPrincipalSource(value: unknown, accepted: Set throw new CodeApiJwtAuthError('malformed_claims', 'principal_source is not accepted'); } +function tenantNamespace(tenantId: string, principalSource: JwtPrincipalSource): string { + return principalSource.startsWith('external:') + ? `${principalSource}:${tenantId}` + : tenantId; +} + function validateClaims( claims: LibreChatJwtClaims, config: VerificationConfig, @@ -581,13 +600,13 @@ function validateClaims( ): CodeApiPrincipal { const now = Math.floor(Date.now() / 1000); const userId = assertString(claims.sub, 'sub'); - const tenantId = resolveTenantIdClaim(claims.tenant_id); const jti = assertString(claims.jti, 'jti'); const iat = assertNumericDate(claims.iat, 'iat'); const nbf = assertNumericDate(claims.nbf, 'nbf'); const exp = assertNumericDate(claims.exp, 'exp'); const planId = optionalString(claims.plan_id, 'plan_id'); const principalSource = assertPrincipalSource(claims.principal_source, trustEntry.principalSources); + const tenantId = tenantNamespace(resolveTenantIdClaim(claims.tenant_id), principalSource); const authContextHash = assertString(claims.auth_context_hash, 'auth_context_hash'); if (jti.length > 256) {