1
0
mirror of https://github.com/sasjs/adapter.git synced 2026-01-14 23:50:06 +00:00

fix(job-execution): refresh access token if it has expired during job status checks

This commit is contained in:
Krishna Acondy
2021-06-21 08:59:12 +01:00
parent d4c8c58552
commit b18b471549
8 changed files with 13305 additions and 84 deletions

13214
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -69,6 +69,7 @@
"axios-cookiejar-support": "^1.0.1", "axios-cookiejar-support": "^1.0.1",
"form-data": "^4.0.0", "form-data": "^4.0.0",
"https": "^1.0.0", "https": "^1.0.0",
"jwt-decode": "^3.1.2",
"tough-cookie": "^4.0.0", "tough-cookie": "^4.0.0",
"url": "^0.11.0" "url": "^0.11.0"
} }

View File

@@ -3,7 +3,9 @@ import {
isRelativePath, isRelativePath,
isUri, isUri,
isUrl, isUrl,
fetchLogByChunks fetchLogByChunks,
isAccessTokenExpiring,
isRefreshTokenExpiring
} from './utils' } from './utils'
import * as NodeFormData from 'form-data' import * as NodeFormData from 'form-data'
import { import {
@@ -29,7 +31,7 @@ import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
import { Logger, LogLevel } from '@sasjs/utils/logger' import { Logger, LogLevel } from '@sasjs/utils/logger'
import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired' import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
import { SasAuthResponse, MacroVar } from '@sasjs/utils/types' import { SasAuthResponse, MacroVar, AuthConfig } from '@sasjs/utils/types'
import { prefixMessage } from '@sasjs/utils/error' import { prefixMessage } from '@sasjs/utils/error'
import * as mime from 'mime' import * as mime from 'mime'
@@ -266,7 +268,7 @@ export class SASViyaApiClient {
* @param jobPath - the path to the file being submitted for execution. * @param jobPath - the path to the file being submitted for execution.
* @param linesOfCode - an array of code lines to execute. * @param linesOfCode - an array of code lines to execute.
* @param contextName - the context to execute the code in. * @param contextName - the context to execute the code in.
* @param accessToken - an access token for an authorized user. * @param authConfig - an object containing an access token, refresh token, client ID and secret.
* @param data - execution data. * @param data - execution data.
* @param debug - when set to true, the log will be returned. * @param debug - when set to true, the log will be returned.
* @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code). * @param expectWebout - when set to true, the automatic _webout fileref will be checked for content, and that content returned. This fileref is used when the Job contains a SASjs web request (as opposed to executing arbitrary SAS code).
@@ -279,7 +281,7 @@ export class SASViyaApiClient {
jobPath: string, jobPath: string,
linesOfCode: string[], linesOfCode: string[],
contextName: string, contextName: string,
accessToken?: string, authConfig?: AuthConfig,
data = null, data = null,
debug: boolean = false, debug: boolean = false,
expectWebout = false, expectWebout = false,
@@ -288,17 +290,20 @@ export class SASViyaApiClient {
printPid = false, printPid = false,
variables?: MacroVar variables?: MacroVar
): Promise<any> { ): Promise<any> {
const { access_token } = authConfig || {}
const logger = process.logger || console
try { try {
const headers: any = { const headers: any = {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
if (accessToken) headers.Authorization = `Bearer ${accessToken}` if (access_token) headers.Authorization = `Bearer ${access_token}`
let executionSessionId: string let executionSessionId: string
const session = await this.sessionManager const session = await this.sessionManager
.getSession(accessToken) .getSession(access_token)
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting session. ') throw prefixMessage(err, 'Error while getting session. ')
}) })
@@ -307,7 +312,7 @@ export class SASViyaApiClient {
if (printPid) { if (printPid) {
const { result: jobIdVariable } = await this.sessionManager const { result: jobIdVariable } = await this.sessionManager
.getVariable(executionSessionId, 'SYSJOBID', accessToken) .getVariable(executionSessionId, 'SYSJOBID', access_token)
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting session variable. ') throw prefixMessage(err, 'Error while getting session variable. ')
}) })
@@ -366,7 +371,7 @@ export class SASViyaApiClient {
if (data) { if (data) {
if (JSON.stringify(data).includes(';')) { if (JSON.stringify(data).includes(';')) {
files = await this.uploadTables(data, accessToken).catch((err) => { files = await this.uploadTables(data, access_token).catch((err) => {
throw prefixMessage(err, 'Error while uploading tables. ') throw prefixMessage(err, 'Error while uploading tables. ')
}) })
@@ -396,7 +401,7 @@ export class SASViyaApiClient {
.post<Job>( .post<Job>(
`/compute/sessions/${executionSessionId}/jobs`, `/compute/sessions/${executionSessionId}/jobs`,
jobRequestBody, jobRequestBody,
accessToken access_token
) )
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while posting job. ') throw prefixMessage(err, 'Error while posting job. ')
@@ -405,8 +410,8 @@ export class SASViyaApiClient {
if (!waitForResult) return session if (!waitForResult) return session
if (debug) { if (debug) {
console.log(`Job has been submitted for '${fileName}'.`) logger.info(`Job has been submitted for '${fileName}'.`)
console.log( logger.info(
`You can monitor the job progress at '${this.serverUrl}${ `You can monitor the job progress at '${this.serverUrl}${
postedJob.links.find((l: any) => l.rel === 'state')!.href postedJob.links.find((l: any) => l.rel === 'state')!.href
}'.` }'.`
@@ -416,7 +421,7 @@ export class SASViyaApiClient {
const jobStatus = await this.pollJobState( const jobStatus = await this.pollJobState(
postedJob, postedJob,
etag, etag,
accessToken, authConfig,
pollOptions pollOptions
).catch(async (err) => { ).catch(async (err) => {
const error = err?.response?.data const error = err?.response?.data
@@ -429,7 +434,7 @@ export class SASViyaApiClient {
const logCount = 1000000 const logCount = 1000000
err.log = await fetchLogByChunks( err.log = await fetchLogByChunks(
this.requestClient, this.requestClient,
accessToken!, access_token!,
sessionLogUrl, sessionLogUrl,
logCount logCount
) )
@@ -440,7 +445,7 @@ export class SASViyaApiClient {
const { result: currentJob } = await this.requestClient const { result: currentJob } = await this.requestClient
.get<Job>( .get<Job>(
`/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`, `/compute/sessions/${executionSessionId}/jobs/${postedJob.id}`,
accessToken access_token
) )
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting job. ') throw prefixMessage(err, 'Error while getting job. ')
@@ -456,7 +461,7 @@ export class SASViyaApiClient {
const logCount = currentJob.logStatistics?.lineCount ?? 1000000 const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks( log = await fetchLogByChunks(
this.requestClient, this.requestClient,
accessToken!, access_token!,
logUrl, logUrl,
logCount logCount
) )
@@ -476,7 +481,7 @@ export class SASViyaApiClient {
if (resultLink) { if (resultLink) {
jobResult = await this.requestClient jobResult = await this.requestClient
.get<any>(resultLink, accessToken, 'text/plain') .get<any>(resultLink, access_token, 'text/plain')
.catch(async (e) => { .catch(async (e) => {
if (e instanceof NotFoundError) { if (e instanceof NotFoundError) {
if (logLink) { if (logLink) {
@@ -484,7 +489,7 @@ export class SASViyaApiClient {
const logCount = currentJob.logStatistics?.lineCount ?? 1000000 const logCount = currentJob.logStatistics?.lineCount ?? 1000000
log = await fetchLogByChunks( log = await fetchLogByChunks(
this.requestClient, this.requestClient,
accessToken!, access_token!,
logUrl, logUrl,
logCount logCount
) )
@@ -503,7 +508,7 @@ export class SASViyaApiClient {
} }
await this.sessionManager await this.sessionManager
.clearSession(executionSessionId, accessToken) .clearSession(executionSessionId, access_token)
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while clearing session. ') throw prefixMessage(err, 'Error while clearing session. ')
}) })
@@ -515,7 +520,7 @@ export class SASViyaApiClient {
jobPath, jobPath,
linesOfCode, linesOfCode,
contextName, contextName,
accessToken, authConfig,
data, data,
debug, debug,
false, false,
@@ -872,13 +877,15 @@ export class SASViyaApiClient {
contextName: string, contextName: string,
debug?: boolean, debug?: boolean,
data?: any, data?: any,
accessToken?: string, authConfig?: AuthConfig,
waitForResult = true, waitForResult = true,
expectWebout = false, expectWebout = false,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false,
variables?: MacroVar variables?: MacroVar
) { ) {
let { access_token, refresh_token, client, secret } = authConfig || {}
if (isRelativePath(sasJob) && !this.rootFolderName) { if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error( throw new Error(
'Relative paths cannot be used without specifying a root folder name' 'Relative paths cannot be used without specifying a root folder name'
@@ -892,7 +899,7 @@ export class SASViyaApiClient {
? `${this.rootFolderName}/${folderPath}` ? `${this.rootFolderName}/${folderPath}`
: folderPath : folderPath
await this.populateFolderMap(fullFolderPath, accessToken).catch((err) => { await this.populateFolderMap(fullFolderPath, access_token).catch((err) => {
throw prefixMessage(err, 'Error while populating folder map. ') throw prefixMessage(err, 'Error while populating folder map. ')
}) })
@@ -906,8 +913,8 @@ export class SASViyaApiClient {
const headers: any = { 'Content-Type': 'application/json' } const headers: any = { 'Content-Type': 'application/json' }
if (!!accessToken) { if (!!access_token) {
headers.Authorization = `Bearer ${accessToken}` headers.Authorization = `Bearer ${access_token}`
} }
const jobToExecute = jobFolder?.find((item) => item.name === jobName) const jobToExecute = jobFolder?.find((item) => item.name === jobName)
@@ -930,7 +937,7 @@ export class SASViyaApiClient {
const { result: jobDefinition } = await this.requestClient const { result: jobDefinition } = await this.requestClient
.get<JobDefinition>( .get<JobDefinition>(
`${this.serverUrl}${jobDefinitionLink.href}`, `${this.serverUrl}${jobDefinitionLink.href}`,
accessToken access_token
) )
.catch((err) => { .catch((err) => {
throw prefixMessage(err, 'Error while getting job definition. ') throw prefixMessage(err, 'Error while getting job definition. ')
@@ -950,7 +957,7 @@ export class SASViyaApiClient {
sasJob, sasJob,
linesToExecute, linesToExecute,
contextName, contextName,
accessToken, authConfig,
data, data,
debug, debug,
expectWebout, expectWebout,
@@ -974,8 +981,9 @@ export class SASViyaApiClient {
contextName: string, contextName: string,
debug: boolean, debug: boolean,
data?: any, data?: any,
accessToken?: string authConfig?: AuthConfig
) { ) {
let { access_token, refresh_token, client, secret } = authConfig || {}
if (isRelativePath(sasJob) && !this.rootFolderName) { if (isRelativePath(sasJob) && !this.rootFolderName) {
throw new Error( throw new Error(
'Relative paths cannot be used without specifying a root folder name.' 'Relative paths cannot be used without specifying a root folder name.'
@@ -988,7 +996,7 @@ export class SASViyaApiClient {
const fullFolderPath = isRelativePath(sasJob) const fullFolderPath = isRelativePath(sasJob)
? `${this.rootFolderName}/${folderPath}` ? `${this.rootFolderName}/${folderPath}`
: folderPath : folderPath
await this.populateFolderMap(fullFolderPath, accessToken) await this.populateFolderMap(fullFolderPath, access_token)
const jobFolder = this.folderMap.get(fullFolderPath) const jobFolder = this.folderMap.get(fullFolderPath)
if (!jobFolder) { if (!jobFolder) {
@@ -1001,7 +1009,7 @@ export class SASViyaApiClient {
let files: any[] = [] let files: any[] = []
if (data && Object.keys(data).length) { if (data && Object.keys(data).length) {
files = await this.uploadTables(data, accessToken) files = await this.uploadTables(data, access_token)
} }
if (!jobToExecute) { if (!jobToExecute) {
@@ -1013,7 +1021,7 @@ export class SASViyaApiClient {
const { result: jobDefinition } = await this.requestClient.get<Job>( const { result: jobDefinition } = await this.requestClient.get<Job>(
`${this.serverUrl}${jobDefinitionLink}`, `${this.serverUrl}${jobDefinitionLink}`,
accessToken access_token
) )
const jobArguments: { [key: string]: any } = { const jobArguments: { [key: string]: any } = {
@@ -1049,18 +1057,18 @@ export class SASViyaApiClient {
const { result: postedJob, etag } = await this.requestClient.post<Job>( const { result: postedJob, etag } = await this.requestClient.post<Job>(
`${this.serverUrl}/jobExecution/jobs?_action=wait`, `${this.serverUrl}/jobExecution/jobs?_action=wait`,
postJobRequestBody, postJobRequestBody,
accessToken access_token
) )
const jobStatus = await this.pollJobState( const jobStatus = await this.pollJobState(
postedJob, postedJob,
etag, etag,
accessToken authConfig
).catch((err) => { ).catch((err) => {
throw prefixMessage(err, 'Error while polling job status. ') throw prefixMessage(err, 'Error while polling job status. ')
}) })
const { result: currentJob } = await this.requestClient.get<Job>( const { result: currentJob } = await this.requestClient.get<Job>(
`${this.serverUrl}/jobExecution/jobs/${postedJob.id}`, `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
accessToken access_token
) )
let jobResult let jobResult
@@ -1071,13 +1079,13 @@ export class SASViyaApiClient {
if (resultLink) { if (resultLink) {
jobResult = await this.requestClient.get<any>( jobResult = await this.requestClient.get<any>(
`${this.serverUrl}${resultLink}/content`, `${this.serverUrl}${resultLink}/content`,
accessToken, access_token,
'text/plain' 'text/plain'
) )
} }
if (debug && logLink) { if (debug && logLink) {
log = await this.requestClient log = await this.requestClient
.get<any>(`${this.serverUrl}${logLink.href}/content`, accessToken) .get<any>(`${this.serverUrl}${logLink.href}/content`, access_token)
.then((res: any) => res.result.items.map((i: any) => i.line).join('\n')) .then((res: any) => res.result.items.map((i: any) => i.line).join('\n'))
} }
if (jobStatus === 'failed') { if (jobStatus === 'failed') {
@@ -1127,12 +1135,28 @@ export class SASViyaApiClient {
private async pollJobState( private async pollJobState(
postedJob: any, postedJob: any,
etag: string | null, etag: string | null,
accessToken?: string, authConfig?: AuthConfig,
pollOptions?: PollOptions pollOptions?: PollOptions
) { ) {
let POLL_INTERVAL = 300 let POLL_INTERVAL = 300
let MAX_POLL_COUNT = 1000 let MAX_POLL_COUNT = 1000
let MAX_ERROR_COUNT = 5 let MAX_ERROR_COUNT = 5
let { access_token, refresh_token, client, secret } = authConfig || {}
if (access_token && refresh_token) {
if (
client &&
secret &&
refresh_token &&
(isAccessTokenExpiring(access_token) ||
isRefreshTokenExpiring(refresh_token))
) {
;({ access_token, refresh_token } = await this.refreshTokens(
client,
secret,
refresh_token
))
}
}
if (pollOptions) { if (pollOptions) {
POLL_INTERVAL = pollOptions.POLL_INTERVAL || POLL_INTERVAL POLL_INTERVAL = pollOptions.POLL_INTERVAL || POLL_INTERVAL
@@ -1146,8 +1170,8 @@ export class SASViyaApiClient {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'If-None-Match': etag 'If-None-Match': etag
} }
if (accessToken) { if (access_token) {
headers.Authorization = `Bearer ${accessToken}` headers.Authorization = `Bearer ${access_token}`
} }
const stateLink = postedJob.links.find((l: any) => l.rel === 'state') const stateLink = postedJob.links.find((l: any) => l.rel === 'state')
if (!stateLink) { if (!stateLink) {
@@ -1157,7 +1181,7 @@ export class SASViyaApiClient {
const { result: state } = await this.requestClient const { result: state } = await this.requestClient
.get<string>( .get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`, `${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
accessToken, access_token,
'text/plain', 'text/plain',
{}, {},
this.debug this.debug
@@ -1185,11 +1209,27 @@ export class SASViyaApiClient {
postedJobState === 'pending' || postedJobState === 'pending' ||
postedJobState === 'unavailable' postedJobState === 'unavailable'
) { ) {
if (access_token && refresh_token) {
if (
client &&
secret &&
refresh_token &&
(isAccessTokenExpiring(access_token) ||
isRefreshTokenExpiring(refresh_token))
) {
;({ access_token, refresh_token } = await this.refreshTokens(
client,
secret,
refresh_token
))
}
}
if (stateLink) { if (stateLink) {
const { result: jobState } = await this.requestClient const { result: jobState } = await this.requestClient
.get<string>( .get<string>(
`${this.serverUrl}${stateLink.href}?_action=wait&wait=300`, `${this.serverUrl}${stateLink.href}?_action=wait&wait=300`,
accessToken, access_token,
'text/plain', 'text/plain',
{}, {},
this.debug this.debug

View File

@@ -4,7 +4,7 @@ import { SASViyaApiClient } from './SASViyaApiClient'
import { SAS9ApiClient } from './SAS9ApiClient' import { SAS9ApiClient } from './SAS9ApiClient'
import { FileUploader } from './FileUploader' import { FileUploader } from './FileUploader'
import { AuthManager } from './auth' import { AuthManager } from './auth'
import { ServerType, MacroVar } from '@sasjs/utils/types' import { ServerType, MacroVar, AuthConfig } from '@sasjs/utils/types'
import { RequestClient } from './request/RequestClient' import { RequestClient } from './request/RequestClient'
import { import {
JobExecutor, JobExecutor,
@@ -240,14 +240,14 @@ export default class SASjs {
* @param fileName - name of the file to run. It will be converted to path to the file being submitted for execution. * @param fileName - name of the file to run. It will be converted to path to the file being submitted for execution.
* @param linesOfCode - lines of sas code from the file to run. * @param linesOfCode - lines of sas code from the file to run.
* @param contextName - context name on which code will be run on the server. * @param contextName - context name on which code will be run on the server.
* @param accessToken - (optional) the access token for authorizing the request. * @param authConfig - (optional) the access token, refresh token, client and secret for authorizing the request.
* @param debug - (optional) if true, global debug config will be overriden * @param debug - (optional) if true, global debug config will be overriden
*/ */
public async executeScriptSASViya( public async executeScriptSASViya(
fileName: string, fileName: string,
linesOfCode: string[], linesOfCode: string[],
contextName: string, contextName: string,
accessToken?: string, authConfig?: AuthConfig,
debug?: boolean debug?: boolean
) { ) {
this.isMethodSupported('executeScriptSASViya', ServerType.SasViya) this.isMethodSupported('executeScriptSASViya', ServerType.SasViya)
@@ -261,7 +261,7 @@ export default class SASjs {
fileName, fileName,
linesOfCode, linesOfCode,
contextName, contextName,
accessToken, authConfig,
null, null,
debug ? debug : this.sasjsConfig.debug debug ? debug : this.sasjsConfig.debug
) )
@@ -777,7 +777,7 @@ export default class SASjs {
* @param config - provide any changes to the config here, for instance to * @param config - provide any changes to the config here, for instance to
* enable/disable `debug`. Any change provided will override the global config, * enable/disable `debug`. Any change provided will override the global config,
* for that particular function call. * for that particular function call.
* @param accessToken - a valid access token that is authorised to execute compute jobs. * @param authConfig - a valid client, secret, refresh and access tokens that are authorised to execute compute jobs.
* The access token is not required when the user is authenticated via the browser. * The access token is not required when the user is authenticated via the browser.
* @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete. * @param waitForResult - a boolean that indicates whether the function needs to wait for execution to complete.
* @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }. * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { MAX_POLL_COUNT: 24 * 60 * 60, POLL_INTERVAL: 1000 }.
@@ -788,7 +788,7 @@ export default class SASjs {
sasJob: string, sasJob: string,
data: any, data: any,
config: any = {}, config: any = {},
accessToken?: string, authConfig?: AuthConfig,
waitForResult?: boolean, waitForResult?: boolean,
pollOptions?: PollOptions, pollOptions?: PollOptions,
printPid = false, printPid = false,
@@ -811,7 +811,7 @@ export default class SASjs {
config.contextName, config.contextName,
config.debug, config.debug,
data, data,
accessToken, authConfig,
!!waitForResult, !!waitForResult,
false, false,
pollOptions, pollOptions,

View File

@@ -1,5 +1,4 @@
import { ServerType } from '@sasjs/utils/types' import { ServerType } from '@sasjs/utils/types'
import { isAuthorizeFormRequired } from '.'
import { RequestClient } from '../request/RequestClient' import { RequestClient } from '../request/RequestClient'
import { serialize } from '../utils' import { serialize } from '../utils'

5
src/types/Process.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
declare namespace NodeJS {
export interface Process {
logger?: import('@sasjs/utils/logger').Logger
}
}

35
src/utils/auth.ts Normal file
View File

@@ -0,0 +1,35 @@
import jwtDecode from 'jwt-decode'
/**
* Checks if the Access Token is expired or is expiring in 1 hour. A default Access Token
* lasts 12 hours. If the Access Token expires, the Refresh Token is used to fetch a new
* Access Token. In the case that the Refresh Token is expired, 1 hour is enough to let
* most jobs finish.
* @param {string} token- token string that will be evaluated
*/
export function isAccessTokenExpiring(token: string): boolean {
if (!token) {
return true
}
const payload = jwtDecode<{ exp: number }>(token)
const timeToLive = payload.exp - new Date().valueOf() / 1000
return timeToLive <= 60 * 60 // 1 hour
}
/**
* Checks if the Refresh Token is expired or expiring in 30 secs. A default Refresh Token
* lasts 30 days. Once the Refresh Token expires, the user must re-authenticate (provide
* credentials in a browser to obtain an authorisation code). 30 seconds is enough time
* to make a request for a final Access Token.
* @param {string} token- token string that will be evaluated
*/
export function isRefreshTokenExpiring(token?: string): boolean {
if (!token) {
return true
}
const payload = jwtDecode<{ exp: number }>(token)
const timeToLive = payload.exp - new Date().valueOf() / 1000
return timeToLive <= 30 // 30 seconds
}

View File

@@ -1,4 +1,5 @@
export * from './asyncForEach' export * from './asyncForEach'
export * from './auth'
export * from './compareTimestamps' export * from './compareTimestamps'
export * from './convertToCsv' export * from './convertToCsv'
export * from './isRelativePath' export * from './isRelativePath'