From cb089f3332faf7a0f63042679712ce17aa7e494c Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 10:35:19 +0000 Subject: [PATCH 1/2] feat(auth): bind a JWKS key to the tenants it may sign for A JWKS entry may carry a tenants list; tokens signed by that key are accepted only for those tenant_id values (reason tenant_not_allowed, 401). A bound key never falls back to the single-tenant namespace. A malformed list is a configuration error at startup, never an unbound key. Keys without the member behave as before. --- helm/codeapi/README.md | 4 +++ service/src/auth/librechat-jwt.test.ts | 29 +++++++++++++++++ service/src/auth/librechat-jwt.ts | 44 ++++++++++++++++++++++---- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index 77486df9..34788161 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -125,6 +125,10 @@ api: `CODEAPI_JWT_PUBLIC_KEYS_DIR` (a mounted directory of PEM files) and `CODEAPI_JWT_JWKS_JSON` (inline JWKS) are also supported for key rotation. +A JWKS entry may carry a `tenants` member, a non-empty list of `tenant_id` +values; tokens signed by that key are then accepted only for those tenants +(for example, a staging key bound to the staging tenant). A malformed list +fails startup rather than trusting the key for every tenant. 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 0725d9da..56c304a9 100644 --- a/service/src/auth/librechat-jwt.test.ts +++ b/service/src/auth/librechat-jwt.test.ts @@ -379,6 +379,35 @@ describe('LibreChat JWT auth provider', () => { ); }); + describe('tenant-bound keys', () => { + function bindTestKey(tenants: unknown): void { + const jwks = JSON.parse(process.env.CODEAPI_JWT_JWKS_JSON!) as { keys: object[] }; + process.env.CODEAPI_JWT_JWKS_JSON = JSON.stringify({ + keys: jwks.keys.map(key => ({ ...key, tenants })), + }); + } + + test('accepts a key only for the tenants it is bound to', () => { + bindTestKey(['tenant_staging']); + expect( + verifyLibreChatJwt(signJwt(baseClaims({ tenant_id: 'tenant_staging' }))).tenantId, + ).toBe('tenant_staging'); + expectJwtReason(signJwt(baseClaims({ tenant_id: 'tenant_abc' })), 'tenant_not_allowed'); + }); + + test('does not let a bound key fall back to the single-tenant namespace', () => { + bindTestKey(['tenant_staging']); + expectJwtReason(signJwt(baseClaims({ tenant_id: undefined })), 'tenant_not_allowed'); + }); + + test('refuses a malformed binding as configuration instead of trusting the key unbound', () => { + for (const tenants of [[], 'tenant_staging', [''], [' tenant_staging'], [42]]) { + bindTestKey(tenants); + expectJwtReason(signJwt(baseClaims({ tenant_id: 'tenant_staging' })), 'config'); + } + }); + }); + test('rejects tampered signatures and malformed required claims', () => { const token = signJwt(baseClaims()); const [encodedHeader, encodedPayload, encodedSignature] = token.split( diff --git a/service/src/auth/librechat-jwt.ts b/service/src/auth/librechat-jwt.ts index 497b18bb..3a7e192d 100644 --- a/service/src/auth/librechat-jwt.ts +++ b/service/src/auth/librechat-jwt.ts @@ -51,6 +51,31 @@ interface LibreChatJwtClaims { interface PublicKeyEntry { alg?: JwtAlg; key: KeyObject | Buffer; + /** When set, the only tenant_id values this key may sign for. */ + tenants?: ReadonlySet; +} + +type JwksEntry = JsonWebKey & { kid?: string; alg?: string; tenants?: unknown }; + +/** + * A JWKS entry may bind its key to tenants with a `tenants` member: a non-empty + * list of tenant_id values. A token signed by that key is then accepted only + * for those tenants. A malformed list is a configuration error, never an + * unbound key. + */ +function parseKeyTenants(kid: string, value: unknown): ReadonlySet | undefined { + if (value === undefined) return undefined; + if ( + !Array.isArray(value) || + value.length === 0 || + !value.every(tenant => typeof tenant === 'string' && tenant.trim() === tenant && tenant !== '') + ) { + throw new CodeApiJwtAuthError( + 'config', + `CodeAPI JWT key ${kid} tenants must be a non-empty list of tenant ids`, + ); + } + return new Set(value as string[]); } interface VerificationConfig { @@ -172,11 +197,9 @@ function publicKeyFromValue(value: string): KeyObject { } function loadJwks(keys: Map, raw: string): void { - let parsed: { keys?: Array }; + let parsed: { keys?: JwksEntry[] }; try { - parsed = JSON.parse(raw) as { - keys?: Array; - }; + parsed = JSON.parse(raw) as { keys?: JwksEntry[] }; } catch { throw new CodeApiJwtAuthError( 'config', @@ -189,10 +212,11 @@ function loadJwks(keys: Map, raw: string): void { 'CODEAPI_JWT_JWKS_JSON must contain a keys array', ); } - for (const jwk of parsed.keys) { + for (const { tenants, ...jwk } of parsed.keys) { if (!jwk.kid) { continue; } + const allowedTenants = parseKeyTenants(jwk.kid, tenants); try { keys.set(jwk.kid, { alg: @@ -200,6 +224,7 @@ function loadJwks(keys: Map, raw: string): void { ? jwk.alg : undefined, key: createPublicKey({ key: jwk, format: 'jwk' }), + ...(allowedTenants ? { tenants: allowedTenants } : {}), }); } catch { throw new CodeApiJwtAuthError( @@ -635,7 +660,14 @@ export function verifyLibreChatJwt(token: string): CodeApiPrincipal { 'JWT signature is invalid', ); } - return validateClaims(claims, config); + const principal = validateClaims(claims, config); + if (key.tenants && !key.tenants.has(principal.tenantId)) { + throw new CodeApiJwtAuthError( + 'tenant_not_allowed', + 'JWT tenant is not allowed for this key', + ); + } + return principal; } export class LibreChatJwtAuthProvider implements AuthProvider { From 25ea2c8232e60da0f121b9d7e4383b0252e11ef0 Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 10:53:16 +0000 Subject: [PATCH 2/2] fix(auth): bound keys require a signed tenant_id and cannot be replaced Review repairs for the tenant binding: a bound key is checked against the signed tenant_id, so a token without one is refused even when the default namespace is listed; a bound kid configured again in any key source fails startup instead of being silently replaced by an unbound copy; a tenants list on an entry without a kid fails startup. Drops two prose pins (synthetic weak-token message, rate-limit message). --- helm/codeapi/README.md | 8 ++-- service/src/auth/librechat-jwt.test.ts | 27 ++++++++++++- service/src/auth/librechat-jwt.ts | 52 ++++++++++++++++++++------ service/src/auth/synthetic.test.ts | 4 +- service/src/middleware/limits.test.ts | 1 - 5 files changed, 72 insertions(+), 20 deletions(-) diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index 34788161..b1a3b57e 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -126,9 +126,11 @@ api: `CODEAPI_JWT_PUBLIC_KEYS_DIR` (a mounted directory of PEM files) and `CODEAPI_JWT_JWKS_JSON` (inline JWKS) are also supported for key rotation. A JWKS entry may carry a `tenants` member, a non-empty list of `tenant_id` -values; tokens signed by that key are then accepted only for those tenants -(for example, a staging key bound to the staging tenant). A malformed list -fails startup rather than trusting the key for every tenant. +values; tokens signed by that key are then accepted only when they carry one +of those `tenant_id` values explicitly (for example, a staging key bound to the +staging tenant). A malformed list, a list on an entry without a `kid`, or a +bound `kid` configured again in another key source fails startup rather than +trusting the key for every tenant. 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 56c304a9..89747dfc 100644 --- a/service/src/auth/librechat-jwt.test.ts +++ b/service/src/auth/librechat-jwt.test.ts @@ -395,7 +395,10 @@ describe('LibreChat JWT auth provider', () => { expectJwtReason(signJwt(baseClaims({ tenant_id: 'tenant_abc' })), 'tenant_not_allowed'); }); - test('does not let a bound key fall back to the single-tenant namespace', () => { + test('requires a signed tenant_id even when the default namespace is listed', () => { + bindTestKey(['legacy']); + expectJwtReason(signJwt(baseClaims({ tenant_id: undefined })), 'tenant_not_allowed'); + process.env.CODEAPI_JWT_SINGLE_TENANT_ID = 'tenant_staging'; bindTestKey(['tenant_staging']); expectJwtReason(signJwt(baseClaims({ tenant_id: undefined })), 'tenant_not_allowed'); }); @@ -406,6 +409,28 @@ describe('LibreChat JWT auth provider', () => { expectJwtReason(signJwt(baseClaims({ tenant_id: 'tenant_staging' })), 'config'); } }); + + test('refuses a binding on an entry without a kid', () => { + const jwks = JSON.parse(process.env.CODEAPI_JWT_JWKS_JSON!) as { keys: Array> }; + const { kid: _kid, ...unnamed } = jwks.keys[0]!; + process.env.CODEAPI_JWT_JWKS_JSON = JSON.stringify({ + keys: [...jwks.keys, { ...unnamed, tenants: [] }], + }); + expectJwtReason(signJwt(baseClaims()), 'config'); + }); + + test('refuses a second source for a bound kid instead of letting it replace the binding', () => { + bindTestKey(['tenant_staging']); + const bound = JSON.parse(process.env.CODEAPI_JWT_JWKS_JSON!) as { keys: Array> }; + const { tenants: _tenants, ...unbound } = bound.keys[0]!; + process.env.CODEAPI_JWT_JWKS_JSON = JSON.stringify({ keys: [bound.keys[0], unbound] }); + expectJwtReason(signJwt(baseClaims({ tenant_id: 'tenant_abc' })), 'config'); + + process.env.CODEAPI_JWT_JWKS_JSON = JSON.stringify({ keys: [bound.keys[0]] }); + process.env.CODEAPI_JWT_PUBLIC_KEY = JSON.stringify(unbound); + process.env.CODEAPI_JWT_KID = 'test-kid'; + expectJwtReason(signJwt(baseClaims({ tenant_id: 'tenant_abc' })), 'config'); + }); }); test('rejects tampered signatures and malformed required claims', () => { diff --git a/service/src/auth/librechat-jwt.ts b/service/src/auth/librechat-jwt.ts index 3a7e192d..488e8d8a 100644 --- a/service/src/auth/librechat-jwt.ts +++ b/service/src/auth/librechat-jwt.ts @@ -196,6 +196,23 @@ function publicKeyFromValue(value: string): KeyObject { } } +/** + * Adds one verification key. A kid configured twice (two JWKS entries, the + * key directory, CODEAPI_JWT_PUBLIC_KEY, the HS256 secret) is refused when + * either copy is tenant-bound: the later source would otherwise replace a + * bound key with an unbound one. + */ +function addKey(keys: Map, kid: string, entry: PublicKeyEntry): void { + const existing = keys.get(kid); + if (existing && (existing.tenants || entry.tenants)) { + throw new CodeApiJwtAuthError( + 'config', + `CodeAPI JWT key ${kid} is bound to tenants and configured more than once`, + ); + } + keys.set(kid, entry); +} + function loadJwks(keys: Map, raw: string): void { let parsed: { keys?: JwksEntry[] }; try { @@ -214,24 +231,29 @@ function loadJwks(keys: Map, raw: string): void { } for (const { tenants, ...jwk } of parsed.keys) { if (!jwk.kid) { + if (tenants !== undefined) { + throw new CodeApiJwtAuthError( + 'config', + 'A CODEAPI_JWT_JWKS_JSON entry with tenants must have a kid', + ); + } continue; } const allowedTenants = parseKeyTenants(jwk.kid, tenants); + let key: KeyObject; try { - keys.set(jwk.kid, { - alg: - jwk.alg === 'EdDSA' || jwk.alg === 'RS256' - ? jwk.alg - : undefined, - key: createPublicKey({ key: jwk, format: 'jwk' }), - ...(allowedTenants ? { tenants: allowedTenants } : {}), - }); + key = createPublicKey({ key: jwk, format: 'jwk' }); } catch { throw new CodeApiJwtAuthError( 'config', `CodeAPI JWT public key ${jwk.kid} is invalid`, ); } + addKey(keys, jwk.kid, { + alg: jwk.alg === 'EdDSA' || jwk.alg === 'RS256' ? jwk.alg : undefined, + key, + ...(allowedTenants ? { tenants: allowedTenants } : {}), + }); } } @@ -255,7 +277,7 @@ function loadPublicKeyDir( if (!kid) { continue; } - keys.set(kid, { + addKey(keys, kid, { key: publicKeyFromValue(readFileSync(fullPath, 'utf8')), }); } @@ -292,7 +314,7 @@ function loadKeys(): Map { '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; @@ -301,7 +323,7 @@ function loadKeys(): Map { 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) { @@ -661,7 +683,13 @@ export function verifyLibreChatJwt(token: string): CodeApiPrincipal { ); } const principal = validateClaims(claims, config); - if (key.tenants && !key.tenants.has(principal.tenantId)) { + /* A bound key decides on the tenant it signed, never on a configured + * default: a token without tenant_id is refused even if the default + * namespace happens to be listed. */ + if ( + key.tenants && + (typeof claims.tenant_id !== 'string' || !key.tenants.has(claims.tenant_id)) + ) { throw new CodeApiJwtAuthError( 'tenant_not_allowed', 'JWT tenant is not allowed for this key', diff --git a/service/src/auth/synthetic.test.ts b/service/src/auth/synthetic.test.ts index 638f9485..115f7413 100644 --- a/service/src/auth/synthetic.test.ts +++ b/service/src/auth/synthetic.test.ts @@ -86,9 +86,7 @@ describe('synthetic CodeAPI auth', () => { expect( authenticateSyntheticRequest(req({ [CODEAPI_SYNTHETIC_AUTH_HEADER]: 'weak-token' }), 'weak-token'), ).toMatchObject({ ok: false, status: 500, reason: 'weak_config' }); - expect(() => validateSyntheticAccessTokenConfig('weak-token')).toThrow( - 'CODEAPI_SYNTHETIC_ACCESS_TOKEN must be at least 32 bytes', - ); + expect(() => validateSyntheticAccessTokenConfig('weak-token')).toThrow(); }); test('supports explicit synthetic identity overrides', () => { diff --git a/service/src/middleware/limits.test.ts b/service/src/middleware/limits.test.ts index e7c1c46f..b1b69009 100644 --- a/service/src/middleware/limits.test.ts +++ b/service/src/middleware/limits.test.ts @@ -179,7 +179,6 @@ describe('execution rate limiting', () => { expect(rejected.headers.get('ratelimit-remaining')).toBe('0'); expect(Number(rejected.headers.get('retry-after'))).toBeGreaterThan(0); expect(body.error).toBe('rate_limited'); - expect(body.message).toContain('Too many CodeAPI execution requests.'); expect(body.retry_after_seconds).toBeGreaterThan(0); });