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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,9 @@ OPENID_NAME_CLAIM=
# Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID)
# When not set, defaults to: email -> preferred_username -> upn
OPENID_EMAIL_CLAIM=
# Optional audience parameter for OpenID authorization requests
# Optional audience parameter for OpenID authorization requests and JWT validation.
# If comma-separated values are provided, JWT validation accepts all values and
# authorization requests use the first non-empty value.
OPENID_AUDIENCE=
# Optional audience parameter for OpenID refresh token requests.
# Some providers, such as Auth0 custom APIs, require this to preserve
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^2.8.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.2.33",
"@librechat/agents": "^3.2.34",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
Expand Down
13 changes: 10 additions & 3 deletions api/strategies/openidStrategy.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ This violates RFC 7235 and may cause issues with strict OAuth clients. Removing
/** @typedef {Configuration | null} */
let openidConfig = null;

const getOpenIdAuthorizationAudience = () =>
(process.env.OPENID_AUDIENCE ?? '')
.split(',')
.map((value) => value.trim())
.find(Boolean);

/**
* Custom OpenID Strategy
*
Expand All @@ -127,10 +133,11 @@ class CustomOpenIDStrategy extends OpenIDStrategy {
params.set('state', options.state);
}

if (process.env.OPENID_AUDIENCE) {
params.set('audience', process.env.OPENID_AUDIENCE);
const authorizationAudience = getOpenIdAuthorizationAudience();
if (authorizationAudience) {
params.set('audience', authorizationAudience);
logger.debug(
`[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`,
`[openidStrategy] Adding audience to authorization request: ${authorizationAudience}`,
);
}

Expand Down
50 changes: 43 additions & 7 deletions api/strategies/openidStrategy.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,20 +140,28 @@ jest.mock('openid-client', () => {
jest.mock('openid-client/passport', () => {
/** Store callbacks by strategy name - 'openid' and 'openidAdmin' */
const verifyCallbacks = {};
const strategies = {};
let lastVerifyCallback;

