Repository navigation
Feature/key custody - #16813
Draft
keltschdt wants to merge 4 commits into
Draft
Feature/key custody#16813keltschdt wants to merge 4 commits into
keltschdt wants to merge 4 commits into
Conversation
Contributor
|
👋 Thanks for the contribution! LibreChat merges all changes into Nothing is needed from you; your commits, reviews and discussion are unchanged. If the diff now shows files you did not touch, rebase onto git remote add upstream https://github.com/LibreChat-AI/LibreChat.git
git fetch upstream dev
git rebase upstream/dev
git push --force-with-leaseMaintainers: apply the |
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Pull Request
This is stage 2 in a family of drafts. Stage 1 is contained in #16448
Summary
When
OPENID_REUSE_TOKENSis on and a user logs in with OpenID, the server today keeps the IdP token set in four places it can read back:req.session.openidTokens, the browserrefreshTokencookie, arefreshtokenbridgesdocument, and the single-flight result inopenidrefreshflights. Every one is plaintext or decryptable withCREDS_KEY, which the process already has. So anyone who reads a database or backup dump together with the process environment gets working IdP refresh tokens for every session in that dump. That is a bulk leak that needs no live access.It happens under normal use:
OPENID_REUSE_TOKENSon, an OpenID login, and later read access to a dump.After this change the server keeps no readable copy of the token set. On login it mints a 32-byte key, sends it to the browser only in the httpOnly
openid_token_keycookie, and seals the token set with AES-256-GCM under that key into onetokencustodiesrecord keyed bysha256(key)(tokenKeyHash). It also signs a marker cookieopenid_user_idholding{ id, tokenKeyHash }. The key is never written to any record, session, flight, or log, so the stored blob cannot be opened with the database orCREDS_KEYalone. The cookie value stays the same across token rotation, so a rotation re-seals the existing record instead of issuing a new key. The refresh-token bridge is no longer needed, and is removed. TOTP secrets move under a separateTOTP_KEYthat defaults toCREDS_KEY(this separation allows for a future split-container setup where an auth-only instance must not have access to CREDS_KEY).Because the server no longer keeps a readable token set, every feature that forwarded the user's IdP tokens is moved onto the same custody record, obtained lazily. The OpenID placeholders (
{{LIBRECHAT_OPENID_ACCESS_TOKEN}},_ID_TOKEN,_TOKEN,_USER_ID,_USER_EMAIL,_USER_NAME,_EXPIRES_AT), the Microsoft Graph placeholder ({{LIBRECHAT_GRAPH_ACCESS_TOKEN}}), and the Graph token endpoint (GET /api/auth/graph-token, the SharePoint picker) resolve the live token set through the per-request custody loader instead ofreq.user.federatedTokens. A request opens custody only when it actually resolves a token-bearing placeholder, exchanges a Graph token, or runs OBO / direct bearer, and all of those share the one memoized custody read per request; an ordinary message or conversation request opens custody never. This closed a regression the custody migration would otherwise introduce: a custody-login browser session carries the id_token as its Bearer, so itsfederatedTokenssnapshot is empty, and without this change those placeholders would raiseOpenIDReauthRequiredErroror resolve empty and the Graph endpoint would answer 401.How it works
Login mints the key and writes the sealed record. Refresh rotates the sealed record under the same key. File authorization proves the browser holds the key without ever opening the blob. The per-request loader reads the store at most once, and file authorization reads only projected metadata, never the sealed bytes.
sequenceDiagram participant Browser participant API participant Store as tokencustodies participant IdP Browser->>API: OpenID login (setOpenIDAuthTokens) API->>API: generateTokenKey + hashTokenKey API->>Store: createCustody -> sealed record (rotationCounter 0) API->>Browser: Set-Cookie openid_token_key + openid_user_id Browser->>API: POST /api/auth/refresh API->>API: loadOpenIDCustody -> openCustody (one read, memoized) API->>IdP: refresh grant (only when the token is not fresh) IdP->>API: new token set API->>Store: rotateCustody -> compare-and-set on rotationCounter API->>Browser: re-issue openid_token_key (same value, new expiry) Browser->>API: GET /images/:ownerId/:filename API->>API: verifyCustodyBinding -> findTokenCustodyMeta (blob untouched) API->>Browser: authorized fileThe behavior lives in
packages/apiandpackages/data-schemas. The/apiCJS files keep only requires, route registration, and calls into those modules.The change also removes the RefreshTokenBridge store and its modules, config, and drift detection; the OpenID
sessionsrow writes; and the plaintext single-flight result (theopenidrefreshflightsentry is now sealed under the session key).Three behavior changes land alongside the token-source move. The custody record supplies the tenant: reads address the record by
tokenKeyHashalone (the unique index identifies one record), and cross-tenant protection is a comparison of the record's owntenantIdafter the read against the tenant the caller knows, so the unauthenticated/api/auth/refresh— which knows no tenant up front — opens a tenant-stamped record and runs under the tenant it reads back; this fixes tenant users being forced to re-login on every refresh. A refresh whose record is deleted by a concurrent logout mid-rotation fails closed:rotateCustodyreportsgone(compare-and-set matched nothing and the reload found no live record) rather than reviving the pre-rotation session, the refresh is rejected session-missing with no cookie re-issued, and the refresh token the IdP just granted — which the rotation never persisted — gets one best-effort IdP revocation so it is not stranded live. The publication-generation response-delivery guard (withOpenIDResponseDeliveryand the flight delivery methods /publicationFlight*fields) is removed, because the custody record is now the liveness check: refresh proves liveness through the compare-and-set, and the Graph endpoint recheckscustodyExistsimmediately before sending.Lazy versus eager. The lazy design above resolves tokens only on requests that forward them, keeping ordinary requests off the custody store. The alternative — a middleware on every MCP-reaching route that opens custody and fills
req.user.federatedTokens, leaving all MCP and Graph readers untouched — was not taken: it opens custody (one indexed read plus one AES-GCM open) on every such request whether or not any token is used, which covers most chat traffic and counts against the Lighthouse query budget, and its snapshot does not inline-refresh, so a long stream can hold an expired token. Eager is only viable with a per-runner, in-memory cache of the sealed record (ciphertext plus metadata, keyed bytokenKeyHash, short TTL, opened per request with the cookie's key so no plaintext outlives the request); that cache's cost is that a logout, ban or deletion on one runner is not seen by another runner's cache until its TTL expires. The same sealed-record cache is the deferred option for the lazy path if the one read per token-forwarding request ever proves to matter.Type of change
Testing
npx tsc --noEmitis clean inpackages/apiandpackages/data-schemas.Tests pass:
packages/apicustody/openid/utils/mcp: 45 suites, 829 tests (AEAD round-trip, wrong key, associated data, hash binding, nondeterminism, tamper; service lifetime/counter/stability/non-destructive; loader and binding; thegonerotation outcome and orphan-token revocation;resolveOpenIDPlaceholderTokensincluding Property 19 — custody is opened only for token-bearing placeholders — as an exhaustive placeholder × placement × source matrix;processMCPEnv/extractOpenIDTokenInforesolved-token precedence; the Graph placeholder precedence; OBO delegation).packages/data-schemastokenCustody and openidRefreshFlight: 33 tests (real unique and TTL indexes, theexpiresAt > nowread, the atomic compare-and-set under concurrency, lookup by hash alone with the tenant returned on the record,findTokenCustodyMetaprojectinguserId/tenantId, the by-user delete tenant filter both ways, index install withMONGO_AUTO_INDEX=false, flight behavior with the delivery methods removed).apiCJS auth suites: 167 tests acrossAuthController(refresh custody path, tenant-stamped refresh, record-deleted-mid-rotation, graph controller onto custody with thecustodyExistsrecheck),AuthController.custody.route,LogoutController,OpenIDRefreshFlight,openIdJwtStrategy(emptyfederatedTokenssnapshot, legacy session/cookie reads removed), andbanViolation.npm run sort-importsapplied to the touched paths only.npm run lighthouse(the LCP budget lane: in-memory Mongo, 250 ms added per query) passed locally, run in a clean worktree at this head with no.envand anen-USbrowser locale: 1 passed, medians LCP 3840.92 ms / 4500, CLS 0.0169 / 0.1, TBT 52.8 ms / 500. The unmodified base under identical conditions measured LCP 3971.608 ms, CLS 0.0169, TBT 48.2 ms, so the change sits within run-to-run noise. Locally the lane failed in global setup at the "Sign up" link whenever a repo-root.envdisabled email login or the OS locale was not English; both reproduced on the base too. This branch pins theen-USlocale wherever a browser starts outside Playwright Test's ownen-USdefault: the setup pages ine2e/setup/authenticate.tsande2e/setup/record.js, and the Chrome the Lighthouse CLI launches ine2e/lighthouse/audit.ts(--accept-lang=en-US, because--langis ignored on macOS). A.envthat disables email login is overridden by exportingALLOW_EMAIL_LOGIN=true ALLOW_REGISTRATION=true. With these in place,npm run lighthouse:runpasses from a checkout with its.envpresent (LCP 3787.5 ms, CLS 0.0169, TBT 59.5 ms). The loader reads the store at most once per request, file authorization is one projectedfindTokenCustodyMetaread that never opens the blob, refresh reuses the loaded context, and no frontend or message-loading code changed.Risk / compatibility
One-time re-login. There is no read-compatibility path: every active OpenID session logs in once more at rollout. A request with only an old
refreshTokencookie, or a session holdingopenidTokens, or a marker with only arefreshTokenHashclaim gets401 { code: 'OPENID_SESSION_MISSING' }.No user-facing change beyond that re-login, which shows the normal login screen.
Record lifetime is not a new setting. It is derived per token response:
max(refreshTokenExpiresAt, accessTokenExpiresAt), ormax(receipt time + REFRESH_TOKEN_EXPIRY, accessTokenExpiresAt)when the response gives no refresh-token expiry, recomputed on every rotation.REFRESH_TOKEN_EXPIRYis only the fallback term; its local-auth role (thesessionsrow expiry and the localrefreshTokencookie lifetime) is unchanged.OPENID_REFRESH_BRIDGE_GRACE_MSis removed with the bridge. The server behaves the same whether it was set or not.Token forwarding, tenant, and the Graph endpoint
MCP servers and the SharePoint picker that forward the user's IdP tokens keep working, now sourced from the custody record. A chat request that drives an MCP server configured with an
obo:block, anAuthorization: Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}header, or any{{LIBRECHAT_OPENID_*}}/{{LIBRECHAT_GRAPH_ACCESS_TOKEN}}placeholder now performs one indexed custody read (shared across all of them for that request); a request that forwards no IdP token performs none, and nothing is added to the page-startup path. On the base, every request paid a session-store read for the same data, so single-tenant and non-MCP traffic is flat or lighter.Multi-tenant deployments: tenant users can now refresh. The record carries its own
tenantId, the refresh looks the record up by key and reads the tenant back, andassertOpenIDSessionIdentityMatchstill rejects a cross-tenant record before any IdP call. No new header or claim is required; this matches how the tenant used to come from the server-held session.GET /api/auth/graph-tokenbehaves as before for a live session, and answers401 { code: 'OPENID_SESSION_MISSING' }(clearing the OpenID cookies) when no live custody record backs the request, instead of the previous "No federated access token" 401. The remote-agent bearer path, which carries its own verified upstream token and has no custody record, is unchanged.One deviation worth noting for reviewers: the id-token-preference refresh follow-up is not performed. The upstream provider's
tokenPreferenceis fixed at creation and the MCP call sites build it once withaccess_tokenpreference; when a config carries both an access-type placeholder and{{LIBRECHAT_OPENID_ID_TOKEN}}, the id token from the access-preference resolution is used as-is, andprocessOpenIDPlaceholdersvalidates its own expiry and raisesOpenIDReauthRequiredErrorif it is stale — a fail-closed, actionable outcome. Threading a per-preference provider factory through the four MCP layers was judged out of proportion; revisit if a deployment needs the id token specifically force-refreshed.TOTP_KEY and the migration
Leave
TOTP_KEYunset and nothing changes: the key resolves toCREDS_KEY, existing TOTP secrets (v3, v2, and bare) keep verifying, and no migration is needed. The migration's--reencrypt-totpstep refuses to run in this case, because there is nothing to move.Setting
TOTP_KEYto a new value is what needs the migration. Existing secrets are still sealed underCREDS_KEY, so they must be re-encrypted under the new key with--reencrypt-totp. The step moves only the secrets that open underCREDS_KEYbut not underTOTP_KEY; it skips ones already underTOTP_KEYand leaves alone ones that open under neither (re-encrypting those would seal garbage over the real secret). Each moved secret is written as av3:blob — the format change is a side effect of re-encrypting, not a separate cleanup pass. Plaintext is unchanged, so a user who verified before keeps verifying once the server can read the secret under the new key.There is an unavoidable transition window, and its ordering does not remove it. Two things have to change: the server's key (
CREDS_KEY→TOTP_KEY, which needs a restart) and the secrets at rest (re-encrypted by the migration, which takes time). A secret verifies only when the server's key matches the secret's key, so every affected user fails 2FA during the gap between those two changes — whichever you do first:TOTP_KEYwhile the servers still readCREDS_KEY, so everyone is broken until you restart the servers.TOTP_KEYwhile the secrets are still onCREDS_KEY, so everyone is broken until the migration finishes.Either way the first action breaks affected users and the second heals them; both are required. This code resolves a single key with no fallback, so it cannot try
TOTP_KEYthenCREDS_KEY, which is the only thing that would close the window. Plan the two actions close together and expect 2FA failures in between. Keep the window short.One safety point about the migration itself:
--reencrypt-totpmust be able to read the current secrets to move them, so run it withCREDS_KEYstill available. If it cannot read a secret under the resolved key it treats it as "opens under neither key" and skips it, which would silently strand that secret.Access tokens in the custody
With only the database, a custody record cannot be opened:
sealedTokensis AES-256-GCM under the browser's key, and the record holds justsha256(key). Given theopenid_token_keycookie value of one session (for debugging that session), this mongosh script finds the record and decrypts it. It uses the same steps askey.tsandaead.ts: hash the 32 raw key bytes, look up bytokenKeyHash, then open thekc1:iv:tag:ciphertextblob with the record's identity as associated data.Run it as a file (
mongosh LibreChat --file custody-decrypt.js). A long--evalor a multi-line paste into the interactive shell can mangle it. The output isaccessToken,idToken,refreshToken,accessTokenExpiresAt,refreshTokenExpiresAtandissuedAt;refreshTokenExpiresAtis absent when the IdP sends norefresh_expires_in(Entra ID, for example). A wrong key or an identity field that differs from the sealed one fails withUnsupported state or unable to authenticate data, and GCM returns no partial plaintext. The printed refresh token is live, so treat the output like a password; printpayload.refreshToken.lengthto check without exposing it.Accepted residual risks (by design, not fixed here)
ADMIN_OAUTH_EXCHANGEholds a plaintext IdP refresh token for 30 seconds, single use, in the admin exchange-code cache. Closing it is a separate change.CREDS_KEY, by scope decision.TOTP_KEY(defaultCREDS_KEY): the verifying request is not authenticated yet, so no user-held key is available.openid_token_keycookie is no more exposed than therefreshTokencookie it replaces, but do not log cookie headers.TOTP_KEYchange has the transition window described above; a single-key resolver cannot avoid it.Checklist
Joachim Keltsch on behalf of Daimler Truck AG.
Provider & Legal Notice | Daimler Truck
Copyright (c) 2025 Daimler Truck AG