From 5fecf6509c549ac875a6031e58105946bfe428b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B3n=20Levy?= Date: Tue, 29 Sep 2026 13:05:31 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=97=93=EF=B8=8F=20fix:=20Omit=20Unavailab?= =?UTF-8?q?le=20OpenID=20Headers=20for=20Scheduled=20Chats=20(opt-in)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduled Chats fire without a browser session, so `req.user` has no OpenID tokens. `resolveHeaders` then throws OpenIDReauthRequiredError for any endpoint header using {{LIBRECHAT_OPENID_ACCESS_TOKEN}} / {{LIBRECHAT_OPENID_ID_TOKEN}}, and every scheduled occurrence on such an endpoint fails before the model call. With SCHEDULES_OMIT_UNAVAILABLE_OPENID_HEADERS=true, the agents route marks the user of a scheduled fire, `createSafeUser` carries the marker, and `resolveHeaders` omits a header whose OpenID credential placeholder cannot be resolved instead of throwing, whether or not the caller strips unresolved placeholders. Valid tokens still substitute. Interactive requests and MCP resolution (`processMCPEnv`, which feeds the schedule MCP preflight) keep failing closed. Off by default. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/server/routes/agents/index.js | 8 +++++ packages/api/src/utils/env.spec.ts | 58 ++++++++++++++++++++++++++++++ packages/api/src/utils/env.ts | 26 ++++++++++++-- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index afb425e7d80..7f0a667fd89 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -18,6 +18,7 @@ const { isAgentTriggerRequest, exemptAgentTriggerFromIpLimiter, captureScheduleFireContext, + OMIT_UNAVAILABLE_OPENID_HEADERS, exemptFromUserLimiter: exemptScheduleFromUserLimiter, detectGenerationRetry, isConfirmedGenerationRetry, @@ -143,6 +144,13 @@ router.use(requireJwtAuth); router.use((req, _res, next) => { req._isAgentTrigger = isAgentTriggerRequest(req); captureScheduleFireContext(req); + if ( + req._isScheduledFire && + req.user && + isEnabled(process.env.SCHEDULES_OMIT_UNAVAILABLE_OPENID_HEADERS) + ) { + req.user[OMIT_UNAVAILABLE_OPENID_HEADERS] = true; + } next(); }); router.use(checkBan); diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index a0f8c43c08e..af0e65d23d0 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -8,7 +8,9 @@ import { resolveNestedObject, processMCPEnv, encodeHeaderValue, + OMIT_UNAVAILABLE_OPENID_HEADERS, } from './env'; +import { OpenIDReauthRequiredError } from './oidc'; import { applyRequestHeaders } from '~/mcp/utils'; function isStdioOptions(options: MCPOptions): options is Extract { @@ -2645,3 +2647,59 @@ describe('processMCPEnv OpenID re-authentication signalling', () => { } }); }); + +describe('resolveHeaders with unavailable OpenID tokens for unattended requests', () => { + function tokenlessOpenIDUser(omit: boolean) { + return createSafeUser({ + ...createTestUser({ id: 'user-123', provider: 'openid' }), + openidId: 'oidc-sub-456', + email: 'me@example.com', + ...(omit && { [OMIT_UNAVAILABLE_OPENID_HEADERS]: true }), + } as unknown as IUser); + } + + const headers = { + authorization: 'Bearer {{LIBRECHAT_OPENID_ID_TOKEN}}', + 'x-openid-access-token': '{{LIBRECHAT_OPENID_ACCESS_TOKEN}}', + 'x-user-email': '{{LIBRECHAT_USER_EMAIL}}', + }; + + it('throws without the unattended marker', () => { + expect(() => + resolveHeaders({ headers, user: tokenlessOpenIDUser(false), stripUnresolved: true }), + ).toThrow(OpenIDReauthRequiredError); + }); + + it.each([true, false])( + 'omits OpenID credential headers and keeps the rest (stripUnresolved=%s)', + (stripUnresolved) => { + const result = resolveHeaders({ + headers, + user: tokenlessOpenIDUser(true), + stripUnresolved, + }); + + expect(result).toEqual({ 'x-user-email': 'me@example.com' }); + }, + ); + + it('still substitutes valid tokens when the marker is set', () => { + const user = createSafeUser({ + ...createTestUser({ id: 'user-123', provider: 'openid' }), + openidId: 'oidc-sub-456', + [OMIT_UNAVAILABLE_OPENID_HEADERS]: true, + federatedTokens: { + access_token: 'live-access-token', + expires_at: Math.floor(Date.now() / 1000) + 3600, + }, + } as unknown as IUser); + + const result = resolveHeaders({ + headers: { 'x-openid-access-token': '{{LIBRECHAT_OPENID_ACCESS_TOKEN}}' }, + user, + stripUnresolved: true, + }); + + expect(result).toEqual({ 'x-openid-access-token': 'live-access-token' }); + }); +}); diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index d880fedfdae..57cdb948d9b 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -106,6 +106,16 @@ export function encodeHeaderValue(value: string): string { * @param user - The user object to extract safe fields from * @returns A new object containing only allowed fields plus federatedTokens if present */ +/** + * Set on `req.user` for unattended requests (Scheduled Chats) whose deployment opted in with + * `SCHEDULES_OMIT_UNAVAILABLE_OPENID_HEADERS=true`. Such requests have no browser session, so the + * user's OpenID tokens are unavailable: `resolveHeaders` then omits a header whose OpenID + * credential placeholder cannot be resolved instead of failing the request. MCP resolution + * (`processMCPEnv`) is unaffected and still signals re-authentication. + */ +export const OMIT_UNAVAILABLE_OPENID_HEADERS = 'omitUnavailableOpenIDHeaders' as const; +type UnattendedOpenIDUser = { [OMIT_UNAVAILABLE_OPENID_HEADERS]?: boolean }; + export function createSafeUser( user: IUser | null | undefined, ): Partial & { federatedTokens?: IUser['federatedTokens'] } { @@ -138,6 +148,10 @@ export function createSafeUser( safeUser.federatedTokens = user.federatedTokens; } + if ((user as UnattendedOpenIDUser)[OMIT_UNAVAILABLE_OPENID_HEADERS] === true) { + (safeUser as UnattendedOpenIDUser)[OMIT_UNAVAILABLE_OPENID_HEADERS] = true; + } + return safeUser; } @@ -300,6 +314,7 @@ function processSingleValue({ body = undefined, isHeader = false, dbSourced = false, + omitUnavailableOpenID = false, }: { originalValue: string; customUserVars?: Record; @@ -308,6 +323,8 @@ function processSingleValue({ isHeader?: boolean; /** When true, only resolve customUserVars — skip env vars, user/OpenID/body placeholders */ dbSourced?: boolean; + /** Leave OpenID credential placeholders unresolved (for the caller to omit) instead of throwing when the tokens are unavailable */ + omitUnavailableOpenID?: boolean; }): string { // Type guard: ensure we're working with a string if (typeof originalValue !== 'string') { @@ -345,6 +362,8 @@ function processSingleValue({ const openidTokenInfo = extractOpenIDTokenInfo(user); if (openidTokenInfo && isOpenIDTokenValid(openidTokenInfo)) { value = processOpenIDPlaceholders(value, openidTokenInfo); + } else if (openidTokenInfo && omitUnavailableOpenID) { + /** Credential placeholders stay literal so `resolveHeaders` omits the whole header */ } else if (openidTokenInfo) { const unresolvable = OPENID_ACCESS_CREDENTIAL_PLACEHOLDER_PATTERN.exec(value); if (unresolvable) { @@ -616,6 +635,8 @@ export function resolveHeaders(options?: { }): Record { const { headers, user, body, customUserVars, stripUnresolved = false } = options ?? {}; const inputHeaders = headers ?? {}; + const omitUnavailableOpenID = + (user as UnattendedOpenIDUser | undefined)?.[OMIT_UNAVAILABLE_OPENID_HEADERS] === true; const resolvedHeaders: Record = { ...inputHeaders }; @@ -627,8 +648,9 @@ export function resolveHeaders(options?: { user: user as IUser, body, isHeader: true, // Important: Enable header encoding + omitUnavailableOpenID, }); - if (!stripUnresolved) { + if (!stripUnresolved && !omitUnavailableOpenID) { resolvedHeaders[key] = processed; return; } @@ -643,7 +665,7 @@ export function resolveHeaders(options?: { return; } - resolvedHeaders[key] = stripUnresolvedPlaceholders(processed); + resolvedHeaders[key] = stripUnresolved ? stripUnresolvedPlaceholders(processed) : processed; }); }