const mockStrategy = jest.fn((options, verify) => {
const mockStrategy = jest.fn(function (options, verify) {
lastVerifyCallback = verify;
return { name: 'openid', options, verify };
this.name = 'openid';
this.options = options;
this.verify = verify;
});
mockStrategy.prototype.authorizationRequestParams = jest.fn(() => new URLSearchParams());

return {
Strategy: mockStrategy,
/** Get the last registered callback (for backward compatibility) */
__getVerifyCallback: () => lastVerifyCallback,
__getStrategyByName: (name) => strategies[name],
/** Store callback by name when passport.use is called */
__setVerifyCallback: (name, callback) => {
verifyCallbacks[name] = callback;
__setStrategy: (name, strategy) => {
strategies[name] = strategy;
if (strategy?.verify) {
verifyCallbacks[name] = strategy.verify;
}
},
/** Get callback by strategy name */
__getVerifyCallbackByName: (name) => verifyCallbacks[name],
Expand All @@ -164,9 +172,7 @@ jest.mock('openid-client/passport', () => {
jest.mock('passport', () => ({
use: jest.fn((name, strategy) => {
const passportMock = require('openid-client/passport');
if (strategy && strategy.verify) {
passportMock.__setVerifyCallback(name, strategy.verify);
}
passportMock.__setStrategy(name, strategy);
}),
}));

Expand Down Expand Up @@ -232,6 +238,7 @@ describe('setupOpenId', () => {
delete process.env.OPENID_USERNAME_CLAIM;
delete process.env.OPENID_NAME_CLAIM;
delete process.env.OPENID_EMAIL_CLAIM;
delete process.env.OPENID_AUDIENCE;
delete process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS;
delete process.env.PROXY;
delete process.env.OPENID_USE_PKCE;
Expand Down Expand Up @@ -337,6 +344,35 @@ describe('setupOpenId', () => {
});
});

describe('authorizationRequestParams', () => {
const getLoginStrategy = () => require('openid-client/passport').__getStrategyByName('openid');

it('adds a single OpenID audience to authorization requests', () => {
process.env.OPENID_AUDIENCE = 'librechat';

const params = getLoginStrategy().authorizationRequestParams({}, { state: 'login-state' });

expect(params.get('audience')).toBe('librechat');
expect(params.get('state')).toBe('login-state');
});

it('uses the first non-empty audience when OPENID_AUDIENCE accepts multiple JWT audiences', () => {
process.env.OPENID_AUDIENCE = ' librechat , control-plane-web ';

const params = getLoginStrategy().authorizationRequestParams({}, {});

expect(params.get('audience')).toBe('librechat');
});

it('does not add an authorization audience when OPENID_AUDIENCE is empty', () => {
process.env.OPENID_AUDIENCE = ' , ';

const params = getLoginStrategy().authorizationRequestParams({}, {});

expect(params.has('audience')).toBe(false);
});
});

it('should create a new user with correct username when preferred_username claim exists', async () => {
// Arrange – our userinfo already has preferred_username 'testusername'
const userinfo = tokenset.claims();
Expand Down
147 changes: 147 additions & 0 deletions client/src/hooks/Agents/__tests__/useApplyModelSpecAgents.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React from 'react';
import { RecoilRoot, useRecoilValue } from 'recoil';
import { renderHook, act } from '@testing-library/react';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type { TEphemeralAgent, TStartupConfig, TModelSpec } from 'librechat-data-provider';
import { ephemeralAgentByConvoId, useUpdateEphemeralAgent } from '~/store/agents';
import { useApplyModelSpecEffects } from '../useApplyModelSpecAgents';

const NEW_CONVO = Constants.NEW_CONVO as string;

const Wrapper = ({ children }: { children: React.ReactNode }) => (
<RecoilRoot>{children}</RecoilRoot>
);

const createModelSpec = (name: string): TModelSpec =>
({
name,
label: name,
preset: {
endpoint: EModelEndpoint.openAI,
model: name,
},
}) as TModelSpec;

const createStartupConfig = (list: TModelSpec[]): TStartupConfig =>
({
modelSpecs: {
list,
prioritize: false,
},
}) as TStartupConfig;

const specsConfig = () => createStartupConfig([createModelSpec('test-spec')]);

const useHarness = (conversationId: string) => {
const applyModelSpecEffects = useApplyModelSpecEffects();
const updateEphemeralAgent = useUpdateEphemeralAgent();
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId));
return { applyModelSpecEffects, updateEphemeralAgent, ephemeralAgent };
};

describe('useApplyModelSpecEffects', () => {
it('preserves an existing conversation ephemeral agent on an in-place model switch', () => {
const conversationId = 'convo-123';
const agent: TEphemeralAgent = { mcp: ['clickhouse'] };
const { result } = renderHook(() => useHarness(conversationId), { wrapper: Wrapper });

act(() => {
result.current.updateEphemeralAgent(conversationId, agent);
});
expect(result.current.ephemeralAgent).toEqual(agent);

act(() => {
result.current.applyModelSpecEffects({
convoId: conversationId,
specName: null,
prevConvoId: conversationId,
prevSpecName: null,
startupConfig: specsConfig(),
});
});

expect(result.current.ephemeralAgent).toEqual(agent);
});

it('preserves a new conversation ephemeral agent on an in-place model switch', () => {
const agent: TEphemeralAgent = { mcp: ['clickhouse'] };
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });

act(() => {
result.current.updateEphemeralAgent(NEW_CONVO, agent);
});

act(() => {
result.current.applyModelSpecEffects({
convoId: NEW_CONVO,
specName: null,
prevConvoId: NEW_CONVO,
prevSpecName: null,
startupConfig: specsConfig(),
});
});

expect(result.current.ephemeralAgent).toEqual(agent);
});

it('resets the ephemeral agent when switching away from a spec', () => {
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });

act(() => {
result.current.updateEphemeralAgent(NEW_CONVO, { mcp: ['clickhouse'] });
});

act(() => {
result.current.applyModelSpecEffects({
convoId: NEW_CONVO,
specName: null,
prevConvoId: NEW_CONVO,
prevSpecName: 'test-spec',
startupConfig: specsConfig(),
});
});

expect(result.current.ephemeralAgent).toBeNull();
});

it('resets the new conversation ephemeral agent when leaving an existing conversation', () => {
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });

act(() => {
result.current.updateEphemeralAgent(NEW_CONVO, { mcp: ['clickhouse'] });
});

act(() => {
result.current.applyModelSpecEffects({
convoId: NEW_CONVO,
specName: null,
prevConvoId: 'convo-123',
prevSpecName: null,
startupConfig: specsConfig(),
});
});

expect(result.current.ephemeralAgent).toBeNull();
});

it('leaves the ephemeral agent untouched when no specs are configured', () => {
const agent: TEphemeralAgent = { mcp: ['clickhouse'] };
const { result } = renderHook(() => useHarness(NEW_CONVO), { wrapper: Wrapper });

act(() => {
result.current.updateEphemeralAgent(NEW_CONVO, agent);
});

act(() => {
result.current.applyModelSpecEffects({
convoId: NEW_CONVO,
specName: null,
prevConvoId: 'convo-123',
prevSpecName: 'test-spec',
startupConfig: {} as TStartupConfig,
});
});

expect(result.current.ephemeralAgent).toEqual(agent);
});
});
22 changes: 17 additions & 5 deletions client/src/hooks/Agents/useApplyModelSpecAgents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,37 @@ import { getModelSpec, applyModelSpecEphemeralAgent } from '~/utils';
*
* When a spec is provided, its tool settings are applied to the ephemeral agent.
* When no spec is provided but specs are configured, the ephemeral agent is reset
* to null so BadgeRowContext can apply localStorage defaults (non-spec experience).
* to null on context transitions (leaving a spec, or moving to a different
* conversation key) so BadgeRowContext refills values from localStorage — both
* transitions re-trigger its init effect. In-place switches (same conversation,
* non-spec → non-spec) keep the ephemeral agent state (e.g. MCP selections),
* since no refill would follow the reset.
*/
export function useApplyModelSpecEffects() {
const updateEphemeralAgent = useUpdateEphemeralAgent();
const applyModelSpecEffects = useCallback(
({
convoId,
specName,
prevConvoId,
prevSpecName,
startupConfig,
}: {
convoId: string | null;
specName?: string | null;
prevConvoId?: string | null;
prevSpecName?: string | null;
startupConfig?: TStartupConfig;
}) => {
if (specName == null || !specName) {
if (startupConfig?.modelSpecs?.list?.length) {
/** Specs are configured but none selected — reset ephemeral agent to null
* so BadgeRowContext fills all values (tool toggles + MCP) from localStorage. */
updateEphemeralAgent((convoId ?? Constants.NEW_CONVO) || Constants.NEW_CONVO, null);
if (!startupConfig?.modelSpecs?.list?.length) {
return;
}
const targetId = (convoId ?? Constants.NEW_CONVO) || Constants.NEW_CONVO;
const sourceId = (prevConvoId ?? Constants.NEW_CONVO) || Constants.NEW_CONVO;
const isContextSwitch = Boolean(prevSpecName) || targetId !== sourceId;
if (isContextSwitch) {
updateEphemeralAgent(targetId, null);
}
return;
}
Expand Down
6 changes: 6 additions & 0 deletions client/src/hooks/useNewConvo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
logger,
} from '~/utils';
import { useDeleteFilesMutation, useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import useGetConversation from './Conversations/useGetConversation';
import useAssistantListMap from './Assistants/useAssistantListMap';
import { useResetChatBadges } from './useChatBadges';
import { useApplyModelSpecEffects } from './Agents';
Expand All @@ -46,6 +47,7 @@ const useNewConvo = (index = 0) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { data: startupConfig } = useGetStartupConfig();
const getConversation = useGetConversation(index);
const applyModelSpecEffects = useApplyModelSpecEffects();
const clearAllConversations = store.useClearConvoState();
const defaultPreset = useRecoilValue(store.defaultPreset);
Expand Down Expand Up @@ -334,10 +336,13 @@ const useNewConvo = (index = 0) => {
preset = getModelSpecPreset(defaultModelSpec);
}

const prevConversation = getConversation();
applyModelSpecEffects({
startupConfig,
specName: preset?.spec,
convoId: conversation.conversationId,
prevConvoId: prevConversation?.conversationId,
prevSpecName: prevConversation?.spec,
});

if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
Expand Down Expand Up @@ -384,6 +389,7 @@ const useNewConvo = (index = 0) => {
startupConfig,
saveBadgesState,
endpointsConfig,
getConversation,
pauseGlobalAudio,
switchToConversation,
applyModelSpecEffects,
Expand Down
Loading
Loading