@sasjs/adapter 4.17.2 → 4.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -92,12 +92,12 @@
92
92
  },
93
93
  "main": "index.js",
94
94
  "dependencies": {
95
- "@sasjs/utils": "^3.5.6",
95
+ "@sasjs/utils": "^3.6.0",
96
96
  "axios": "1.18.1",
97
97
  "axios-cookiejar-support": "5.0.5",
98
98
  "form-data": "4.0.6",
99
99
  "https": "1.0.0",
100
100
  "tough-cookie": "4.1.3"
101
101
  },
102
- "version": "4.17.2"
102
+ "version": "4.18.0"
103
103
  }
@@ -18,12 +18,17 @@ import {
18
18
  } from './types/errors'
19
19
  import { SessionManager } from './SessionManager'
20
20
  import { ContextManager } from './ContextManager'
21
- import { SasAuthResponse, MacroVar, AuthConfig } from '@sasjs/utils/types'
21
+ import {
22
+ SasAuthResponse,
23
+ MacroVar,
24
+ AuthConfig,
25
+ ServerType
26
+ } from '@sasjs/utils/types'
22
27
  import { isAuthorizeFormRequired } from './auth/isAuthorizeFormRequired'
23
28
  import { RequestClient } from './request/RequestClient'
24
29
  import { prefixMessage } from '@sasjs/utils/error'
25
30
  import { pollJobState } from './api/viya/pollJobState'
26
- import { getTokens } from './auth/getTokens'
31
+ import { getTokens, OnTokensRefreshed } from './auth/getTokens'
27
32
  import { uploadTables } from './api/viya/uploadTables'
28
33
  import { executeOnComputeApi } from './api/viya/executeOnComputeApi'
29
34
  import { getAccessTokenForViya } from './auth/getAccessTokenForViya'
