Skip to content

Commit 677d7e7

Browse files
committed
1.4.1 - upgrade fetch interceptor from path-prefix to origin-based credential matching
1 parent d63bfb0 commit 677d7e7

2 files changed

Lines changed: 86 additions & 13 deletions

File tree

src/ui/assets/auth.js

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -104,21 +104,47 @@
104104
window.fetch = async function (input, init = {}) {
105105
const url = typeof input === 'string' ? input : input.url;
106106

107-
if (!init.headers) init.headers = {};
108-
if (init.headers instanceof Headers) {
109-
const token = getCookie('__Host-csrf-token') || getCookie('__Secure-csrf-token') || getCookie('csrf-token');
110-
if (token) init.headers.set('X-CSRF-Token', token);
111-
} else {
112-
init.headers = addCsrfHeader(init.headers);
107+
// Derive the auth backend origin so that every request to the same
108+
// domain as the auth server gets credentials/CSRF headers — not just
109+
// requests whose path starts with apiPrefix. This covers routes like
110+
// /mcp on the same host as /auth (cross-domain headless deployments).
111+
//
112+
// We use window.location.href as the base for resolving relative URLs.
113+
// Try/catch guards against malformed URLs; on failure both origins stay
114+
// null and only the isAuthEndpoint() path-based fallback is used.
115+
let backendOrigin = null;
116+
let requestOrigin = null;
117+
try {
118+
const pageBase = window.location?.href || '';
119+
backendOrigin = UI_CONFIG.apiPrefix.startsWith('http')
120+
? new URL(UI_CONFIG.apiPrefix).origin
121+
: new URL(pageBase).origin;
122+
requestOrigin = new URL(url, pageBase).origin;
123+
} catch (_) { /* malformed URL — isAuthEndpoint() below handles known endpoints */ }
124+
125+
const isBackendRequest = backendOrigin !== null && backendOrigin === requestOrigin;
126+
const isAuthRequest = isBackendRequest || isAuthEndpoint(url);
127+
128+
if (isAuthRequest) {
129+
if (!init.headers) init.headers = {};
130+
if (init.headers instanceof Headers) {
131+
const token = getCookie('__Host-csrf-token') || getCookie('__Secure-csrf-token') || getCookie('csrf-token');
132+
if (token) init.headers.set('X-CSRF-Token', token);
133+
} else {
134+
init.headers = addCsrfHeader(init.headers);
135+
}
136+
init.credentials = init.credentials || 'include';
113137
}
114-
init.credentials = init.credentials || 'include';
115138

116139
let response = await originalFetch(input, init);
117140

118141
if ((response.status === 401 || response.status === 403) && !isAuthEndpoint(url)) {
119142
try {
120143
const refreshResult = await refreshToken();
121-
if (refreshResult && refreshResult.success) {
144+
// Use a lenient check that accepts both { success: true } and
145+
// other truthy payloads (e.g. { accessToken: "..." }) that the
146+
// backend may return without an explicit `success` field.
147+
if (refreshResult && refreshResult.success !== false) {
122148
if (_overrides.onRefreshSuccess) _overrides.onRefreshSuccess(refreshResult);
123149
if (!(init.headers instanceof Headers)) {
124150
init.headers = addCsrfHeader(init.headers);
@@ -395,7 +421,8 @@
395421
*/
396422
async refresh() {
397423
const result = await refreshToken().catch(() => null);
398-
return !!(result && result.success);
424+
// Succeeds if result.success is explicitly true OR if result is simply an object (e.g. {accessToken: "..."})
425+
return !!(result && (result.success !== false));
399426
},
400427

401428
// --- SESSION ---

tests/auth-js.test.ts

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,8 @@ describe('fetch interceptor — CSRF', () => {
922922

923923
fetchMock.mockResolvedValueOnce(fakeResponse({ data: 'ok' }));
924924

925-
await fetch('/api/data');
925+
// CSRF headers are only injected for auth backend requests
926+
await fetch('/auth/me');
926927

927928
const [, opts] = fetchMock.mock.calls[0];
928929
expect(opts.headers['X-CSRF-Token']).toBe('test-csrf-value');
@@ -938,18 +939,44 @@ describe('fetch interceptor — CSRF', () => {
938939

939940
fetchMock.mockResolvedValueOnce(fakeResponse({ data: 'ok' }));
940941

941-
await fetch('/api/data');
942+
await fetch('/auth/me');
942943

943944
const [, opts] = fetchMock.mock.calls[0];
944945
expect(opts.headers?.['X-CSRF-Token']).toBeUndefined();
945946
});
946947

947-
it('adds credentials: include when not explicitly set', async () => {
948+
it('adds credentials: include on auth backend requests', async () => {
949+
fetchMock.mockResolvedValueOnce(fakeResponse({ data: 'ok' }));
950+
951+
await fetch('/auth/me');
952+
953+
const [, opts] = fetchMock.mock.calls[0];
954+
expect(opts.credentials).toBe('include');
955+
});
956+
957+
it('does NOT add credentials: include on cross-origin third-party API requests', async () => {
958+
// Third-party APIs (e.g. LiteLLM, OpenAI, Stripe) return
959+
// Access-Control-Allow-Origin: * which browsers block if the request
960+
// includes credentials. The interceptor must leave them untouched.
961+
fetchMock.mockResolvedValueOnce(fakeResponse({ data: 'ok' }));
962+
963+
await fetch('https://litellm.external.com/v1/chat/completions');
964+
965+
const [, opts] = fetchMock.mock.calls[0];
966+
// credentials must remain unset for cross-origin third-party requests
967+
expect(opts.credentials).toBeUndefined();
968+
});
969+
970+
it('adds credentials: include on same-origin non-auth-prefix requests (e.g. /mcp on the auth server)', async () => {
971+
// When the SPA and auth backend share the same origin, any request to
972+
// that origin should receive credentials — including /mcp or other
973+
// non-/auth-prefixed routes on the same server.
948974
fetchMock.mockResolvedValueOnce(fakeResponse({ data: 'ok' }));
949975

950-
await fetch('/api/data');
976+
await fetch('/mcp/tool');
951977

952978
const [, opts] = fetchMock.mock.calls[0];
979+
// same origin as apiPrefix → gets credentials
953980
expect(opts.credentials).toBe('include');
954981
});
955982

@@ -984,6 +1011,25 @@ describe('fetch interceptor — auto-refresh', () => {
9841011
expect(fetchMock).toHaveBeenCalledTimes(3);
9851012
});
9861013

1014+
it('retries after refresh when backend returns no explicit success field (lenient check)', async () => {
1015+
// Some backends return { accessToken: "..." } without an explicit success field.
1016+
// The interceptor must treat any truthy response without success:false as a success.
1017+
fetchMock
1018+
.mockResolvedValueOnce(fakeResponse({ error: 'Unauthorized' }, 401))
1019+
.mockResolvedValueOnce(fakeResponse({ accessToken: 'new-token' })) // no success field
1020+
.mockResolvedValueOnce(fakeResponse({ data: 'ok' }));
1021+
1022+
Object.defineProperty(window, 'location', {
1023+
writable: true,
1024+
configurable: true,
1025+
value: { pathname: '/dashboard', href: 'http://localhost/dashboard', set href(v) {} },
1026+
});
1027+
1028+
await fetch('/api/protected');
1029+
1030+
expect(fetchMock).toHaveBeenCalledTimes(3);
1031+
});
1032+
9871033
it('retries original request after successful token refresh on 403 (Forbidden)', async () => {
9881034
fetchMock
9891035
.mockResolvedValueOnce(fakeResponse({ error: 'Forbidden' }, 403)) // 1st call → 403

0 commit comments

Comments
 (0)