diff --git a/helm/codeapi/README.md b/helm/codeapi/README.md index 77486df9..b1a3b57e 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -125,6 +125,12 @@ 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 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 0725d9da..89747dfc 100644 --- a/service/src/auth/librechat-jwt.test.ts +++ b/service/src/auth/librechat-jwt.test.ts @@ -379,6 +379,60 @@ 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('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'); + }); + + 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('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', () => { 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..488e8d8a 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 { @@ -171,12 +196,27 @@ 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?: 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,24 +229,31 @@ 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) { + 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' }), - }); + 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 } : {}), + }); } } @@ -230,7 +277,7 @@ function loadPublicKeyDir( if (!kid) { continue; } - keys.set(kid, { + addKey(keys, kid, { key: publicKeyFromValue(readFileSync(fullPath, 'utf8')), }); } @@ -267,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; @@ -276,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) { @@ -635,7 +682,20 @@ export function verifyLibreChatJwt(token: string): CodeApiPrincipal { 'JWT signature is invalid', ); } - return validateClaims(claims, config); + const principal = validateClaims(claims, config); + /* 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', + ); + } + return principal; } export class LibreChatJwtAuthProvider implements AuthProvider { 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); });