@@ -337,6 +342,7 @@ export class SASViyaApiClient {
337
342
  * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
338
343
  * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
339
344
  * @param variables - an object that represents macro variables.
345
+ * @param onTokensRefreshed - optional callback invoked with the rotated token pair after a successful internal refresh, so consumers that persist tokens can store the new pair.
340
346
  */
341
347
  public async executeScript(
342
348
  jobPath: string,
@@ -349,7 +355,8 @@ export class SASViyaApiClient {
349
355
  waitForResult = true,
350
356
  pollOptions?: PollOptions,
351
357
  printPid = false,
352
- variables?: MacroVar
358
+ variables?: MacroVar,
359
+ onTokensRefreshed?: OnTokensRefreshed
353
360
  ): Promise<any> {
354
361
  return executeOnComputeApi(
355
362
  this.requestClient,
@@ -365,7 +372,8 @@ export class SASViyaApiClient {
365
372
  waitForResult,
366
373
  pollOptions,
367
374
  printPid,
368
- variables
375
+ variables,
376
+ onTokensRefreshed
369
377
  )
370
378
  }
371
379
 
@@ -864,11 +872,17 @@ export class SASViyaApiClient {
864
872
  expectWebout = false,
865
873
  pollOptions?: PollOptions,
866
874
  printPid = false,
867
- variables?: MacroVar
875
+ variables?: MacroVar,
876
+ onTokensRefreshed?: OnTokensRefreshed
868
877
  ) {
869
878
  let access_token = (authConfig || {}).access_token
870
879
  if (authConfig) {
871
- ;({ access_token } = await getTokens(this.requestClient, authConfig))
880
+ ;({ access_token } = await getTokens(
881
+ this.requestClient,
882
+ authConfig,
883
+ ServerType.SasViya,
884
+ onTokensRefreshed
885
+ ))
872
886
  }
873
887
 
874
888
  if (isRelativePath(sasJob) && !this.rootFolderName) {
@@ -943,7 +957,8 @@ export class SASViyaApiClient {
943
957
  waitForResult,
944
958
  pollOptions,
945
959
  printPid,
946
- variables
960
+ variables,
961
+ onTokensRefreshed
947
962
  )
948
963
  }
949
964
 
@@ -954,18 +969,25 @@ export class SASViyaApiClient {
954
969
  * @param debug - sets the _debug flag in the job arguments.
955
970
  * @param data - any data to be passed in as input to the job.
956
971
  * @param accessToken - an optional access token for an authorized user.
972
+ * @param onTokensRefreshed - optional callback invoked with the rotated token pair after a successful internal refresh, so consumers that persist tokens can store the new pair.
957
973
  */
958
974
  public async executeJob(
959
975
  sasJob: string,
960
976
  contextName: string,
961
977
  debug: boolean,
962
978
  data?: any,
963
- authConfig?: AuthConfig
979
+ authConfig?: AuthConfig,
980
+ onTokensRefreshed?: OnTokensRefreshed
964
981
  ): Promise<JobExecutionResult> {
965
982
  let access_token = (authConfig || {}).access_token
966
983
 
967
984
  if (authConfig) {
968
- ;({ access_token } = await getTokens(this.requestClient, authConfig))
985
+ ;({ access_token } = await getTokens(
986
+ this.requestClient,
987
+ authConfig,
988
+ ServerType.SasViya,
989
+ onTokensRefreshed
990
+ ))
969
991
  }
970
992
 
971
993
  if (isRelativePath(sasJob) && !this.rootFolderName) {
@@ -1050,11 +1072,14 @@ export class SASViyaApiClient {
1050
1072
  access_token
1051
1073
  )
1052
1074
 
1053
- const jobStatus = await this.pollJobState(postedJob, authConfig).catch(
1054
- (err) => {
1055
- throw prefixMessage(err, 'Error while polling job status. ')
1056
- }
1057
- )
1075
+ const jobStatus = await this.pollJobState(
1076
+ postedJob,
1077
+ authConfig,
1078
+ undefined,
1079
+ onTokensRefreshed
1080
+ ).catch((err) => {
1081
+ throw prefixMessage(err, 'Error while polling job status. ')
1082
+ })
1058
1083
 
1059
1084
  const { result: currentJob } = await this.requestClient.get<Job>(
1060
1085
  `${this.serverUrl}/jobExecution/jobs/${postedJob.id}`,
@@ -1138,14 +1163,17 @@ export class SASViyaApiClient {
1138
1163
  private async pollJobState(
1139
1164
  postedJob: Job,
1140
1165
  authConfig?: AuthConfig,
1141
- pollOptions?: PollOptions
1166
+ pollOptions?: PollOptions,
1167
+ onTokensRefreshed?: OnTokensRefreshed
1142
1168
  ) {
1143
1169
  return pollJobState(
1144
1170
  this.requestClient,
1145
1171
  postedJob,
1146
1172
  this.debug,
1147
1173
  authConfig,
1148
- pollOptions
1174
+ pollOptions,
1175
+ undefined,
1176
+ onTokensRefreshed
1149
1177
  )
1150
1178
  }
1151
1179
 
package/src/SASjs.ts CHANGED
@@ -16,6 +16,7 @@ import { SASViyaApiClient } from './SASViyaApiClient'
16
16
  import { SAS9ApiClient } from './SAS9ApiClient'
17
17
  import { SASjsApiClient, SASjsAuthResponse } from './SASjsApiClient'
18
18
  import { AuthManager } from './auth'
19
+ import { OnTokensRefreshed } from './auth/getTokens'
19
20
  import {
20
21
  ServerType,
21
22
  MacroVar,
@@ -45,6 +46,7 @@ interface ExecuteScriptParams {
45
46
  authConfig?: AuthConfig
46
47
  authConfigSas9?: AuthConfigSas9
47
48
  debug?: boolean
49
+ onTokensRefreshed?: OnTokensRefreshed
48
50
  }
49
51
 
50
52
  const defaultConfig: SASjsConfig = {
@@ -101,6 +103,7 @@ export default class SASjs {
101
103
  * @param authConfig - (optional) a valid client, secret, refresh and access tokens that are authorised to execute scripts.
102
104
  * @param authConfigSas9 - (required for server type sas9) a valid username and password that are authorised to execute scripts.
103
105
  * @param debug - (optional) if true, global debug config will be overriden
106
+ * @param onTokensRefreshed - (optional, SAS Viya only) callback invoked with the rotated token pair after a successful internal refresh, so consumers that persist tokens can store the new pair.
104
107
  */
105
108
  public async executeScript({
106
109
  linesOfCode,
@@ -109,7 +112,8 @@ export default class SASjs {
109
112
  runTime,
110
113
  authConfig,
111
114
  authConfigSas9,
112
- debug
115
+ debug,
116
+ onTokensRefreshed
113
117
  }: ExecuteScriptParams) {
114
118
  this.isMethodSupported('executeScript', [
115
119
  ServerType.Sas9,
@@ -157,7 +161,13 @@ export default class SASjs {
157
161
  contextName,
158
162
  authConfig,
159
163
  null,
160
- debug ? debug : this.sasjsConfig.debug
164
+ debug ? debug : this.sasjsConfig.debug,
165
+ false,
166
+ true,
167
+ undefined,
168
+ false,
169
+ undefined,
170
+ onTokensRefreshed
161
171
  )
162
172
  }
163
173
  }
@@ -1,5 +1,5 @@
1
1
  import { timestampToYYYYMMDDHHMMSS } from '@sasjs/utils/time'
2
- import { AuthConfig, MacroVar } from '@sasjs/utils/types'
2
+ import { AuthConfig, MacroVar, ServerType } from '@sasjs/utils/types'
3
3
  import { prefixMessage } from '@sasjs/utils/error'
4
4
  import {
5
5
  PollOptions,
@@ -7,7 +7,7 @@ import {
7
7
  ComputeJobExecutionError,
8
8
  NotFoundError
9
9
  } from '../..'
10
- import { getTokens } from '../../auth/getTokens'
10
+ import { getTokens, OnTokensRefreshed } from '../../auth/getTokens'
11
11
  import { RequestClient } from '../../request/RequestClient'
12
12
  import { SessionManager } from '../../SessionManager'
13
13
  import { isRelativePath, fetchLogByChunks } from '../../utils'
@@ -32,6 +32,7 @@ interface JobRequestBody {
32
32
  * @param pollOptions - an object that represents poll interval(milliseconds) and maximum amount of attempts. Object example: { maxPollCount: 24 * 60 * 60, pollInterval: 1000 }. More information available at src/api/viya/pollJobState.ts.
33
33
  * @param printPid - a boolean that indicates whether the function should print (PID) of the started job.
34
34
  * @param variables - an object that represents macro variables.
35
+ * @param onTokensRefreshed - optional callback invoked with the rotated token pair after a successful internal refresh, so consumers that persist tokens can store the new pair.
35
36
  */
36
37
  export async function executeOnComputeApi(
37
38
  requestClient: RequestClient,
@@ -47,12 +48,18 @@ export async function executeOnComputeApi(
47
48
  waitForResult = true,
48
49
  pollOptions?: PollOptions,
49
50
  printPid = false,
50
- variables?: MacroVar
51
+ variables?: MacroVar,
52
+ onTokensRefreshed?: OnTokensRefreshed
51
53
  ): Promise<any> {
52
54
  let access_token = (authConfig || {}).access_token
53
55
 
54
56
  if (authConfig) {
55
- ;({ access_token } = await getTokens(requestClient, authConfig))
57
+ ;({ access_token } = await getTokens(
58
+ requestClient,
59
+ authConfig,
60
+ ServerType.SasViya,
61
+ onTokensRefreshed
62
+ ))
56
63
  }
57
64
 
58
65
  const logger = process.logger || console
@@ -173,7 +180,8 @@ export async function executeOnComputeApi(
173
180
  {
174
181
  session,
175
182
  sessionManager
176
- }
183
+ },
184
+ onTokensRefreshed
177
185
  ).catch(async (err) => {
178
186
  const error = err?.response?.data
179
187
  const result = /err=[0-9]*,/.exec(error)
@@ -196,7 +204,12 @@ export async function executeOnComputeApi(
196
204
  })
197
205
 
198
206
  if (authConfig) {
199
- ;({ access_token } = await getTokens(requestClient, authConfig))
207
+ ;({ access_token } = await getTokens(
208
+ requestClient,
209
+ authConfig,
210
+ ServerType.SasViya,
211
+ onTokensRefreshed
212
+ ))
200
213
  }
201
214
 
202
215
  const { result: currentJob } = await requestClient
@@ -1,6 +1,6 @@
1
- import { AuthConfig } from '@sasjs/utils/types'
1
+ import { AuthConfig, ServerType } from '@sasjs/utils/types'
2
2
  import { Job, PollOptions, PollStrategy } from '../..'
3
- import { getTokens } from '../../auth/getTokens'
3
+ import { getTokens, OnTokensRefreshed } from '../../auth/getTokens'
4
4
  import { RequestClient } from '../../request/RequestClient'
5
5
  import { JobStatePollError } from '../../types/errors'
6
6
  import { Link, WriteStream, SessionState, JobSessionManager } from '../../types'
@@ -46,7 +46,8 @@ export async function pollJobState(
46
46
  debug: boolean,
47
47
  authConfig?: AuthConfig,
48
48
  pollOptions?: PollOptions,
49
- jobSessionManager?: JobSessionManager
49
+ jobSessionManager?: JobSessionManager,
50
+ onTokensRefreshed?: OnTokensRefreshed
50
51
  ): Promise<JobState> {
51
52
  const logger = process.logger || console
52
53
 
@@ -130,7 +131,8 @@ export async function pollJobState(
130
131
  authConfig,
131
132
  streamLog,
132
133
  logFileStream,
133
- jobSessionManager
134
+ jobSessionManager,
135
+ onTokensRefreshed
134
136
  )
135
137
 
136
138
  currentState = result.state
@@ -162,7 +164,8 @@ export async function pollJobState(
162
164
  authConfig,
163
165
  streamLog,
164
166
  logFileStream,
165
- jobSessionManager
167
+ jobSessionManager,
168
+ onTokensRefreshed
166
169
  )
167
170
 
168
171
  currentState = result.state
@@ -179,14 +182,21 @@ const getJobState = async (
179
182
  job: Job,
180
183
  currentState: string,
181
184
  debug: boolean,
182
- authConfig?: AuthConfig
185
+ authConfig?: AuthConfig,
186
+ onTokensRefreshed?: OnTokensRefreshed
183
187
  ): Promise<JobState> => {
184
188
  const stateLink = job.links.find((l: any) => l.rel === 'state')!
185
189
 
186
190
  if (needsRetry(currentState)) {
187
191
  let tokens
188
192
 
189
- if (authConfig) tokens = await getTokens(requestClient, authConfig)
193
+ if (authConfig)
194
+ tokens = await getTokens(
195
+ requestClient,
196
+ authConfig,
197
+ ServerType.SasViya,
198
+ onTokensRefreshed
199
+ )
190
200
 
191
201
  const { result: jobState } = await requestClient
192
202
  .get<string>(
@@ -236,7 +246,8 @@ export const doPoll = async (
236
246
  authConfig?: AuthConfig,
237
247
  streamLog?: boolean,
238
248
  logStream?: WriteStream,
239
- jobSessionManager?: JobSessionManager
249
+ jobSessionManager?: JobSessionManager,
250
+ onTokensRefreshed?: OnTokensRefreshed
240
251
  ): Promise<{ state: JobState; pollCount: number }> => {
241
252
  const { maxPollCount, pollInterval } = pollOptions
242
253
  const logger = process.logger || console
@@ -287,7 +298,8 @@ export const doPoll = async (
287
298
  postedJob,
288
299
  state,
289
300
  debug,
290
- authConfig
301
+ authConfig,
302
+ onTokensRefreshed
291
303
  ).catch((err) => {
292
304
  errorCount++
293
305
 
@@ -8,26 +8,76 @@ import { RequestClient } from '../request/RequestClient'
8
8
  import { refreshTokensForViya } from './refreshTokensForViya'
9
9
  import { refreshTokensForSasjs } from './refreshTokensForSasjs'
10
10
 
11
+ /**
12
+ * Callback invoked with the rotated token pair after a successful internal
13
+ * refresh. Viya refresh tokens are single-use/rotating, so consumers that
14
+ * persist tokens (e.g. the CLI writing `.env.{target}`) should pass a handler
15
+ * wherever this is accepted to avoid the persisted pair going stale.
16
+ */
17
+ export type OnTokensRefreshed = (tokens: {
18
+ access_token: string
19
+ refresh_token: string
20
+ }) => void | Promise<void>
21
+
11
22
  /**
12
23
  * Returns the auth configuration, refreshing the tokens if necessary.
13
24
  * This function can only be used by Node, if a server type is SASVIYA.
14
25
  * @param requestClient - the pre-configured HTTP request client
15
26
  * @param authConfig - an object containing a client ID, secret, access token and refresh token
16
27
  * @param serverType - server type for which refreshing the tokens, defaults to SASVIYA
28
+ * @param onTokensRefreshed - optional callback invoked with the rotated token
29
+ * pair after a successful internal refresh.
17
30
  */
18
31
  export async function getTokens(
19
32
  requestClient: RequestClient,
20
33
  authConfig: AuthConfig,
21
- serverType: ServerType = ServerType.SasViya
34
+ serverType: ServerType = ServerType.SasViya,
35
+ onTokensRefreshed?: OnTokensRefreshed
22
36
  ): Promise<AuthConfig> {
23
37
  const logger = process.logger || console
24
- let { access_token, refresh_token, client, secret } = authConfig
38
+ // Note: client/secret are NOT mutated. The sas.cli public-client fallback
39
+ // is applied only to the refresh call itself, so a caller that round-trips
40
+ // the returned AuthConfig into persistent storage never ends up saving
41
+ // fabricated CLIENT/SECRET values.
42
+ let { access_token, refresh_token } = authConfig
43
+ const { client, secret } = authConfig
44
+
45
+ // Tokens are not always decodable JWTs - some servers issue opaque tokens.
46
+ // jwt-decode throws InvalidTokenError for these; only in that case is the
47
+ // token treated as still valid (the server is the authority on expiry and
48
+ // will reject a genuinely expired token on use/refresh).
49
+ // Use a 300 s (5 min) safety margin instead of the isAccessTokenExpiring
50
+ // default of 3600 s (1 h). Some Viya estates issue access tokens with a
51
+ // 1-hour TTL; with the 3600 s default a brand-new 1 h token is immediately
52
+ // considered "expiring", causing a redundant refresh even when the CLI has
53
+ // already refreshed the token moments earlier. 300 s is short enough that a
54
+ // fresh 1 h token (TTL ≈ 3600 ≫ 300) is NOT considered expiring, yet long
55
+ // enough to let a single API call complete before the token actually expires.
56
+ // Long-running jobs are protected because pollJobState calls getTokens on
57
+ // every poll, so a token that does expire mid-job is refreshed in-place.
58
+ let isAccessTokenExpiringSoon = false
59
+ try {
60
+ isAccessTokenExpiringSoon = isAccessTokenExpiring(access_token, 300)
61
+ } catch (e) {
62
+ if ((e as Error).name !== 'InvalidTokenError') throw e
63
+ }
64
+
65
+ let isRefreshTokenExpired = false
66
+ try {
67
+ isRefreshTokenExpired = hasTokenExpired(refresh_token)
68
+ } catch (e) {
69
+ if ((e as Error).name !== 'InvalidTokenError') throw e
70
+ }
71
+
72
+ let isRefreshTokenExpiringSoon = false
73
+ try {
74
+ isRefreshTokenExpiringSoon = isRefreshTokenExpiring(refresh_token)
75
+ } catch (e) {
76
+ if ((e as Error).name !== 'InvalidTokenError') throw e
77
+ }
25
78
 
26
- if (
27
- isAccessTokenExpiring(access_token) ||
28
- isRefreshTokenExpiring(refresh_token)
29
- ) {
30
- if (hasTokenExpired(refresh_token)) {
79
+ if (isAccessTokenExpiringSoon || isRefreshTokenExpiringSoon) {
80
+ if (isRefreshTokenExpired) {
31
81
  const error =
32
82
  'Unable to obtain new access token. Your refresh token has expired.'
33
83
 
@@ -38,16 +88,39 @@ export async function getTokens(
38
88
 
39
89
  logger.info('Refreshing access and refresh tokens.')
40
90
 
91
+ // Tokens minted without a registered OAuth client (e.g. via the
92
+ // password grant against the built-in public client) can still be
93
+ // refreshed using that same secret-less client. A configured client
94
+ // with a missing secret is treated as a public client (empty secret).
41
95
  const tokens =
42
96
  serverType === ServerType.SasViya
43
97
  ? await refreshTokensForViya(
44
98
  requestClient,
45
- client,
46
- secret,
99
+ client || 'sas.cli',
100
+ secret || '',
47
101
  refresh_token
48
102
  )
49
103
  : await refreshTokensForSasjs(requestClient, refresh_token)
50
104
  ;({ access_token, refresh_token } = tokens)
105
+
106
+ // Viya refresh tokens are single-use/rotating. Callers throughout the
107
+ // compute-execution chain (executeOnComputeApi, pollJobState's poll
108
+ // loop, executeComputeJob -> executeJob, etc.) share this same
109
+ // authConfig object by reference and call getTokens again later in the
110
+ // same execution - update it in place so a later call in the same chain
111
+ // refreshes with the token that was just issued, not the one that was
112
+ // just consumed. client/secret are deliberately left untouched (see
113
+ // note above).
114
+ authConfig.access_token = access_token
115
+ authConfig.refresh_token = refresh_token
116
+
117
+ // A failing persistence handler must not turn a successful refresh into
118
+ // an auth failure - log the error and return the fresh pair regardless.
119
+ try {
120
+ await onTokensRefreshed?.({ access_token, refresh_token })
121
+ } catch (e) {
122
+ logger.error('onTokensRefreshed handler failed:', e)
123
+ }
51
124
  }
52
125
 
53
126
  return { access_token, refresh_token, client, secret }
package/src/index.ts CHANGED
@@ -5,4 +5,5 @@ export * from './SASViyaApiClient'
5
5
  export * from './SAS9ApiClient'
6
6
  export * from './SASjsApiClient'
7
7
  export * from './request/SasjsRequestClient'
8
+ export type { OnTokensRefreshed } from './auth/getTokens'
8
9
  export default SASjs