@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/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 credentialsPath(key) {
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 { ...keyringCreds, auth_source: 'keyring' };
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(credentialsPath(key), 'utf-8');
228
+ const data = fs.readFileSync(credentialsFilePath(key), 'utf-8');
217
229
  const creds = JSON.parse(data);
218
- return { ...creds, auth_source: 'file' };
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
- return getLastSeen(instance, this._activeUsername());
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
- return touchLastSeen(instance, this._activeUsername());
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 = loadCredentials(instance);
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._activeUsername();
515
+ const username = this._usernameForOptions(options);
331
516
  if (!username) return false; // No username set — bare key IS the active path
332
- const userCreds = loadCredentials(instance, username);
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
- if (process.env.SERVICENOW_OAUTH_TOKEN) return 'env_token';
344
- if (getBasicAuthFromEnv(instance)) return 'env_basic';
345
- const creds = loadCredentials(instance, this._activeUsername());
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
- return creds.auth_source || creds.auth_method || 'stored';
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
- try {
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
- if (getBasicAuthFromEnv(instance)) return true;
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 = this._activeUsername()) {
391
- // Check basic auth from env vars first
392
- const basicCreds = getBasicAuthFromEnv(instance);
393
- if (basicCreds) return basicCreds;
394
- const creds = loadCredentials(instance, username);
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 (creds.expires_at && Date.now() >= (creds.expires_at - 300) * 1000) {
400
- if (creds.refresh_token) {
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 creds;
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 for per-user credential keying
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 loginWithCode(instanceURL, code) {
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}): ${text}`);
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
- const text = await resp.text();
582
- throw errAuth(`Token refresh failed: ${text}`);
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
- saveCredentials(instance, newCreds, creds.username);
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
- deleteCredentials(instance, this._activeUsername());
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 = loadCredentials(instance);
932
+ const stored = this.credentialStore.load(instance);
618
933
  if (!stored || !stored.access_token) return false;
619
934
  stored.username = username;
620
- saveCredentials(instance, stored, username);
621
- deleteCredentials(instance); // remove bare-instance legacy key
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
  }