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/docs/fork/patches.md b/docs/fork/patches.md index d1c6802a..b3ed2e88 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,47 @@ 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, + and isolate their tenant storage namespaces by that validated 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; @@ -295,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 9c9d0f1d..8dbeab1c 100644 --- a/helm/codeapi/README.md +++ b/helm/codeapi/README.md @@ -109,17 +109,32 @@ 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. 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`, +`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..233ba702 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,127 @@ 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', + }); + 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( + 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'); + }); + + 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'); + }); + + 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'); + + 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', () => { + 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..e17d2be3 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,153 @@ 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(); + 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`); + } + 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`); + } + 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)) { + 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 +467,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 +518,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 +530,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,38 +579,40 @@ 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 tenantNamespace(tenantId: string, principalSource: JwtPrincipalSource): string { + return principalSource.startsWith('external:') + ? `${principalSource}:${tenantId}` + : tenantId; +} + +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'); - 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); + 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) { throw new CodeApiJwtAuthError('malformed_claims', 'jti is too long'); } - if (issuer !== config.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 +658,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 +689,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 {