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
8 changes: 8 additions & 0 deletions api/server/routes/agents/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
isAgentTriggerRequest,
exemptAgentTriggerFromIpLimiter,
captureScheduleFireContext,
OMIT_UNAVAILABLE_OPENID_HEADERS,
exemptFromUserLimiter: exemptScheduleFromUserLimiter,
detectGenerationRetry,
isConfirmedGenerationRetry,
Expand Down Expand Up @@ -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);
Expand Down
58 changes: 58 additions & 0 deletions packages/api/src/utils/env.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MCPOptions, { type?: 'stdio' }> {
Expand Down Expand Up @@ -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' });
});
});
26 changes: 24 additions & 2 deletions packages/api/src/utils/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SafeUser> & { federatedTokens?: IUser['federatedTokens'] } {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -300,6 +314,7 @@ function processSingleValue({
body = undefined,
isHeader = false,
dbSourced = false,
omitUnavailableOpenID = false,
}: {
originalValue: string;
customUserVars?: Record<string, string>;
Expand All @@ -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') {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -616,6 +635,8 @@ export function resolveHeaders(options?: {
}): Record<string, string> {
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<string, string> = { ...inputHeaders };

Expand All @@ -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;
}
Expand All @@ -643,7 +665,7 @@ export function resolveHeaders(options?: {
return;
}

resolvedHeaders[key] = stripUnresolvedPlaceholders(processed);
resolvedHeaders[key] = stripUnresolved ? stripUnresolvedPlaceholders(processed) : processed;
});
}

Expand Down
Loading