diff --git a/src/auth/AuthManager.ts b/src/auth/AuthManager.ts index 1c108e6..cfcd03b 100644 --- a/src/auth/AuthManager.ts +++ b/src/auth/AuthManager.ts @@ -375,7 +375,7 @@ export class AuthManager { * */ public async logOut() { - this.requestClient.clearCsrfTokens() + this.requestClient.resetInMemoryAuthState() return this.requestClient.get(this.logoutUrl, undefined).then(() => true) } diff --git a/src/request/RequestClient.ts b/src/request/RequestClient.ts index 82ba21b..b6b3d03 100644 --- a/src/request/RequestClient.ts +++ b/src/request/RequestClient.ts @@ -37,6 +37,7 @@ export class RequestClient implements HttpClient { protected csrfToken: CsrfToken = { headerName: '', value: '' } protected fileUploadCsrfToken: CsrfToken | undefined protected httpClient!: AxiosInstance + private isRecoveringFromNetworkError = false constructor( protected baseUrl: string, @@ -77,6 +78,16 @@ export class RequestClient implements HttpClient { localStorage.setItem('refreshToken', '') } + public resetInMemoryAuthState() { + this.clearCsrfTokens() + if (typeof localStorage !== 'undefined') { + this.clearLocalStorageTokens() + } + if (typeof document !== 'undefined') { + document.cookie = 'XSRF-TOKEN=; Max-Age=0; Path=/;' + } + } + public getBaseUrl() { return this.httpClient.defaults.baseURL || '' } @@ -687,6 +698,24 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''} throw new CertificateError(e.message) } + if ( + e.isAxiosError && + !response && + e.code === 'ERR_NETWORK' && + !this.isRecoveringFromNetworkError + ) { + // Opaque ERR_NETWORK usually means the server rejected stale credentials. + // Wipe in-memory auth state so the retry either succeeds + // or surfaces a clean LoginRequiredError. + this.resetInMemoryAuthState() + this.isRecoveringFromNetworkError = true + try { + return await callback() + } finally { + this.isRecoveringFromNetworkError = false + } + } + if (e.message) throw e else throw prefixMessage(e, 'Error while handling error. ') } diff --git a/src/request/Sas9RequestClient.ts b/src/request/Sas9RequestClient.ts index d4a249e..43ccc0a 100644 --- a/src/request/Sas9RequestClient.ts +++ b/src/request/Sas9RequestClient.ts @@ -23,6 +23,13 @@ export class Sas9RequestClient extends RequestClient { } } + public resetInMemoryAuthState() { + super.resetInMemoryAuthState() + if (this.httpClient.defaults.jar) { + ;(this.httpClient.defaults.jar as tough.CookieJar).removeAllCookiesSync() + } + } + public async login(username: string, password: string, jobsPath: string) { const codeInjectorPath = `/User Folders/${username}/My Folder/sasjs/runner` if (this.httpClient.defaults.jar) { diff --git a/src/test/RequestClient.spec.ts b/src/test/RequestClient.spec.ts index fa193c1..0f7606d 100644 --- a/src/test/RequestClient.spec.ts +++ b/src/test/RequestClient.spec.ts @@ -589,6 +589,42 @@ ${resHeaders[0]}: ${resHeaders[1]}${ requestClient['handleError'](error, () => {}, false) ).resolves.toEqual(undefined) }) + + it('should clear CSRF and retry once on opaque ERR_NETWORK', async () => { + const networkError = { + isAxiosError: true, + code: 'ERR_NETWORK', + message: 'Network Error' + } + requestClient['csrfToken'] = { headerName: 'h', value: 'v' } + const callback = jest.fn().mockResolvedValue('ok') + + await expect( + requestClient['handleError'](networkError, callback) + ).resolves.toEqual('ok') + + expect(callback).toHaveBeenCalledTimes(1) + expect(requestClient['csrfToken']).toEqual({ headerName: '', value: '' }) + }) + + it('should not loop if retry also fails with ERR_NETWORK', async () => { + const networkError = { + isAxiosError: true, + code: 'ERR_NETWORK', + message: 'Network Error' + } + const innerHandle = jest.fn(() => + requestClient['handleError'](networkError, () => + Promise.reject(networkError) + ) + ) + + await expect( + requestClient['handleError'](networkError, innerHandle) + ).rejects.toEqual(networkError) + + expect(innerHandle).toHaveBeenCalledTimes(1) + }) }) })