@@ -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