Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions helm/codeapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
54 changes: 54 additions & 0 deletions service/src/auth/librechat-jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> };
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<Record<string, unknown>> };
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(
Expand Down
92 changes: 76 additions & 16 deletions service/src/auth/librechat-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
}

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<string> | 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 {
Expand Down Expand Up @@ -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<string, PublicKeyEntry>, 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<string, PublicKeyEntry>, raw: string): void {
let parsed: { keys?: Array<JsonWebKey & { kid?: string; alg?: string }> };
let parsed: { keys?: JwksEntry[] };
try {
parsed = JSON.parse(raw) as {
keys?: Array<JsonWebKey & { kid?: string; alg?: string }>;
};
parsed = JSON.parse(raw) as { keys?: JwksEntry[] };
} catch {
throw new CodeApiJwtAuthError(
'config',
Expand All @@ -189,24 +229,31 @@ function loadJwks(keys: Map<string, PublicKeyEntry>, 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 } : {}),
});
}
}

Expand All @@ -230,7 +277,7 @@ function loadPublicKeyDir(
if (!kid) {
continue;
}
keys.set(kid, {
addKey(keys, kid, {
key: publicKeyFromValue(readFileSync(fullPath, 'utf8')),
});
}
Expand Down Expand Up @@ -267,7 +314,7 @@ function loadKeys(): Map<string, PublicKeyEntry> {
'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;
Expand All @@ -276,7 +323,7 @@ function loadKeys(): Map<string, PublicKeyEntry> {
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) {
Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 1 addition & 3 deletions service/src/auth/synthetic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 0 additions & 1 deletion service/src/middleware/limits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
Loading