@jacebenson/jsn 4.2.3 → 4.3.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/README.md +12 -0
- package/package.json +2 -2
- package/src/app.js +9 -0
- package/src/auth.js +411 -96
- package/src/cli.js +18 -11
- package/src/commands/auth.js +273 -73
- 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 +69 -18
- 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,10 +270,159 @@ 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
|
}
|
|
273
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Extract the browser session token and cookies from pasted DevTools headers
|
|
285
|
+
* or a "Copy as cURL" command. Only these two values are retained.
|
|
286
|
+
*/
|
|
287
|
+
export function parseBrowserSessionInput(input) {
|
|
288
|
+
const text = String(input || '');
|
|
289
|
+
const tokenMatch = text.match(/(?:x-usertoken|x-user-token)\s*:\s*([^\s'";,]+)/i);
|
|
290
|
+
const cookieArgMatch = text.match(/(?:^|\s)(?:-b|--cookie)\s+(['"])(.*?)\1/s);
|
|
291
|
+
const quotedCookieMatch = text.match(/cookie\s*:\s*(['"])(.*?)\1/i);
|
|
292
|
+
const plainCookieMatch = text.match(/cookie\s*:\s*([^\r\n]+)/i);
|
|
293
|
+
const token = tokenMatch?.[1]?.trim() || '';
|
|
294
|
+
const cookies = (cookieArgMatch?.[2] || quotedCookieMatch?.[2] || plainCookieMatch?.[1] || '')
|
|
295
|
+
.trim()
|
|
296
|
+
.replace(/['"`]\s*(?:\\\s*)?$/, '');
|
|
297
|
+
if (!token) throw errAuth('No X-UserToken header found');
|
|
298
|
+
if (!cookies) throw errAuth('No Cookie header found. Paste the complete request headers or cURL command.');
|
|
299
|
+
return { auth_method: 'gck', access_token: token, cookies, auth_source: 'gck' };
|
|
300
|
+
}
|
|
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
|
+
|
|
274
426
|
export class AuthManager {
|
|
275
427
|
/**
|
|
276
428
|
* @param {object} identity — the resolved-identity provider. Production
|
|
@@ -281,8 +433,11 @@ export class AuthManager {
|
|
|
281
433
|
* Transitional back-compat: a legacy configProvider ({ config, ...
|
|
282
434
|
* getEffectiveInstance() }) is adapted to the identity surface, so the
|
|
283
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.
|
|
284
439
|
*/
|
|
285
|
-
constructor(identity) {
|
|
440
|
+
constructor(identity, { credentialStore = defaultCredentialStore } = {}) {
|
|
286
441
|
if (identity && identity.config && typeof identity.getUsername !== 'function') {
|
|
287
442
|
const provider = identity;
|
|
288
443
|
this.identity = {
|
|
@@ -292,10 +447,16 @@ export class AuthManager {
|
|
|
292
447
|
return (name && cfg.profiles?.[name]?.username) || null;
|
|
293
448
|
},
|
|
294
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
|
+
},
|
|
295
455
|
};
|
|
296
456
|
} else {
|
|
297
457
|
this.identity = identity;
|
|
298
458
|
}
|
|
459
|
+
this.credentialStore = credentialStore;
|
|
299
460
|
this.httpClient = { timeout: 30000 };
|
|
300
461
|
}
|
|
301
462
|
|
|
@@ -305,15 +466,39 @@ export class AuthManager {
|
|
|
305
466
|
* instance key for backward compatibility with legacy / no-profile usage.
|
|
306
467
|
*/
|
|
307
468
|
_activeUsername() {
|
|
308
|
-
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();
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
loadCredentials(instance, username) {
|
|
480
|
+
return this.credentialStore.load(instance, username);
|
|
481
|
+
}
|
|
482
|
+
|
|
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);
|
|
309
489
|
}
|
|
310
490
|
|
|
311
|
-
getLastSeen(instance) {
|
|
312
|
-
|
|
491
|
+
getLastSeen(instance, options = {}) {
|
|
492
|
+
const creds = this.loadCredentials(instance, this._usernameForOptions(options));
|
|
493
|
+
return creds?.last_seen || null;
|
|
313
494
|
}
|
|
314
495
|
|
|
315
|
-
touchLastSeen(instance) {
|
|
316
|
-
|
|
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);
|
|
317
502
|
}
|
|
318
503
|
|
|
319
504
|
/**
|
|
@@ -321,15 +506,15 @@ export class AuthManager {
|
|
|
321
506
|
* user@instance keying). Returns true when old-style creds are found
|
|
322
507
|
* but wouldn't be picked up by the current username-scoped lookup.
|
|
323
508
|
*/
|
|
324
|
-
hasLegacyCredentials(instance) {
|
|
509
|
+
hasLegacyCredentials(instance, options = {}) {
|
|
325
510
|
if (!instance) return false;
|
|
326
|
-
const bareCreds =
|
|
511
|
+
const bareCreds = this.credentialStore.load(instance);
|
|
327
512
|
if (!bareCreds) return false;
|
|
328
513
|
// If active profile has a username, the bare key won't be found
|
|
329
514
|
// by loadCredentials(instance, username). That's the legacy case.
|
|
330
|
-
const username = this.
|
|
515
|
+
const username = this._usernameForOptions(options);
|
|
331
516
|
if (!username) return false; // No username set — bare key IS the active path
|
|
332
|
-
const userCreds =
|
|
517
|
+
const userCreds = this.credentialStore.load(instance, username);
|
|
333
518
|
return !!bareCreds && !userCreds;
|
|
334
519
|
}
|
|
335
520
|
|
|
@@ -339,73 +524,187 @@ export class AuthManager {
|
|
|
339
524
|
* For older stored credentials without auth_source, falls back
|
|
340
525
|
* to the saved auth_method field.
|
|
341
526
|
*/
|
|
342
|
-
getAuthSource(instance) {
|
|
343
|
-
|
|
344
|
-
if (
|
|
345
|
-
|
|
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));
|
|
346
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
|
+
}
|
|
347
544
|
// New creds have auth_source; older ones only have auth_method
|
|
348
|
-
|
|
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
|
+
}
|
|
349
655
|
}
|
|
350
656
|
|
|
351
657
|
isAuthenticated() {
|
|
352
|
-
if (process.env.SERVICENOW_OAUTH_TOKEN) return true;
|
|
353
|
-
if (getBasicAuthFromEnv()) return true;
|
|
354
658
|
const instance = this.identity.getEffectiveInstance();
|
|
355
659
|
if (!instance) return false;
|
|
356
|
-
|
|
357
|
-
this.getCredentialsFor(instance);
|
|
358
|
-
return true;
|
|
359
|
-
} catch {
|
|
360
|
-
return false;
|
|
361
|
-
}
|
|
660
|
+
return this.getAuthState(instance).state === 'available';
|
|
362
661
|
}
|
|
363
662
|
|
|
364
|
-
isAuthenticatedFor(instance) {
|
|
663
|
+
isAuthenticatedFor(instance, options = {}) {
|
|
365
664
|
if (!instance) return false;
|
|
366
|
-
|
|
367
|
-
const creds = loadCredentials(instance, this._activeUsername());
|
|
368
|
-
if (!creds) return false;
|
|
369
|
-
if (creds.auth_method === 'basic') {
|
|
370
|
-
return Boolean(creds.username && creds.password);
|
|
371
|
-
}
|
|
372
|
-
if (creds.expires_at && Date.now() >= creds.expires_at * 1000) return false;
|
|
373
|
-
return !!creds.access_token;
|
|
665
|
+
return this.getAuthState(instance, options).state === 'available';
|
|
374
666
|
}
|
|
375
667
|
|
|
376
668
|
async getCredentials() {
|
|
377
|
-
if (process.env.SERVICENOW_OAUTH_TOKEN) {
|
|
378
|
-
return { auth_method: 'oauth', access_token: process.env.SERVICENOW_OAUTH_TOKEN, auth_source: 'env_token' };
|
|
379
|
-
}
|
|
380
669
|
const instance = this.identity.getEffectiveInstance();
|
|
381
670
|
if (!instance) {
|
|
382
671
|
throw errAuth('No instance configured');
|
|
383
672
|
}
|
|
384
|
-
// Check basic auth from env vars first
|
|
385
|
-
const basicCreds = getBasicAuthFromEnv(instance);
|
|
386
|
-
if (basicCreds) return basicCreds;
|
|
387
673
|
return this.getCredentialsFor(instance);
|
|
388
674
|
}
|
|
389
675
|
|
|
390
|
-
getCredentialsFor(instance, username =
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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);
|
|
395
689
|
if (!creds) {
|
|
396
690
|
throw errAuth(`Not authenticated for ${instance}`);
|
|
397
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');
|
|
398
699
|
// Check expiry — refresh if less than 5 minutes remaining
|
|
399
|
-
if (
|
|
400
|
-
if (
|
|
401
|
-
return this.refreshToken(instance, creds);
|
|
402
|
-
}
|
|
700
|
+
if (resolved.expires_at && Date.now() >= (resolved.expires_at - 300) * 1000) {
|
|
701
|
+
if (resolved.refresh_token) return this.refreshToken(instance, resolved, credentialUsername);
|
|
403
702
|
throw errAuth('Token expired, please login again');
|
|
404
703
|
}
|
|
405
|
-
return
|
|
704
|
+
return resolved;
|
|
406
705
|
}
|
|
407
706
|
|
|
408
|
-
async login(instanceURL) {
|
|
707
|
+
async login(instanceURL, username = this._activeUsername()) {
|
|
409
708
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
410
709
|
const clientID = getOAuthClientID();
|
|
411
710
|
const pkce = generatePKCE();
|
|
@@ -431,8 +730,8 @@ export class AuthManager {
|
|
|
431
730
|
|
|
432
731
|
console.log('\nExchanging authorization code for tokens...');
|
|
433
732
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
434
|
-
// Save with username
|
|
435
|
-
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);
|
|
436
735
|
return newCreds;
|
|
437
736
|
}
|
|
438
737
|
|
|
@@ -443,7 +742,7 @@ export class AuthManager {
|
|
|
443
742
|
* If waitFile is provided, the method will read the code from that file
|
|
444
743
|
* (waits for file to appear, polling every 2 seconds, up to 5 minutes).
|
|
445
744
|
*/
|
|
446
|
-
buildAuthURL(instanceURL, waitFile) {
|
|
745
|
+
buildAuthURL(instanceURL, waitFile, username = this._activeUsername()) {
|
|
447
746
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
448
747
|
const clientID = getOAuthClientID();
|
|
449
748
|
const pkce = generatePKCE();
|
|
@@ -454,12 +753,12 @@ export class AuthManager {
|
|
|
454
753
|
console.log();
|
|
455
754
|
console.log(`Waiting for authorization code in file: ${waitFile}`);
|
|
456
755
|
console.log('(polling every 2 seconds — write the code to the file to complete login)');
|
|
457
|
-
return this._waitForCodeFile(instanceURL, clientID, pkce, waitFile);
|
|
756
|
+
return this._waitForCodeFile(instanceURL, clientID, pkce, waitFile, 300000, username);
|
|
458
757
|
}
|
|
459
758
|
return url;
|
|
460
759
|
}
|
|
461
760
|
|
|
462
|
-
async _waitForCodeFile(instanceURL, clientID, pkce, filePath, timeout = 300000) {
|
|
761
|
+
async _waitForCodeFile(instanceURL, clientID, pkce, filePath, timeout = 300000, username) {
|
|
463
762
|
const start = Date.now();
|
|
464
763
|
const pollInterval = 2000;
|
|
465
764
|
while (Date.now() - start < timeout) {
|
|
@@ -469,7 +768,7 @@ export class AuthManager {
|
|
|
469
768
|
console.log(`\nAuthorization code found in ${filePath}`);
|
|
470
769
|
removePKCEState(instanceURL);
|
|
471
770
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
472
|
-
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
771
|
+
this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
|
|
473
772
|
console.log('Token exchange successful!\n');
|
|
474
773
|
return newCreds;
|
|
475
774
|
}
|
|
@@ -489,7 +788,7 @@ export class AuthManager {
|
|
|
489
788
|
async loginWithPassword(instanceURL, username = this._activeUsername()) {
|
|
490
789
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
491
790
|
const envCreds = getBasicAuthFromEnv(instanceURL);
|
|
492
|
-
const storedCreds = loadCredentials(instanceURL, username);
|
|
791
|
+
const storedCreds = this.loadCredentials(instanceURL, username);
|
|
493
792
|
const creds = envCreds || storedCreds;
|
|
494
793
|
if (!creds || creds.auth_method !== 'basic' || !creds.username || !creds.password) {
|
|
495
794
|
throw errAuth(
|
|
@@ -501,7 +800,7 @@ export class AuthManager {
|
|
|
501
800
|
` jsn auth login --basic ${instanceURL}`
|
|
502
801
|
);
|
|
503
802
|
}
|
|
504
|
-
saveCredentials(instanceURL, creds, creds.username);
|
|
803
|
+
this.saveCredentials(instanceURL, creds, creds.username);
|
|
505
804
|
console.log(`✓ Basic auth credentials saved for ${instanceURL}`);
|
|
506
805
|
return creds;
|
|
507
806
|
}
|
|
@@ -510,7 +809,14 @@ export class AuthManager {
|
|
|
510
809
|
* Complete login using an authorization code obtained from a prior buildAuthURL() call.
|
|
511
810
|
* The PKCE state must have been saved by an earlier buildAuthURL() call.
|
|
512
811
|
*/
|
|
513
|
-
async
|
|
812
|
+
async loginWithGck(instanceURL, input, username = this._activeUsername()) {
|
|
813
|
+
instanceURL = normalizeInstanceURL(instanceURL);
|
|
814
|
+
const creds = parseBrowserSessionInput(input);
|
|
815
|
+
this.saveCredentials(instanceURL, { ...creds, username: username || undefined }, username || undefined);
|
|
816
|
+
return { ...creds, username: username || undefined };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async loginWithCode(instanceURL, code, username = this._activeUsername()) {
|
|
514
820
|
instanceURL = normalizeInstanceURL(instanceURL);
|
|
515
821
|
const clientID = getOAuthClientID();
|
|
516
822
|
const pkce = loadPKCEState(instanceURL);
|
|
@@ -526,7 +832,7 @@ export class AuthManager {
|
|
|
526
832
|
removePKCEState(instanceURL);
|
|
527
833
|
|
|
528
834
|
const newCreds = await this.exchangeCode(instanceURL, clientID, code, pkce);
|
|
529
|
-
saveCredentials(instanceURL, newCreds, newCreds.username);
|
|
835
|
+
this.saveCredentials(instanceURL, newCreds, username || newCreds.username);
|
|
530
836
|
return newCreds;
|
|
531
837
|
}
|
|
532
838
|
|
|
@@ -547,7 +853,7 @@ export class AuthManager {
|
|
|
547
853
|
|
|
548
854
|
const text = await resp.text();
|
|
549
855
|
if (!resp.ok) {
|
|
550
|
-
throw errAuth(`Token exchange failed (status ${resp.status})
|
|
856
|
+
throw errAuth(`Token exchange failed (status ${resp.status})`);
|
|
551
857
|
}
|
|
552
858
|
|
|
553
859
|
const tokenResp = JSON.parse(text);
|
|
@@ -563,7 +869,10 @@ export class AuthManager {
|
|
|
563
869
|
};
|
|
564
870
|
}
|
|
565
871
|
|
|
566
|
-
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
|
+
}
|
|
567
876
|
const tokenURL = `${instance.replace(/\/$/, '')}/oauth_token.do`;
|
|
568
877
|
const clientID = getOAuthClientID();
|
|
569
878
|
const body = new URLSearchParams();
|
|
@@ -578,11 +887,17 @@ export class AuthManager {
|
|
|
578
887
|
});
|
|
579
888
|
|
|
580
889
|
if (!resp.ok) {
|
|
581
|
-
|
|
582
|
-
throw errAuth(`Token refresh failed
|
|
890
|
+
await resp.text();
|
|
891
|
+
throw errAuth(`Token refresh failed (status ${resp.status})`);
|
|
583
892
|
}
|
|
584
893
|
|
|
585
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());
|
|
586
901
|
const newCreds = {
|
|
587
902
|
auth_method: 'oauth',
|
|
588
903
|
access_token: tokenResp.access_token,
|
|
@@ -593,15 +908,15 @@ export class AuthManager {
|
|
|
593
908
|
if (tokenResp.expires_in) {
|
|
594
909
|
newCreds.expires_at = Math.floor(Date.now() / 1000) + tokenResp.expires_in;
|
|
595
910
|
}
|
|
596
|
-
|
|
911
|
+
this.credentialStore.save(instance, newCreds, username);
|
|
597
912
|
return newCreds;
|
|
598
913
|
}
|
|
599
914
|
|
|
600
|
-
logout(instance) {
|
|
915
|
+
logout(instance, username = this._activeUsername()) {
|
|
601
916
|
if (!instance) {
|
|
602
917
|
throw errAuth('No instance specified');
|
|
603
918
|
}
|
|
604
|
-
|
|
919
|
+
this.credentialStore.delete(instance, username);
|
|
605
920
|
}
|
|
606
921
|
|
|
607
922
|
/**
|
|
@@ -614,11 +929,11 @@ export class AuthManager {
|
|
|
614
929
|
*/
|
|
615
930
|
migrateLegacyCredential(instance, username) {
|
|
616
931
|
if (!instance || !username) return false;
|
|
617
|
-
const stored =
|
|
932
|
+
const stored = this.credentialStore.load(instance);
|
|
618
933
|
if (!stored || !stored.access_token) return false;
|
|
619
934
|
stored.username = username;
|
|
620
|
-
|
|
621
|
-
|
|
935
|
+
this.credentialStore.save(instance, stored, username);
|
|
936
|
+
this.credentialStore.delete(instance); // remove bare-instance legacy key
|
|
622
937
|
return true;
|
|
623
938
|
}
|
|
624
939
|
}
|