@jacebenson/jsn 4.2.4 → 4.3.1
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/README.md +12 -0
- package/package.json +2 -2
- package/src/app.js +9 -0
- package/src/auth.js +386 -100
- package/src/cli.js +18 -11
- package/src/commands/auth.js +208 -70
- package/src/commands/decisiontables.js +178 -0
- package/src/commands/dev/_simple.js +1 -2
- package/src/commands/dev/flows.js +92 -838
- package/src/commands/docs/db.js +60 -2
- package/src/commands/perf.js +143 -0
- package/src/decision-table-crud.js +266 -0
- package/src/decision-table-inspection.js +260 -0
- package/src/errors.js +1 -1
- package/src/flow-inspection.js +844 -0
- package/src/flow-publish.js +357 -0
- package/src/helpers.js +7 -1
- package/src/perf-capture.js +112 -0
- package/src/perf.js +335 -0
- package/src/sdk.js +120 -25
- package/src/session.js +14 -0
- package/src/commands/instance.js +0 -362
package/src/auth.js
CHANGED
|
@@ -112,14 +112,19 @@ const KEYRING_SERVICE = 'servicenow-cli';
|
|
|
112
112
|
const KEYRING_ATTR_SERVICE = 'service';
|
|
113
113
|
const KEYRING_ATTR_USERNAME = 'username';
|
|
114
114
|
|
|
115
|
-
function
|
|
115
|
+
function credentialsFilePath(key) {
|
|
116
116
|
const dir = path.join(globalConfigDir(), 'credentials');
|
|
117
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
118
117
|
// Match Go's filename encoding: replace :// and / and : with _
|
|
119
118
|
const filename = key + '.json';
|
|
120
119
|
return path.join(dir, filename);
|
|
121
120
|
}
|
|
122
121
|
|
|
122
|
+
function credentialsPath(key) {
|
|
123
|
+
const filePath = credentialsFilePath(key);
|
|
124
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
125
|
+
return filePath;
|
|
126
|
+
}
|
|
127
|
+
|
|
123
128
|
function getOAuthClientID() {
|
|
124
129
|
return process.env.SERVICENOW_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
|
|
125
130
|
}
|
|
@@ -167,6 +172,9 @@ function keyringLookup(key) {
|
|
|
167
172
|
refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
|
|
168
173
|
expires_at: parsed.expires_at || parsed.ExpiresAt || 0,
|
|
169
174
|
created_at: parsed.created_at || parsed.CreatedAt || 0,
|
|
175
|
+
auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(parsed.auth_source)
|
|
176
|
+
? (parsed.auth_method === 'gck' ? 'gck' : 'keyring')
|
|
177
|
+
: parsed.auth_source,
|
|
170
178
|
};
|
|
171
179
|
} catch {
|
|
172
180
|
return null;
|
|
@@ -206,16 +214,25 @@ function keyringDelete(key) {
|
|
|
206
214
|
* When username is omitted (legacy configs without profile user), uses
|
|
207
215
|
* bare instance key for lookup.
|
|
208
216
|
*/
|
|
209
|
-
function loadCredentials(instance, username) {
|
|
217
|
+
function loadCredentials(instance, username, injectedStore) {
|
|
218
|
+
if (injectedStore) return injectedStore.load(instance, username);
|
|
210
219
|
const key = username ? credKey(instance, username) : credKey(instance);
|
|
211
220
|
const keyringCreds = keyringLookup(key);
|
|
212
221
|
if (keyringCreds) {
|
|
213
|
-
return {
|
|
222
|
+
return {
|
|
223
|
+
...keyringCreds,
|
|
224
|
+
auth_source: keyringCreds.auth_source || (keyringCreds.auth_method === 'gck' ? 'gck' : 'keyring'),
|
|
225
|
+
};
|
|
214
226
|
}
|
|
215
227
|
try {
|
|
216
|
-
const data = fs.readFileSync(
|
|
228
|
+
const data = fs.readFileSync(credentialsFilePath(key), 'utf-8');
|
|
217
229
|
const creds = JSON.parse(data);
|
|
218
|
-
return {
|
|
230
|
+
return {
|
|
231
|
+
...creds,
|
|
232
|
+
auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(creds.auth_source)
|
|
233
|
+
? (creds.auth_method === 'gck' ? 'gck' : 'file')
|
|
234
|
+
: creds.auth_source,
|
|
235
|
+
};
|
|
219
236
|
} catch {
|
|
220
237
|
return null;
|
|
221
238
|
}
|
|
@@ -225,7 +242,11 @@ function loadCredentials(instance, username) {
|
|
|
225
242
|
* Save credentials for an instance.
|
|
226
243
|
* Uses <username>@<instance> key when username is available.
|
|
227
244
|
*/
|
|
228
|
-
function saveCredentials(instance, creds, username) {
|
|
245
|
+
function saveCredentials(instance, creds, username, injectedStore) {
|
|
246
|
+
if (injectedStore) {
|
|
247
|
+
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
248
|
+
return injectedStore.save(instance, creds, username);
|
|
249
|
+
}
|
|
229
250
|
const key = username ? credKey(instance, username) : credKey(instance);
|
|
230
251
|
// Stamp last_seen on every credential save
|
|
231
252
|
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
@@ -235,29 +256,11 @@ function saveCredentials(instance, creds, username) {
|
|
|
235
256
|
}
|
|
236
257
|
}
|
|
237
258
|
|
|
238
|
-
/**
|
|
239
|
-
* Get the last_seen timestamp for an instance, if available.
|
|
240
|
-
*/
|
|
241
|
-
function getLastSeen(instance, username) {
|
|
242
|
-
const creds = loadCredentials(instance, username);
|
|
243
|
-
if (!creds || !creds.last_seen) return null;
|
|
244
|
-
return creds.last_seen;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/**
|
|
248
|
-
* Update the last_seen timestamp for an instance to now.
|
|
249
|
-
*/
|
|
250
|
-
function touchLastSeen(instance, username) {
|
|
251
|
-
const creds = loadCredentials(instance, username);
|
|
252
|
-
if (!creds) return;
|
|
253
|
-
creds.last_seen = Math.floor(Date.now() / 1000);
|
|
254
|
-
saveCredentials(instance, creds, username);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
259
|
/**
|
|
258
260
|
* Delete credentials for an instance keyed by <username>@<instance>.
|
|
259
261
|
*/
|
|
260
|
-
function deleteCredentials(instance, username) {
|
|
262
|
+
function deleteCredentials(instance, username, injectedStore) {
|
|
263
|
+
if (injectedStore) return injectedStore.delete(instance, username);
|
|
261
264
|
const key = username ? credKey(instance, username) : credKey(instance);
|
|
262
265
|
keyringDelete(key);
|
|
263
266
|
try {
|
|
@@ -267,6 +270,12 @@ function deleteCredentials(instance, username) {
|
|
|
267
270
|
}
|
|
268
271
|
}
|
|
269
272
|
|
|
273
|
+
const defaultCredentialStore = {
|
|
274
|
+
load: loadCredentials,
|
|
275
|
+
save: saveCredentials,
|
|
276
|
+
delete: deleteCredentials,
|
|
277
|
+
};
|
|
278
|
+
|
|
270
279
|
function askHidden(promptText) {
|
|
271
280
|
return passwordPrompt({ message: promptText });
|
|
272
281
|
}
|
|
@@ -290,6 +299,130 @@ export function parseBrowserSessionInput(input) {
|
|
|
290
299
|
return { auth_method: 'gck', access_token: token, cookies, auth_source: 'gck' };
|
|
291
300
|
}
|
|
292
301
|
|
|
302
|
+
const AUTH_METHODS = new Set(['oauth', 'basic', 'gck']);
|
|
303
|
+
const AUTH_SOURCES = new Set(['env_token', 'env_basic', 'gck', 'keyring', 'file', 'unavailable']);
|
|
304
|
+
|
|
305
|
+
function normalizeAuthMethod(value, fallback = 'unconfigured') {
|
|
306
|
+
return AUTH_METHODS.has(value) ? value : fallback;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizeAuthSource(value, fallback = 'unavailable') {
|
|
310
|
+
return AUTH_SOURCES.has(value) ? value : fallback;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function sourceMethod(source) {
|
|
314
|
+
return { env_token: 'oauth', env_basic: 'basic', gck: 'gck' }[source] || null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const PROBE_FAILURES = {
|
|
318
|
+
missing_credentials: {
|
|
319
|
+
message: 'Credentials are not available for this profile.',
|
|
320
|
+
hint: 'Run: jsn auth login',
|
|
321
|
+
},
|
|
322
|
+
credentials_expired: {
|
|
323
|
+
message: 'Credentials have expired and cannot be refreshed.',
|
|
324
|
+
hint: 'Run: jsn auth login',
|
|
325
|
+
},
|
|
326
|
+
refresh_required: {
|
|
327
|
+
message: 'Credentials require refresh before probing.',
|
|
328
|
+
hint: 'Run: jsn auth refresh',
|
|
329
|
+
},
|
|
330
|
+
refresh_failed: {
|
|
331
|
+
message: 'Credential refresh failed before the authenticated probe.',
|
|
332
|
+
hint: 'Run: jsn auth login',
|
|
333
|
+
},
|
|
334
|
+
invalid_browser_session: {
|
|
335
|
+
message: 'Browser session credentials are incomplete or invalid.',
|
|
336
|
+
hint: 'Capture a fresh ServiceNow browser request and run: jsn auth login --gck',
|
|
337
|
+
},
|
|
338
|
+
malformed_credentials: {
|
|
339
|
+
message: 'Credentials are malformed for the selected authentication method.',
|
|
340
|
+
hint: 'Run: jsn auth login',
|
|
341
|
+
},
|
|
342
|
+
permission_denied: {
|
|
343
|
+
message: 'The authenticated user lacks permission for the current-user probe.',
|
|
344
|
+
hint: 'Ask an administrator to grant access to the sys_user table/API.',
|
|
345
|
+
},
|
|
346
|
+
unauthorized: {
|
|
347
|
+
message: 'The instance rejected the selected credentials.',
|
|
348
|
+
hint: 'Run: jsn auth login',
|
|
349
|
+
},
|
|
350
|
+
network: {
|
|
351
|
+
message: 'The authenticated probe could not reach the instance.',
|
|
352
|
+
hint: 'Check your internet connection and instance URL.',
|
|
353
|
+
},
|
|
354
|
+
unknown: {
|
|
355
|
+
message: 'The authenticated probe failed for an unknown reason.',
|
|
356
|
+
hint: 'Retry the probe; if it persists, check the instance logs.',
|
|
357
|
+
},
|
|
358
|
+
missing_instance: {
|
|
359
|
+
message: 'No instance is configured for this profile.',
|
|
360
|
+
hint: 'Set an instance URL and run: jsn auth login',
|
|
361
|
+
},
|
|
362
|
+
sdk_construction_failed: {
|
|
363
|
+
message: 'The authenticated probe could not be initialized for this profile.',
|
|
364
|
+
hint: 'Check the profile configuration and run: jsn auth status again',
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
const PROBE_CLASSIFICATIONS = {
|
|
369
|
+
missing_credentials: 'unavailable', credentials_expired: 'expired', refresh_required: 'refresh_required',
|
|
370
|
+
refresh_failed: 'refresh_failed', invalid_browser_session: 'browser_session_invalid',
|
|
371
|
+
malformed_credentials: 'malformed', permission_denied: 'permission_denied', unauthorized: 'unauthorized',
|
|
372
|
+
network: 'network_error', unknown: 'unknown',
|
|
373
|
+
missing_instance: 'unavailable', sdk_construction_failed: 'configuration_error',
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
function probeFailure(code, status = 'failed') {
|
|
377
|
+
return { status, code, classification: PROBE_CLASSIFICATIONS[code] || 'unknown', ...PROBE_FAILURES[code] };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function classifyProbeError(error, method) {
|
|
381
|
+
if (error?.status === 401 || error?.status === 403) {
|
|
382
|
+
return error.status === 403 ? 'permission_denied' : 'unauthorized';
|
|
383
|
+
}
|
|
384
|
+
if (error?.code === 'network_error' || ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'].includes(error?.code)) {
|
|
385
|
+
return 'network';
|
|
386
|
+
}
|
|
387
|
+
const text = String(error?.message || '').toLowerCase();
|
|
388
|
+
if (error?.code === 'auth_error' && text.includes('refresh')) return 'refresh_failed';
|
|
389
|
+
if (method === 'gck' && error?.code === 'auth_error') return 'invalid_browser_session';
|
|
390
|
+
return 'unknown';
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Classify credentials without returning or logging any credential material.
|
|
395
|
+
* This is deliberately pure so diagnostics cannot refresh, persist, or probe.
|
|
396
|
+
*/
|
|
397
|
+
export function classifyCredentialState(credentials, method, now = Date.now()) {
|
|
398
|
+
if (!credentials) return 'missing';
|
|
399
|
+
if (!AUTH_METHODS.has(method)) return 'malformed';
|
|
400
|
+
|
|
401
|
+
if (method === 'basic') {
|
|
402
|
+
const hasUser = typeof credentials.username === 'string' && credentials.username.length > 0;
|
|
403
|
+
const hasPassword = typeof credentials.password === 'string' && credentials.password.length > 0;
|
|
404
|
+
return hasUser && hasPassword ? 'available' : 'malformed';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (method === 'gck') {
|
|
408
|
+
const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
|
|
409
|
+
const hasCookies = typeof credentials.cookies === 'string' && credentials.cookies.length > 0;
|
|
410
|
+
return hasToken && hasCookies ? 'available' : 'malformed';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (credentials.access_token !== undefined && typeof credentials.access_token !== 'string') return 'malformed';
|
|
414
|
+
if (credentials.refresh_token !== undefined && typeof credentials.refresh_token !== 'string') return 'malformed';
|
|
415
|
+
|
|
416
|
+
const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
|
|
417
|
+
if (!hasToken) return credentials.refresh_token ? 'refreshable' : 'missing';
|
|
418
|
+
if (credentials.expires_at !== undefined && credentials.expires_at !== null && credentials.expires_at !== 0) {
|
|
419
|
+
const expiresAt = Number(credentials.expires_at);
|
|
420
|
+
if (!Number.isFinite(expiresAt)) return 'malformed';
|
|
421
|
+
if (now >= (expiresAt - 300) * 1000) return credentials.refresh_token ? 'refreshable' : 'expired';
|
|
422
|
+
}
|
|
423
|
+
return 'available';
|
|
424
|
+
}
|
|
425
|
+
|
|
293
426
|
export class AuthManager {
|
|
294
427
|
/**
|
|
295
428
|
* @param {object} identity — the resolved-identity provider. Production
|
|
@@ -300,8 +433,11 @@ export class AuthManager {
|
|
|
300
433
|
* Transitional back-compat: a legacy configProvider ({ config, ...
|
|
301
434
|
* getEffectiveInstance() }) is adapted to the identity surface, so the
|
|
302
435
|
* existing test fixtures (which predate the session) keep working.
|
|
436
|
+
* @param {object} options
|
|
437
|
+
* @param {object} options.credentialStore — optional load/save/delete adapter;
|
|
438
|
+
* defaults to the OS keyring with file fallback.
|
|
303
439
|
*/
|
|
304
|
-
constructor(identity) {
|
|
440
|
+
constructor(identity, { credentialStore = defaultCredentialStore } = {}) {
|
|
305
441
|
if (identity && identity.config && typeof identity.getUsername !== 'function') {
|
|
306
442
|
const provider = identity;
|
|
307
443
|
this.identity = {
|
|
@@ -311,10 +447,16 @@ export class AuthManager {
|
|
|
311
447
|
return (name && cfg.profiles?.[name]?.username) || null;
|
|
312
448
|
},
|
|
313
449
|
getEffectiveInstance: () => provider.getEffectiveInstance(),
|
|
450
|
+
getAuthMethod: () => {
|
|
451
|
+
const cfg = provider.config;
|
|
452
|
+
const name = cfg.activeProfile || cfg.defaultProfile;
|
|
453
|
+
return cfg.profiles?.[name]?.auth_method || null;
|
|
454
|
+
},
|
|
314
455
|
};
|
|
315
456
|
} else {
|
|
316
457
|
this.identity = identity;
|
|
317
458
|
}
|
|
459
|
+
this.credentialStore = credentialStore;
|
|
318
460
|
this.httpClient = { timeout: 30000 };
|
|
319
461
|
}
|
|
320
462
|
|
|
@@ -324,15 +466,39 @@ export class AuthManager {
|
|
|
324
466
|
* instance key for backward compatibility with legacy / no-profile usage.
|
|
325
467
|
*/
|
|
326
468
|
_activeUsername() {
|
|
327
|
-
return this.identity.getUsername() || null;
|
|
469
|
+
return typeof this.identity.getUsername === 'function' ? this.identity.getUsername() || null : null;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// An options object with a username property is an explicit identity,
|
|
473
|
+
// including `username: undefined` which intentionally selects bare creds.
|
|
474
|
+
// Omitted options retain the legacy active-user fallback.
|
|
475
|
+
_usernameForOptions(options = {}) {
|
|
476
|
+
return Object.hasOwn(options, 'username') ? options.username : this._activeUsername();
|
|
328
477
|
}
|
|
329
478
|
|
|
330
|
-
|
|
331
|
-
return
|
|
479
|
+
loadCredentials(instance, username) {
|
|
480
|
+
return this.credentialStore.load(instance, username);
|
|
332
481
|
}
|
|
333
482
|
|
|
334
|
-
|
|
335
|
-
return
|
|
483
|
+
saveCredentials(instance, credentials, username) {
|
|
484
|
+
return this.credentialStore.save(instance, credentials, username);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
deleteCredentials(instance, username) {
|
|
488
|
+
return this.credentialStore.delete(instance, username);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
getLastSeen(instance, options = {}) {
|
|
492
|
+
const creds = this.loadCredentials(instance, this._usernameForOptions(options));
|
|
493
|
+
return creds?.last_seen || null;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
touchLastSeen(instance, options = {}) {
|
|
497
|
+
const username = this._usernameForOptions(options);
|
|
498
|
+
const creds = this.credentialStore.load(instance, username);
|
|
499
|
+
if (!creds) return;
|
|
500
|
+
creds.last_seen = Math.floor(Date.now() / 1000);
|
|
501
|
+
this.credentialStore.save(instance, creds, username);
|
|
336
502
|
}
|
|
337
503
|
|
|
338
504
|
/**
|
|
@@ -340,15 +506,15 @@ export class AuthManager {
|
|
|
340
506
|
* user@instance keying). Returns true when old-style creds are found
|
|
341
507
|
* but wouldn't be picked up by the current username-scoped lookup.
|
|
342
508
|
*/
|
|
343
|
-
hasLegacyCredentials(instance) {
|
|
509
|
+
hasLegacyCredentials(instance, options = {}) {
|
|
344
510
|
if (!instance) return false;
|
|
345
|
-
const bareCreds =
|
|
511
|
+
const bareCreds = this.credentialStore.load(instance);
|
|
346
512
|
if (!bareCreds) return false;
|
|
347
513
|
// If active profile has a username, the bare key won't be found
|
|
348
514
|
// by loadCredentials(instance, username). That's the legacy case.
|
|
349
|
-
const username = this.
|
|
515
|
+
const username = this._usernameForOptions(options);
|
|
350
516
|
if (!username) return false; // No username set — bare key IS the active path
|
|
351
|
-
const userCreds =
|
|
517
|
+
const userCreds = this.credentialStore.load(instance, username);
|
|
352
518
|
return !!bareCreds && !userCreds;
|
|
353
519
|
}
|
|
354
520
|
|
|
@@ -358,76 +524,187 @@ export class AuthManager {
|
|
|
358
524
|
* For older stored credentials without auth_source, falls back
|
|
359
525
|
* to the saved auth_method field.
|
|
360
526
|
*/
|
|
361
|
-
getAuthSource(instance) {
|
|
362
|
-
|
|
363
|
-
if (
|
|
364
|
-
|
|
527
|
+
getAuthSource(instance, options = {}) {
|
|
528
|
+
const method = this._selectedMethod(options);
|
|
529
|
+
if (method !== 'gck' && method !== 'basic' && process.env.SERVICENOW_OAUTH_TOKEN) return 'env_token';
|
|
530
|
+
if (method !== 'gck' && method !== 'oauth' && getBasicAuthFromEnv(instance)) return 'env_basic';
|
|
531
|
+
const creds = this.credentialStore.load(instance, this._usernameForOptions(options));
|
|
365
532
|
if (!creds) return null;
|
|
533
|
+
// A configured method is authoritative at this public boundary. Stored
|
|
534
|
+
// metadata from another method must not be reported as usable source.
|
|
535
|
+
if (method !== 'unconfigured') {
|
|
536
|
+
const storedMethod = normalizeAuthMethod(creds.auth_method, '');
|
|
537
|
+
if (creds.auth_method != null && (!storedMethod || storedMethod !== method)) return 'unavailable';
|
|
538
|
+
const source = normalizeAuthSource(creds.auth_source, '');
|
|
539
|
+
if (!source) return 'unavailable';
|
|
540
|
+
const sourceMethod = { env_token: 'oauth', env_basic: 'basic', gck: 'gck' }[source];
|
|
541
|
+
if (sourceMethod && sourceMethod !== method) return 'unavailable';
|
|
542
|
+
return source;
|
|
543
|
+
}
|
|
366
544
|
// New creds have auth_source; older ones only have auth_method
|
|
367
|
-
|
|
545
|
+
const source = normalizeAuthSource(creds.auth_source, '');
|
|
546
|
+
if (source) return source;
|
|
547
|
+
return normalizeAuthMethod(creds.auth_method, '') || 'unavailable';
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
probeUnavailable(code) {
|
|
551
|
+
return probeFailure(code, 'not_attempted');
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
_selectedMethod(options = {}) {
|
|
555
|
+
const value = Object.hasOwn(options, 'authMethod') ? options.authMethod
|
|
556
|
+
: typeof this.identity.getAuthMethod === 'function' ? this.identity.getAuthMethod() : null;
|
|
557
|
+
return normalizeAuthMethod(value, 'unconfigured');
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
createProfileProvider(instance, options = {}) {
|
|
561
|
+
return {
|
|
562
|
+
getCredentials: () => this.getCredentialsFor(instance, options.username, options),
|
|
563
|
+
touchLastSeen: () => this.touchLastSeen(instance, options),
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Return only safe metadata about the selected auth method and credentials.
|
|
569
|
+
* Unlike getCredentialsFor(), this never refreshes or mutates credentials.
|
|
570
|
+
*/
|
|
571
|
+
getAuthState(instance = this.identity.getEffectiveInstance(), options = {}) {
|
|
572
|
+
const configuredValue = Object.hasOwn(options, 'authMethod')
|
|
573
|
+
? options.authMethod
|
|
574
|
+
: typeof this.identity.getAuthMethod === 'function'
|
|
575
|
+
? this.identity.getAuthMethod() : null;
|
|
576
|
+
const configuredMethod = normalizeAuthMethod(configuredValue);
|
|
577
|
+
const isConfigured = configuredMethod !== 'unconfigured';
|
|
578
|
+
let method = configuredMethod;
|
|
579
|
+
let classificationMethod = isConfigured ? configuredMethod : null;
|
|
580
|
+
let source = 'unavailable';
|
|
581
|
+
let credentials = null;
|
|
582
|
+
let malformedMetadata = configuredValue != null && !isConfigured;
|
|
583
|
+
|
|
584
|
+
if (process.env.SERVICENOW_OAUTH_TOKEN && (!isConfigured || configuredMethod === 'oauth')) {
|
|
585
|
+
source = 'env_token';
|
|
586
|
+
classificationMethod = 'oauth';
|
|
587
|
+
credentials = { access_token: process.env.SERVICENOW_OAUTH_TOKEN };
|
|
588
|
+
} else {
|
|
589
|
+
const basicCredentials = getBasicAuthFromEnv(instance);
|
|
590
|
+
if (basicCredentials && (!isConfigured || configuredMethod === 'basic')) {
|
|
591
|
+
source = 'env_basic';
|
|
592
|
+
classificationMethod = 'basic';
|
|
593
|
+
credentials = basicCredentials;
|
|
594
|
+
} else if (instance) {
|
|
595
|
+
credentials = this.credentialStore.load(instance, this._usernameForOptions(options));
|
|
596
|
+
if (credentials) {
|
|
597
|
+
if (credentials.auth_method != null) {
|
|
598
|
+
const storedMethod = normalizeAuthMethod(credentials.auth_method, '');
|
|
599
|
+
if (!storedMethod) malformedMetadata = true;
|
|
600
|
+
else if (isConfigured && storedMethod !== configuredMethod) malformedMetadata = true;
|
|
601
|
+
else if (!isConfigured) classificationMethod = storedMethod;
|
|
602
|
+
}
|
|
603
|
+
if (credentials.auth_source != null) {
|
|
604
|
+
const storedSource = normalizeAuthSource(credentials.auth_source, '');
|
|
605
|
+
if (storedSource) source = storedSource;
|
|
606
|
+
else {
|
|
607
|
+
source = 'unavailable';
|
|
608
|
+
malformedMetadata = true;
|
|
609
|
+
}
|
|
610
|
+
if (storedSource && isConfigured && sourceMethod(storedSource)
|
|
611
|
+
&& sourceMethod(storedSource) !== configuredMethod) {
|
|
612
|
+
source = 'unavailable';
|
|
613
|
+
malformedMetadata = true;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const state = malformedMetadata
|
|
621
|
+
? 'malformed'
|
|
622
|
+
: classificationMethod
|
|
623
|
+
? classifyCredentialState(credentials, classificationMethod)
|
|
624
|
+
: credentials ? 'malformed' : 'missing';
|
|
625
|
+
return {
|
|
626
|
+
auth_method: method,
|
|
627
|
+
auth_source: normalizeAuthSource(source, 'unavailable'),
|
|
628
|
+
state,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Perform one read-only authenticated current-user request and classify only
|
|
634
|
+
* safe lifecycle outcomes. The SDK is injected to keep this seam independent
|
|
635
|
+
* of command wiring and to make the selected auth path explicit.
|
|
636
|
+
*/
|
|
637
|
+
async probeCurrentUser(instance = this.identity.getEffectiveInstance(), sdk, options = {}) {
|
|
638
|
+
const authState = this.getAuthState(instance, options);
|
|
639
|
+
const method = authState.auth_method === 'unconfigured' ? null : authState.auth_method;
|
|
640
|
+
const stateFailures = {
|
|
641
|
+
missing: 'missing_credentials',
|
|
642
|
+
expired: 'credentials_expired',
|
|
643
|
+
refreshable: 'refresh_required',
|
|
644
|
+
malformed: method === 'gck' ? 'invalid_browser_session' : 'malformed_credentials',
|
|
645
|
+
};
|
|
646
|
+
if (stateFailures[authState.state]) return probeFailure(stateFailures[authState.state], 'not_attempted');
|
|
647
|
+
if (!sdk || typeof sdk.getCurrentUser !== 'function') return probeFailure('unknown', 'not_attempted');
|
|
648
|
+
|
|
649
|
+
try {
|
|
650
|
+
await sdk.getCurrentUser({ touchLastSeen: false });
|
|
651
|
+
return { status: 'succeeded', classification: 'authenticated' };
|
|
652
|
+
} catch (error) {
|
|
653
|
+
return probeFailure(classifyProbeError(error, method));
|
|
654
|
+
}
|
|
368
655
|
}
|
|
369
656
|
|
|
370
657
|
isAuthenticated() {
|
|
371
|
-
if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
|
|
372
|
-
if (getBasicAuthFromEnv()) return true;
|
|
373
658
|
const instance = this.identity.getEffectiveInstance();
|
|
374
659
|
if (!instance) return false;
|
|
375
|
-
|
|
376
|
-
this.getCredentialsFor(instance);
|
|
377
|
-
return true;
|
|
378
|
-
} catch {
|
|
379
|
-
return false;
|
|
380
|
-
}
|
|
660
|
+
return this.getAuthState(instance).state === 'available';
|
|
381
661
|
}
|
|
382
662
|
|
|
383
|
-
isAuthenticatedFor(instance) {
|
|
663
|
+
isAuthenticatedFor(instance, options = {}) {
|
|
384
664
|
if (!instance) return false;
|
|
385
|
-
|
|
386
|
-
const creds = loadCredentials(instance, this._activeUsername());
|
|
387
|
-
if (!creds) return false;
|
|
388
|
-
if (creds.auth_method === 'basic') {
|
|
389
|
-
return Boolean(creds.username && creds.password);
|
|
390
|
-
}
|
|
391
|
-
if (creds.auth_method === 'gck') {
|
|
392
|
-
return Boolean(creds.access_token && creds.cookies);
|
|
393
|
-
}
|
|
394
|
-
if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
|
|
395
|
-
return !!creds.access_token;
|
|
665
|
+
return this.getAuthState(instance, options).state === 'available';
|
|
396
666
|
}
|
|
397
667
|
|
|
398
668
|
async getCredentials() {
|
|
399
|
-
if (process.env.SERVICENOW_OAUTH_TOKEN) {
|
|
400
|
-
return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN, auth_source: 'env_token' };
|
|
401
|
-
}
|
|
402
669
|
const instance = this.identity.getEffectiveInstance();
|
|
403
670
|
if (!instance) {
|
|
404
671
|
throw errAuth('No instance configured');
|
|
405
672
|
}
|
|
406
|
-
// Check basic auth from env vars first
|
|
407
|
-
const basicCreds = getBasicAuthFromEnv(instance);
|
|
408
|
-
if (basicCreds) return basicCreds;
|
|
409
673
|
return this.getCredentialsFor(instance);
|
|
410
674
|
}
|
|
411
675
|
|
|
412
|
-
getCredentialsFor(instance, username =
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
676
|
+
getCredentialsFor(instance, username, options = {}) {
|
|
677
|
+
const method = this._selectedMethod(options);
|
|
678
|
+
// With no configured method, preserve the legacy environment precedence:
|
|
679
|
+
// OAuth token first, then Basic credentials, then stored credentials.
|
|
680
|
+
if ((method === 'unconfigured' || method === 'oauth') && process.env.SERVICENOW_OAUTH_TOKEN) {
|
|
681
|
+
return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN, auth_source: 'env_token' };
|
|
682
|
+
}
|
|
683
|
+
if (method === 'unconfigured' || method === 'basic') {
|
|
684
|
+
const basicCreds = getBasicAuthFromEnv(instance);
|
|
685
|
+
if (basicCreds) return basicCreds;
|
|
686
|
+
}
|
|
687
|
+
const credentialUsername = Object.hasOwn(options, 'username') ? options.username : (username ?? this._activeUsername());
|
|
688
|
+
const creds = this.credentialStore.load(instance, credentialUsername);
|
|
417
689
|
if (!creds) {
|
|
418
690
|
throw errAuth(`Not authenticated for ${instance}`);
|
|
419
691
|
}
|
|
692
|
+
if (method !== 'unconfigured' && creds.auth_method != null && creds.auth_method !== method) {
|
|
693
|
+
throw errAuth(`Stored credentials do not match configured ${method} authentication`);
|
|
694
|
+
}
|
|
695
|
+
const resolved = method !== 'unconfigured' && !creds.auth_method ? { ...creds, auth_method: method } : creds;
|
|
696
|
+
if (method === 'gck' && (!resolved.access_token || !resolved.cookies)) throw errAuth('Invalid browser session credentials');
|
|
697
|
+
if (method === 'basic' && (!resolved.username || !resolved.password)) throw errAuth('Invalid basic auth credentials');
|
|
698
|
+
if (method === 'oauth' && !resolved.access_token) throw errAuth('Invalid OAuth credentials');
|
|
420
699
|
// Check expiry — refresh if less than 5 minutes remaining
|
|
421
|
-
if (
|
|
422
|
-
if (
|
|
423
|
-
return this.refreshToken(instance, creds);
|
|
424
|
-
}
|
|
700
|
+
if (resolved.expires_at && Date.now() >= (resolved.expires_at - 300) * 1000) {
|
|
701
|
+
if (resolved.refresh_token) return this.refreshToken(instance, resolved, credentialUsername);
|
|
425
702
|
throw errAuth('Token expired, please login again');
|
|
426
703
|
}
|
|
427
|
-
return
|
|
704
|
+
return resolved;
|
|
428
705
|
}
|
|
429
706
|
|
|
430
|
-
async login(instanceURL) {
|
|
707
|
+
async login(instanceURL, username = this._activeUsername()) {
|
|
431
708
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
432
709
|
const clientID = getOAuthClientID();
|
|
433
710
|
const pkce = generatePKCE();
|
|
@@ -453,8 +730,8 @@ export class AuthManager {
|
|
|
453
730
|
|
|
454
731
|
console.log('\nExchanging authorization code for tokens...');
|
|
455
732
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
456
|
-
// Save with username
|
|
457
|
-
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
733
|
+
// Save with the selected profile username when one is already known.
|
|
734
|
+
this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
|
|
458
735
|
return newCreds;
|
|
459
736
|
}
|
|
460
737
|
|
|
@@ -465,7 +742,7 @@ export class AuthManager {
|
|
|
465
742
|
* If waitFile is provided, the method will read the code from that file
|
|
466
743
|
* (waits for file to appear, polling every 2 seconds, up to 5 minutes).
|
|
467
744
|
*/
|
|
468
|
-
buildAuthURL(instanceURL, waitFile) {
|
|
745
|
+
buildAuthURL(instanceURL, waitFile, username = this._activeUsername()) {
|
|
469
746
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
470
747
|
const clientID = getOAuthClientID();
|
|
471
748
|
const pkce = generatePKCE();
|
|
@@ -476,12 +753,12 @@ export class AuthManager {
|
|
|
476
753
|
console.log();
|
|
477
754
|
console.log(`Waiting for authorization code in file: ${waitFile}`);
|
|
478
755
|
console.log('(polling every 2 seconds — write the code to the file to complete login)');
|
|
479
|
-
return this._waitForCodeFile(instanceURL, clientID, pkce, waitFile);
|
|
756
|
+
return this._waitForCodeFile(instanceURL, clientID, pkce, waitFile, 300000, username);
|
|
480
757
|
}
|
|
481
758
|
return url;
|
|
482
759
|
}
|
|
483
760
|
|
|
484
|
-
async _waitForCodeFile(instanceURL, clientID, pkce, filePath, timeout = 300000) {
|
|
761
|
+
async _waitForCodeFile(instanceURL, clientID, pkce, filePath, timeout = 300000, username) {
|
|
485
762
|
const start = Date.now();
|
|
486
763
|
const pollInterval = 2000;
|
|
487
764
|
while (Date.now() - start < timeout) {
|
|
@@ -491,7 +768,7 @@ export class AuthManager {
|
|
|
491
768
|
console.log(`\nAuthorization code found in ${filePath}`);
|
|
492
769
|
removePKCEState(instanceURL);
|
|
493
770
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
494
|
-
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
771
|
+
this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
|
|
495
772
|
console.log('Token exchange successful!\n');
|
|
496
773
|
return newCreds;
|
|
497
774
|
}
|
|
@@ -511,7 +788,7 @@ export class AuthManager {
|
|
|
511
788
|
async loginWithPassword(instanceURL, username = this._activeUsername()) {
|
|
512
789
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
513
790
|
const envCreds = getBasicAuthFromEnv(instanceURL);
|
|
514
|
-
const storedCreds = loadCredentials(instanceURL, username);
|
|
791
|
+
const storedCreds = this.loadCredentials(instanceURL, username);
|
|
515
792
|
const creds = envCreds || storedCreds;
|
|
516
793
|
if (!creds || creds.auth_method !== 'basic' || !creds.username || !creds.password) {
|
|
517
794
|
throw errAuth(
|
|
@@ -523,7 +800,7 @@ export class AuthManager {
|
|
|
523
800
|
` jsn auth login --basic ${instanceURL}`
|
|
524
801
|
);
|
|
525
802
|
}
|
|
526
|
-
saveCredentials(instanceURL, creds, creds.username);
|
|
803
|
+
this.saveCredentials(instanceURL, creds, creds.username);
|
|
527
804
|
console.log(`✓ Basic auth credentials saved for ${instanceURL}`);
|
|
528
805
|
return creds;
|
|
529
806
|
}
|
|
@@ -535,11 +812,11 @@ export class AuthManager {
|
|
|
535
812
|
async loginWithGck(instanceURL, input, username = this._activeUsername()) {
|
|
536
813
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
537
814
|
const creds = parseBrowserSessionInput(input);
|
|
538
|
-
saveCredentials(instanceURL, { ...creds, username: username || undefined }, username || undefined);
|
|
815
|
+
this.saveCredentials(instanceURL, { ...creds, username: username || undefined }, username || undefined);
|
|
539
816
|
return { ...creds, username: username || undefined };
|
|
540
817
|
}
|
|
541
818
|
|
|
542
|
-
async loginWithCode(instanceURL, code) {
|
|
819
|
+
async loginWithCode(instanceURL, code, username = this._activeUsername()) {
|
|
543
820
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
544
821
|
const clientID = getOAuthClientID();
|
|
545
822
|
const pkce = loadPKCEState(instanceURL);
|
|
@@ -555,7 +832,7 @@ export class AuthManager {
|
|
|
555
832
|
removePKCEState(instanceURL);
|
|
556
833
|
|
|
557
834
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
558
|
-
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
835
|
+
this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
|
|
559
836
|
return newCreds;
|
|
560
837
|
}
|
|
561
838
|
|
|
@@ -576,7 +853,7 @@ export class AuthManager {
|
|
|
576
853
|
|
|
577
854
|
const text = await resp.text();
|
|
578
855
|
if (!resp.ok) {
|
|
579
|
-
throw errAuth(`Token exchange failed (status ${resp.status})
|
|
856
|
+
throw errAuth(`Token exchange failed (status ${resp.status})`);
|
|
580
857
|
}
|
|
581
858
|
|
|
582
859
|
const tokenResp = JSON.parse(text);
|
|
@@ -592,7 +869,10 @@ export class AuthManager {
|
|
|
592
869
|
};
|
|
593
870
|
}
|
|
594
871
|
|
|
595
|
-
async refreshToken(instance, creds) {
|
|
872
|
+
async refreshToken(instance, creds, targetUsername = undefined) {
|
|
873
|
+
if (creds.auth_method !== 'oauth') {
|
|
874
|
+
throw errAuth(`Cannot refresh ${creds.auth_method || 'unconfigured'} credentials; only OAuth credentials support refresh`);
|
|
875
|
+
}
|
|
596
876
|
const tokenURL = `${instance.replace(/\/$/, '')}/oauth_token.do`;
|
|
597
877
|
const clientID = getOAuthClientID();
|
|
598
878
|
const body = new URLSearchParams();
|
|
@@ -607,11 +887,17 @@ export class AuthManager {
|
|
|
607
887
|
});
|
|
608
888
|
|
|
609
889
|
if (!resp.ok) {
|
|
610
|
-
|
|
611
|
-
throw errAuth(`Token refresh failed
|
|
890
|
+
await resp.text();
|
|
891
|
+
throw errAuth(`Token refresh failed (status ${resp.status})`);
|
|
612
892
|
}
|
|
613
893
|
|
|
614
894
|
const tokenResp = await resp.json();
|
|
895
|
+
// An explicitly supplied target (including null for a username-less
|
|
896
|
+
// non-active target) must not fall back to the active identity. Preserve
|
|
897
|
+
// the legacy fallback only for callers that omit the third argument.
|
|
898
|
+
const username = targetUsername !== undefined
|
|
899
|
+
? (targetUsername || creds.username || null)
|
|
900
|
+
: (creds.username || this._activeUsername());
|
|
615
901
|
const newCreds = {
|
|
616
902
|
auth_method: 'oauth',
|
|
617
903
|
access_token: tokenResp.access_token,
|
|
@@ -622,15 +908,15 @@ export class AuthManager {
|
|
|
622
908
|
if (tokenResp.expires_in) {
|
|
623
909
|
newCreds.expires_at = Math.floor(Date.now() / 1000) + tokenResp.expires_in;
|
|
624
910
|
}
|
|
625
|
-
|
|
911
|
+
this.credentialStore.save(instance, newCreds, username);
|
|
626
912
|
return newCreds;
|
|
627
913
|
}
|
|
628
914
|
|
|
629
|
-
logout(instance) {
|
|
915
|
+
logout(instance, username = this._activeUsername()) {
|
|
630
916
|
if (!instance) {
|
|
631
917
|
throw errAuth('No instance specified');
|
|
632
918
|
}
|
|
633
|
-
|
|
919
|
+
this.credentialStore.delete(instance, username);
|
|
634
920
|
}
|
|
635
921
|
|
|
636
922
|
/**
|
|
@@ -643,11 +929,11 @@ export class AuthManager {
|
|
|
643
929
|
*/
|
|
644
930
|
migrateLegacyCredential(instance, username) {
|
|
645
931
|
if (!instance || !username) return false;
|
|
646
|
-
const stored =
|
|
932
|
+
const stored = this.credentialStore.load(instance);
|
|
647
933
|
if (!stored || !stored.access_token) return false;
|
|
648
934
|
stored.username = username;
|
|
649
|
-
|
|
650
|
-
|
|
935
|
+
this.credentialStore.save(instance, stored, username);
|
|
936
|
+
this.credentialStore.delete(instance); // remove bare-instance legacy key
|
|
651
937
|
return true;
|
|
652
938
|
}
|
|
653
939
|
}
|