diff --git a/api/server/controllers/auth/LogoutController.js b/api/server/controllers/auth/LogoutController.js index 0b3cf262b8..039ed630c2 100644 --- a/api/server/controllers/auth/LogoutController.js +++ b/api/server/controllers/auth/LogoutController.js @@ -8,13 +8,16 @@ const logoutController = async (req, res) => { const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {}; const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid'; - /** For OpenID users, read refresh token from session; for others, use cookie */ + /** For OpenID users, read tokens from session (with cookie fallback) */ let refreshToken; + let idToken; if (isOpenIdUser && req.session?.openidTokens) { refreshToken = req.session.openidTokens.refreshToken; + idToken = req.session.openidTokens.idToken; delete req.session.openidTokens; } refreshToken = refreshToken || parsedCookies.refreshToken; + idToken = idToken || parsedCookies.openid_id_token; try { const logout = await logoutUser(req, refreshToken); @@ -31,21 +34,34 @@ const logoutController = async (req, res) => { isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) && process.env.OPENID_ISSUER ) { - const openIdConfig = getOpenIdConfig(); - if (!openIdConfig) { - logger.warn( - '[logoutController] OpenID config not found. Please verify that the open id configuration and initialization are correct.', - ); - } else { - const endSessionEndpoint = openIdConfig - ? openIdConfig.serverMetadata().end_session_endpoint - : null; + let openIdConfig; + try { + openIdConfig = getOpenIdConfig(); + } catch (err) { + logger.warn('[logoutController] OpenID config not available:', err.message); + } + if (openIdConfig) { + const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint; if (endSessionEndpoint) { const endSessionUrl = new URL(endSessionEndpoint); /** Redirect back to app's login page after IdP logout */ const postLogoutRedirectUri = process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`; endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri); + + /** Add id_token_hint (preferred) or client_id for OIDC spec compliance */ + if (idToken) { + endSessionUrl.searchParams.set('id_token_hint', idToken); + } else if (process.env.OPENID_CLIENT_ID) { + endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID); + } else { + logger.warn( + '[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' + + 'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' + + 'The OIDC end-session request may be rejected by the identity provider.', + ); + } + response.redirect = endSessionUrl.toString(); } else { logger.warn( diff --git a/api/server/controllers/auth/LogoutController.spec.js b/api/server/controllers/auth/LogoutController.spec.js new file mode 100644 index 0000000000..3f2a2de8e1 --- /dev/null +++ b/api/server/controllers/auth/LogoutController.spec.js @@ -0,0 +1,259 @@ +const cookies = require('cookie'); + +const mockLogoutUser = jest.fn(); +const mockLogger = { warn: jest.fn(), error: jest.fn() }; +const mockIsEnabled = jest.fn(); +const mockGetOpenIdConfig = jest.fn(); + +jest.mock('cookie'); +jest.mock('@librechat/api', () => ({ isEnabled: (...args) => mockIsEnabled(...args) })); +jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger })); +jest.mock('~/server/services/AuthService', () => ({ + logoutUser: (...args) => mockLogoutUser(...args), +})); +jest.mock('~/strategies', () => ({ getOpenIdConfig: () => mockGetOpenIdConfig() })); + +const { logoutController } = require('./LogoutController'); + +function buildReq(overrides = {}) { + return { + user: { _id: 'user1', openidId: 'oid1', provider: 'openid' }, + headers: { cookie: 'refreshToken=rt1' }, + session: { + openidTokens: { refreshToken: 'srt', idToken: 'small-id-token' }, + destroy: jest.fn(), + }, + ...overrides, + }; +} + +function buildRes() { + const res = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + clearCookie: jest.fn(), + }; + return res; +} + +const ORIGINAL_ENV = process.env; + +beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...ORIGINAL_ENV, + OPENID_USE_END_SESSION_ENDPOINT: 'true', + OPENID_ISSUER: 'https://idp.example.com', + OPENID_CLIENT_ID: 'my-client-id', + DOMAIN_CLIENT: 'https://app.example.com', + }; + cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' }); + mockLogoutUser.mockResolvedValue({ status: 200, message: 'Logout successful' }); + mockIsEnabled.mockReturnValue(true); + mockGetOpenIdConfig.mockReturnValue({ + serverMetadata: () => ({ + end_session_endpoint: 'https://idp.example.com/logout', + }), + }); +}); + +afterAll(() => { + process.env = ORIGINAL_ENV; +}); + +describe('LogoutController', () => { + describe('id_token_hint from session', () => { + it('sets id_token_hint when session has idToken', async () => { + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toContain('id_token_hint=small-id-token'); + expect(body.redirect).not.toContain('client_id='); + }); + }); + + describe('id_token_hint from cookie fallback', () => { + it('uses cookie id_token when session has no tokens', async () => { + cookies.parse.mockReturnValue({ + refreshToken: 'cookie-rt', + openid_id_token: 'cookie-id-token', + }); + const req = buildReq({ session: { destroy: jest.fn() } }); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toContain('id_token_hint=cookie-id-token'); + }); + }); + + describe('client_id fallback', () => { + it('falls back to client_id when no idToken is available', async () => { + cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' }); + const req = buildReq({ session: { destroy: jest.fn() } }); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toContain('client_id=my-client-id'); + expect(body.redirect).not.toContain('id_token_hint='); + }); + + it('does not produce client_id=undefined when OPENID_CLIENT_ID is unset', async () => { + delete process.env.OPENID_CLIENT_ID; + cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' }); + const req = buildReq({ session: { destroy: jest.fn() } }); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).not.toContain('client_id='); + expect(body.redirect).not.toContain('undefined'); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Neither id_token_hint nor OPENID_CLIENT_ID'), + ); + }); + }); + + describe('OPENID_USE_END_SESSION_ENDPOINT disabled', () => { + it('does not include redirect when disabled', async () => { + mockIsEnabled.mockReturnValue(false); + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toBeUndefined(); + }); + }); + + describe('OPENID_ISSUER unset', () => { + it('does not include redirect when OPENID_ISSUER is missing', async () => { + delete process.env.OPENID_ISSUER; + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toBeUndefined(); + }); + }); + + describe('non-OpenID user', () => { + it('does not include redirect for non-OpenID users', async () => { + const req = buildReq({ + user: { _id: 'user1', provider: 'local' }, + }); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toBeUndefined(); + }); + }); + + describe('post_logout_redirect_uri', () => { + it('uses OPENID_POST_LOGOUT_REDIRECT_URI when set', async () => { + process.env.OPENID_POST_LOGOUT_REDIRECT_URI = 'https://custom.example.com/logged-out'; + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + const url = new URL(body.redirect); + expect(url.searchParams.get('post_logout_redirect_uri')).toBe( + 'https://custom.example.com/logged-out', + ); + }); + + it('defaults to DOMAIN_CLIENT/login when OPENID_POST_LOGOUT_REDIRECT_URI is unset', async () => { + delete process.env.OPENID_POST_LOGOUT_REDIRECT_URI; + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + const url = new URL(body.redirect); + expect(url.searchParams.get('post_logout_redirect_uri')).toBe( + 'https://app.example.com/login', + ); + }); + }); + + describe('OpenID config not available', () => { + it('warns and returns no redirect when getOpenIdConfig throws', async () => { + mockGetOpenIdConfig.mockImplementation(() => { + throw new Error('OpenID configuration has not been initialized'); + }); + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('OpenID config not available'), + 'OpenID configuration has not been initialized', + ); + }); + }); + + describe('end_session_endpoint not in metadata', () => { + it('warns and returns no redirect when end_session_endpoint is missing', async () => { + mockGetOpenIdConfig.mockReturnValue({ + serverMetadata: () => ({}), + }); + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + const body = res.send.mock.calls[0][0]; + expect(body.redirect).toBeUndefined(); + expect(mockLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('end_session_endpoint not found'), + ); + }); + }); + + describe('error handling', () => { + it('returns 500 on logoutUser error', async () => { + mockLogoutUser.mockRejectedValue(new Error('session error')); + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ message: 'session error' }); + }); + }); + + describe('cookie clearing', () => { + it('clears all auth cookies on successful logout', async () => { + const req = buildReq(); + const res = buildRes(); + + await logoutController(req, res); + + expect(res.clearCookie).toHaveBeenCalledWith('refreshToken'); + expect(res.clearCookie).toHaveBeenCalledWith('openid_access_token'); + expect(res.clearCookie).toHaveBeenCalledWith('openid_id_token'); + expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id'); + expect(res.clearCookie).toHaveBeenCalledWith('token_provider'); + }); + }); +}); diff --git a/client/src/hooks/AuthContext.tsx b/client/src/hooks/AuthContext.tsx index 86f80cde6b..ca82e10f8f 100644 --- a/client/src/hooks/AuthContext.tsx +++ b/client/src/hooks/AuthContext.tsx @@ -34,11 +34,12 @@ const AuthContextProvider = ({ authConfig?: TAuthConfig; children: ReactNode; }) => { + const isExternalRedirectRef = useRef(false); const [user, setUser] = useRecoilState(store.user); + const logoutRedirectRef = useRef(undefined); const [token, setToken] = useState(undefined); const [error, setError] = useState(undefined); const [isAuthenticated, setIsAuthenticated] = useState(false); - const logoutRedirectRef = useRef(undefined); const { data: userRole = null } = useGetRole(SystemRoles.USER, { enabled: !!(isAuthenticated && (user?.role ?? '')), @@ -55,7 +56,6 @@ const AuthContextProvider = ({ const { token, isAuthenticated, user, redirect } = userContext; setUser(user); setToken(token); - //@ts-ignore - ok for token to be undefined initially setTokenHeader(token); setIsAuthenticated(isAuthenticated); @@ -106,11 +106,21 @@ const AuthContextProvider = ({ }); const logoutUser = useLogoutUserMutation({ onSuccess: (data) => { + if (data.redirect) { + /** data.redirect is the IdP's end_session_endpoint URL — an absolute URL generated + * server-side from trusted IdP metadata (not user input), so isSafeRedirect is bypassed. + * setUserContext is debounced (50ms) and won't fire before page unload, so clear the + * axios Authorization header synchronously to prevent in-flight requests. */ + isExternalRedirectRef.current = true; + setTokenHeader(undefined); + window.location.replace(data.redirect); + return; + } setUserContext({ token: undefined, isAuthenticated: false, user: undefined, - redirect: data.redirect ?? '/login', + redirect: '/login', }); }, onError: (error) => { @@ -146,8 +156,14 @@ const AuthContextProvider = ({ console.log('Test mode. Skipping silent refresh.'); return; } + if (isExternalRedirectRef.current) { + return; + } refreshToken.mutate(undefined, { onSuccess: (data: t.TRefreshTokenResponse | undefined) => { + if (isExternalRedirectRef.current) { + return; + } const { user, token = '' } = data ?? {}; if (token) { setUserContext({ token, isAuthenticated: true, user }); @@ -160,6 +176,9 @@ const AuthContextProvider = ({ navigate(buildLoginRedirectUrl()); }, onError: (error) => { + if (isExternalRedirectRef.current) { + return; + } console.log('refreshToken mutation error:', error); if (authConfig?.test === true) { return; @@ -171,6 +190,9 @@ const AuthContextProvider = ({ }, []); useEffect(() => { + if (isExternalRedirectRef.current) { + return; + } if (userQuery.data) { setUser(userQuery.data); } else if (userQuery.isError) { diff --git a/client/src/hooks/__tests__/AuthContext.spec.tsx b/client/src/hooks/__tests__/AuthContext.spec.tsx index 5a24a31ec4..20af37e3f2 100644 --- a/client/src/hooks/__tests__/AuthContext.spec.tsx +++ b/client/src/hooks/__tests__/AuthContext.spec.tsx @@ -24,6 +24,13 @@ let mockCapturedLoginOptions: { onError: (...args: unknown[]) => void; }; +let mockCapturedLogoutOptions: { + onSuccess: (...args: unknown[]) => void; + onError: (...args: unknown[]) => void; +}; + +const mockRefreshMutate = jest.fn(); + jest.mock('~/data-provider', () => ({ useLoginUserMutation: jest.fn( (options: { @@ -34,8 +41,16 @@ jest.mock('~/data-provider', () => ({ return { mutate: jest.fn() }; }, ), - useLogoutUserMutation: jest.fn(() => ({ mutate: jest.fn() })), - useRefreshTokenMutation: jest.fn(() => ({ mutate: jest.fn() })), + useLogoutUserMutation: jest.fn( + (options: { + onSuccess: (...args: unknown[]) => void; + onError: (...args: unknown[]) => void; + }) => { + mockCapturedLogoutOptions = options; + return { mutate: jest.fn() }; + }, + ), + useRefreshTokenMutation: jest.fn(() => ({ mutate: mockRefreshMutate })), useGetUserQuery: jest.fn(() => ({ data: undefined, isError: false, @@ -69,6 +84,25 @@ function renderProvider() { ); } +/** Renders without test:true so silentRefresh actually runs */ +function renderProviderLive() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + + return render( + + + + + + + + + , + ); +} + describe('AuthContextProvider — login onError redirect handling', () => { const originalLocation = window.location; @@ -172,3 +206,88 @@ describe('AuthContextProvider — login onError redirect handling', () => { expect(decodeURIComponent(params.get('redirect_to')!)).toBe(target); }); }); + +describe('AuthContextProvider — logout onSuccess/onError handling', () => { + const originalLocation = window.location; + const mockSetTokenHeader = jest.requireMock('librechat-data-provider').setTokenHeader; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, 'location', { + value: { + ...originalLocation, + pathname: '/c/some-chat', + search: '', + hash: '', + replace: jest.fn(), + }, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + }); + + it('calls window.location.replace and setTokenHeader(undefined) when redirect is present', () => { + renderProvider(); + + act(() => { + mockCapturedLogoutOptions.onSuccess({ + message: 'Logout successful', + redirect: 'https://idp.example.com/logout?id_token_hint=abc', + }); + }); + + expect(window.location.replace).toHaveBeenCalledWith( + 'https://idp.example.com/logout?id_token_hint=abc', + ); + expect(mockSetTokenHeader).toHaveBeenCalledWith(undefined); + }); + + it('does not call window.location.replace when redirect is absent', async () => { + renderProvider(); + + act(() => { + mockCapturedLogoutOptions.onSuccess({ message: 'Logout successful' }); + }); + + expect(window.location.replace).not.toHaveBeenCalled(); + }); + + it('does not trigger silentRefresh after OIDC redirect', () => { + renderProviderLive(); + mockRefreshMutate.mockClear(); + + act(() => { + mockCapturedLogoutOptions.onSuccess({ + message: 'Logout successful', + redirect: 'https://idp.example.com/logout?id_token_hint=abc', + }); + }); + + expect(window.location.replace).toHaveBeenCalled(); + expect(mockRefreshMutate).not.toHaveBeenCalled(); + }); + + it('clears auth state on logout error without external redirect', () => { + jest.useFakeTimers(); + const { getByTestId } = renderProvider(); + + act(() => { + mockCapturedLogoutOptions.onError(new Error('Logout failed')); + }); + act(() => { + jest.advanceTimersByTime(100); + }); + + expect(window.location.replace).not.toHaveBeenCalled(); + expect(getByTestId('consumer').getAttribute('data-authenticated')).toBe('false'); + jest.useRealTimers(); + }); +}); diff --git a/packages/data-provider/specs/headers-helpers.spec.ts b/packages/data-provider/specs/headers-helpers.spec.ts new file mode 100644 index 0000000000..4df7a2f934 --- /dev/null +++ b/packages/data-provider/specs/headers-helpers.spec.ts @@ -0,0 +1,24 @@ +import axios from 'axios'; +import { setTokenHeader } from '../src/headers-helpers'; + +describe('setTokenHeader', () => { + afterEach(() => { + delete axios.defaults.headers.common['Authorization']; + }); + + it('sets the Authorization header with a Bearer token', () => { + setTokenHeader('my-token'); + expect(axios.defaults.headers.common['Authorization']).toBe('Bearer my-token'); + }); + + it('deletes the Authorization header when called with undefined', () => { + axios.defaults.headers.common['Authorization'] = 'Bearer old-token'; + setTokenHeader(undefined); + expect(axios.defaults.headers.common['Authorization']).toBeUndefined(); + }); + + it('is a no-op when clearing an already absent header', () => { + setTokenHeader(undefined); + expect(axios.defaults.headers.common['Authorization']).toBeUndefined(); + }); +}); diff --git a/packages/data-provider/specs/request-interceptor.spec.ts b/packages/data-provider/specs/request-interceptor.spec.ts new file mode 100644 index 0000000000..872f2a9f67 --- /dev/null +++ b/packages/data-provider/specs/request-interceptor.spec.ts @@ -0,0 +1,102 @@ +/** + * @jest-environment jsdom + */ +import axios from 'axios'; +import { setTokenHeader } from '../src/headers-helpers'; + +/** + * The response interceptor in request.ts registers at import time when + * `typeof window !== 'undefined'` (jsdom provides window). + * + * We use axios's built-in request adapter mock to avoid real HTTP calls, + * and verify the interceptor's behavior by observing whether a 401 triggers + * a refresh POST or is immediately rejected. + */ + +/** Mock the axios adapter to simulate responses without HTTP */ +const mockAdapter = jest.fn(); +let originalAdapter: typeof axios.defaults.adapter; + +beforeAll(async () => { + originalAdapter = axios.defaults.adapter; + axios.defaults.adapter = mockAdapter; + + /** Import triggers interceptor registration */ + await import('../src/request'); +}); + +beforeEach(() => { + mockAdapter.mockReset(); +}); + +afterAll(() => { + axios.defaults.adapter = originalAdapter; +}); + +afterEach(() => { + delete axios.defaults.headers.common['Authorization']; +}); + +describe('axios 401 interceptor — Authorization header guard', () => { + it('skips refresh and rejects when Authorization header is cleared', async () => { + /** Simulate a cleared header (as done by setTokenHeader(undefined) during logout) */ + setTokenHeader(undefined); + + /** Set up adapter: first call returns 401, second would be the refresh */ + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/api/messages', headers: {} }, + }); + + try { + await axios.get('/api/messages'); + } catch { + // expected rejection + } + + /** + * If the interceptor skipped refresh, only 1 call was made (the original). + * If it attempted refresh, there would be 2+ calls (original + refresh POST). + */ + expect(mockAdapter).toHaveBeenCalledTimes(1); + }); + + it('attempts refresh when Authorization header is present', async () => { + setTokenHeader('valid-token'); + + /** First call: 401 on the original request */ + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/api/messages', headers: {}, _retry: false }, + }); + + /** Second call: the refresh endpoint succeeds */ + mockAdapter.mockResolvedValueOnce({ + data: { token: 'new-token' }, + status: 200, + headers: {}, + config: {}, + }); + + /** Third call: retried original request succeeds */ + mockAdapter.mockResolvedValueOnce({ + data: { messages: [] }, + status: 200, + headers: {}, + config: {}, + }); + + try { + await axios.get('/api/messages'); + } catch { + // may reject depending on exact flow + } + + /** More than 1 call means the interceptor attempted refresh */ + expect(mockAdapter.mock.calls.length).toBeGreaterThan(1); + + /** Verify the second call targeted the refresh endpoint */ + const refreshCall = mockAdapter.mock.calls[1]; + expect(refreshCall[0].url).toContain('api/auth/refresh'); + }); +}); diff --git a/packages/data-provider/src/headers-helpers.ts b/packages/data-provider/src/headers-helpers.ts index 195f7c7912..fa24b36997 100644 --- a/packages/data-provider/src/headers-helpers.ts +++ b/packages/data-provider/src/headers-helpers.ts @@ -4,6 +4,10 @@ export function setAcceptLanguageHeader(value: string): void { axios.defaults.headers.common['Accept-Language'] = value; } -export function setTokenHeader(token: string) { - axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; +export function setTokenHeader(token: string | undefined) { + if (token === undefined) { + delete axios.defaults.headers.common['Authorization']; + } else { + axios.defaults.headers.common['Authorization'] = 'Bearer ' + token; + } } diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts index fd5d42d76a..8b316731fb 100644 --- a/packages/data-provider/src/request.ts +++ b/packages/data-provider/src/request.ts @@ -99,6 +99,11 @@ if (typeof window !== 'undefined') { return Promise.reject(error); } + /** Skip refresh when the Authorization header has been cleared (e.g. during logout) */ + if (!axios.defaults.headers.common['Authorization']) { + return Promise.reject(error); + } + if (error.response.status === 401 && !originalRequest._retry) { console.warn('401 error, refreshing token'); originalRequest._retry = true;