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
1 change: 1 addition & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:-}
Expand Down
55 changes: 48 additions & 7 deletions docs/fork/patches.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:<slug>` 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;
Expand All @@ -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.
17 changes: 16 additions & 1 deletion helm/codeapi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<slug>` 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`.

Expand Down
148 changes: 145 additions & 3 deletions service/src/auth/librechat-jwt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -52,6 +53,7 @@ type JwtClaims = {

const originalEnv = new Map<string, string | undefined>();
let privateKey: KeyObject;
let publicJwk: JsonWebKey;

function base64Url(value: Buffer | string): string {
return Buffer.from(value).toString('base64url');
Expand Down Expand Up @@ -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<string, unknown> = {}): Record<string, unknown> {
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) {
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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 })));

Expand Down
Loading