diff --git a/packages/data-provider/specs/request-interceptor.spec.ts b/packages/data-provider/specs/request-interceptor.spec.ts index b5e43736cc..71d47703c8 100644 --- a/packages/data-provider/specs/request-interceptor.spec.ts +++ b/packages/data-provider/specs/request-interceptor.spec.ts @@ -85,7 +85,7 @@ describe('axios 401 interceptor — Authorization header guard', () => { mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, - config: { url: '/api/share/abc123', headers: {} }, + config: { url: '/api/share/abc123', method: 'get', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ @@ -114,6 +114,75 @@ describe('axios 401 interceptor — Authorization header guard', () => { expect(refreshCall[0].url).toContain('api/auth/refresh'); }); + it('recognizes base-prefixed shared link data requests', async () => { + expect.assertions(2); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/chat/share/abc123', + pathname: '/chat/share/abc123', + search: '', + hash: '', + origin: 'http://localhost', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/chat/api/share/abc123', method: 'get', headers: {} }, + }); + + mockAdapter.mockResolvedValueOnce({ + data: { token: 'new-token' }, + status: 200, + headers: {}, + config: {}, + }); + + mockAdapter.mockResolvedValueOnce({ + data: { sharedLink: {} }, + status: 200, + headers: {}, + config: {}, + }); + + try { + await axios.get('/chat/api/share/abc123'); + } catch { + // may reject depending on exact flow + } + + expect(mockAdapter.mock.calls.length).toBe(3); + + const refreshCall = mockAdapter.mock.calls[1]; + expect(refreshCall[0].url).toContain('api/auth/refresh'); + }); + + it('does not refresh or redirect for unrelated 401s on public shared link pages', async () => { + expect.assertions(2); + setTokenHeader(undefined); + + setWindowLocation({ + href: 'http://localhost/share/abc123', + pathname: '/share/abc123', + search: '', + hash: '', + } as Partial); + + mockAdapter.mockRejectedValueOnce({ + response: { status: 401 }, + config: { url: '/api/mcp/servers', headers: {} }, + }); + + try { + await axios.get('/api/mcp/servers'); + } catch { + // expected rejection + } + + expect(mockAdapter).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe('http://localhost/share/abc123'); + }); + it('does not bypass guard when share/ appears only in query params', async () => { expect.assertions(1); setTokenHeader(undefined); @@ -152,7 +221,7 @@ describe('axios 401 interceptor — Authorization header guard', () => { mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, - config: { url: '/api/share/abc123', headers: {} }, + config: { url: '/api/share/abc123', method: 'get', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ @@ -184,7 +253,7 @@ describe('axios 401 interceptor — Authorization header guard', () => { mockAdapter.mockRejectedValueOnce({ response: { status: 401 }, - config: { url: '/api/share/abc123', headers: {} }, + config: { url: '/api/share/abc123', method: 'get', headers: {} }, }); mockAdapter.mockResolvedValueOnce({ diff --git a/packages/data-provider/src/request.ts b/packages/data-provider/src/request.ts index 5021b150e3..0dc7d9b725 100644 --- a/packages/data-provider/src/request.ts +++ b/packages/data-provider/src/request.ts @@ -67,6 +67,32 @@ let failedQueue: { resolve: (value?: any) => void; reject: (reason?: any) => voi const refreshToken = (retry?: boolean): Promise => _post(endpoints.refreshToken(retry)); +const stripBasePath = (pathname: string) => { + const baseUrl = endpoints.apiBaseUrl(); + if (baseUrl && (pathname === baseUrl || pathname.startsWith(`${baseUrl}/`))) { + return pathname.slice(baseUrl.length) || '/'; + } + return pathname; +}; + +const isSharePage = () => + /(?:^|\/)share\/[^/]+\/?$/.test(stripBasePath(window.location.pathname)); + +const getRequestPathname = (url?: string) => { + if (typeof url !== 'string') { + return ''; + } + try { + return new URL(url, window.location.origin).pathname; + } catch { + return url.split(/[?#]/)[0] ?? ''; + } +}; + +const isSharedMessagesRequest = (url?: string, method?: string) => + method?.toLowerCase() === 'get' && + /(?:^|\/)api\/share\/[^/]+$/.test(getRequestPathname(url)); + const dispatchTokenUpdatedEvent = (token: string) => { setTokenHeader(token); window.dispatchEvent(new CustomEvent('tokenUpdated', { detail: token })); @@ -100,10 +126,11 @@ if (typeof window !== 'undefined') { } /** Skip refresh when the Authorization header has been cleared (e.g. during logout), - * but allow shared link requests to proceed so auth recovery/redirect can happen */ + * but allow the shared link data request to proceed so private shares can still + * recover auth/redirect without unrelated share-page queries forcing login. */ if ( !axios.defaults.headers.common['Authorization'] && - !window.location.pathname.startsWith('/share/') + !(isSharePage() && isSharedMessagesRequest(originalRequest.url, originalRequest.method)) ) { return Promise.reject(error); }