mirror of
https://github.com/sasjs/adapter.git
synced 2026-07-23 22:55:28 +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:
committed by
GitHub
parent
3d99a9b1e4
commit
b3921f7d05
Generated
+9
-23
@@ -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
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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. ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user