1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-07-23 14:45:29 +00:00

fix: session expiry (#886)

* fix: handle session inactivity expiry

* fix: clear all cookies on session expiry and throw LoginRequiredError

* chore: add diagnostic logging to session recovery flow

* fix: re-establish session on ERR_NETWORK before retrying

* chore: remove diagnostic logging

Partially reverts 706fd8e470

* fix(deps): bump form-data
This commit is contained in:
Sead Mulahasanović
2026-06-18 13:17:28 +02:00
committed by GitHub
parent 3d99a9b1e4
commit b3921f7d05
6 changed files with 118 additions and 26 deletions
+9 -23
View File
@@ -10,7 +10,7 @@
"@sasjs/utils": "^3.5.6", "@sasjs/utils": "^3.5.6",
"axios": "1.16.0", "axios": "1.16.0",
"axios-cookiejar-support": "5.0.5", "axios-cookiejar-support": "5.0.5",
"form-data": "4.0.4", "form-data": "4.0.6",
"https": "1.0.0", "https": "1.0.0",
"tough-cookie": "4.1.3" "tough-cookie": "4.1.3"
}, },
@@ -3556,22 +3556,6 @@
"tough-cookie": ">=4.0.0" "tough-cookie": ">=4.0.0"
} }
}, },
"node_modules/axios/node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-jest": { "node_modules/babel-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -6179,16 +6163,16 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "4.0.4", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -6616,7 +6600,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
+1 -1
View File
@@ -79,7 +79,7 @@
"@sasjs/utils": "^3.5.6", "@sasjs/utils": "^3.5.6",
"axios": "1.16.0", "axios": "1.16.0",
"axios-cookiejar-support": "5.0.5", "axios-cookiejar-support": "5.0.5",
"form-data": "4.0.4", "form-data": "4.0.6",
"https": "1.0.0", "https": "1.0.0",
"tough-cookie": "4.1.3" "tough-cookie": "4.1.3"
} }
+1 -1
View File
@@ -375,7 +375,7 @@ export class AuthManager {
* *
*/ */
public async logOut() { public async logOut() {
this.requestClient.clearCsrfTokens() this.requestClient.resetInMemoryAuthState()
return this.requestClient.get(this.logoutUrl, undefined).then(() => true) return this.requestClient.get(this.logoutUrl, undefined).then(() => true)
} }
+64 -1
View File
@@ -28,6 +28,9 @@ import {
import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError' import { InvalidSASjsCsrfError } from '../types/errors/InvalidSASjsCsrfError'
import { inspect } from 'util' import { inspect } from 'util'
const getLogger = () =>
(typeof process !== 'undefined' && process.logger) || console
export class RequestClient implements HttpClient { export class RequestClient implements HttpClient {
private requests: SASjsRequest[] = [] private requests: SASjsRequest[] = []
private requestsLimit: number = 10 private requestsLimit: number = 10
@@ -37,6 +40,7 @@ export class RequestClient implements HttpClient {
protected csrfToken: CsrfToken = { headerName: '', value: '' } protected csrfToken: CsrfToken = { headerName: '', value: '' }
protected fileUploadCsrfToken: CsrfToken | undefined protected fileUploadCsrfToken: CsrfToken | undefined
protected httpClient!: AxiosInstance protected httpClient!: AxiosInstance
private isRecoveringFromNetworkError = false
constructor( constructor(
protected baseUrl: string, protected baseUrl: string,
@@ -77,6 +81,25 @@ export class RequestClient implements HttpClient {
localStorage.setItem('refreshToken', '') localStorage.setItem('refreshToken', '')
} }
public resetInMemoryAuthState() {
this.clearCsrfTokens()
if (typeof localStorage !== 'undefined') {
this.clearLocalStorageTokens()
}
if (typeof document !== 'undefined') {
this.clearAllCookies()
}
}
private clearAllCookies() {
const cookies = document.cookie.split(';')
for (const cookie of cookies) {
const name = cookie.split('=')[0].trim()
if (!name) continue
document.cookie = `${name}=; Max-Age=0; Path=/;`
}
}
public getBaseUrl() { public getBaseUrl() {
return this.httpClient.defaults.baseURL || '' return this.httpClient.defaults.baseURL || ''
} }
@@ -354,6 +377,7 @@ export class RequestClient implements HttpClient {
const csrfTokenKey = Object.keys(params).find((k) => const csrfTokenKey = Object.keys(params).find((k) =>
k?.toLowerCase().includes('csrf') k?.toLowerCase().includes('csrf')
) )
if (csrfTokenKey) { if (csrfTokenKey) {
this.csrfToken.value = params[csrfTokenKey] this.csrfToken.value = params[csrfTokenKey]
this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token' this.csrfToken.headerName = this.csrfToken.headerName || 'x-csrf-token'
@@ -378,7 +402,7 @@ export class RequestClient implements HttpClient {
}) })
.then((res) => res.data) .then((res) => res.data)
.catch((error) => { .catch((error) => {
const logger = process.logger || console const logger = getLogger()
logger.error(error) logger.error(error)
}) })
} }
@@ -687,6 +711,45 @@ ${resHeaders}${parsedResBody ? `\n\n${parsedResBody}` : ''}
throw new CertificateError(e.message) 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, re-establish session via GET /,
// then retry the original request.
this.resetInMemoryAuthState()
this.isRecoveringFromNetworkError = true
try {
// Re-establish session and CSRF cookie
const rootResponse = await this.httpClient
.get('/', { withXSRFToken: true })
.catch((err) => err.response)
if (rootResponse?.data) {
const cookie =
/<script>document.cookie = '(XSRF-TOKEN=.*; Max-Age=86400; SameSite=Strict; Path=\/;)'<\/script>/.exec(
rootResponse.data
)?.[1]
if (cookie && typeof document !== 'undefined') {
document.cookie = cookie
}
this.parseAndSetCsrfToken(rootResponse)
}
return await callback()
} catch {
// Session could not be recovered — surface LoginRequiredError
throw new LoginRequiredError()
} finally {
this.isRecoveringFromNetworkError = false
}
}
if (e.message) throw e if (e.message) throw e
else throw prefixMessage(e, 'Error while handling error. ') else throw prefixMessage(e, 'Error while handling error. ')
} }
+7
View File
@@ -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) { public async login(username: string, password: string, jobsPath: string) {
const codeInjectorPath = `/User Folders/${username}/My Folder/sasjs/runner` const codeInjectorPath = `/User Folders/${username}/My Folder/sasjs/runner`
if (this.httpClient.defaults.jar) { if (this.httpClient.defaults.jar) {
+36
View File
@@ -589,6 +589,42 @@ ${resHeaders[0]}: ${resHeaders[1]}${
requestClient['handleError'](error, () => {}, false) requestClient['handleError'](error, () => {}, false)
).resolves.toEqual(undefined) ).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 throw LoginRequiredError 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.toThrow(LoginRequiredError)
expect(innerHandle).toHaveBeenCalledTimes(1)
})
}) })
}) })