@jacebenson/jsn 4.2.4 → 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,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
- getLastSeen(instance) {
331
- return getLastSeen(instance, this._activeUsername());
479
+ loadCredentials(instance, username) {
480
+ return this.credentialStore.load(instance, username);
332
481
  }
333
482
 
334
- touchLastSeen(instance) {
335
- return touchLastSeen(instance, this._activeUsername());
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 = loadCredentials(instance);
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._activeUsername();
515
+ const username = this._usernameForOptions(options);
350
516
  if (!username) return false; // No username set — bare key IS the active path
351
- const userCreds = loadCredentials(instance, username);
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
- if (process.env.SERVICENOW_OAUTH_TOKEN) return 'env_token';
363
- if (getBasicAuthFromEnv(instance)) return 'env_basic';
364
- 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));
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
- 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
+ }
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
- try {
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
- if (getBasicAuthFromEnv(instance)) return true;
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 = this._activeUsername()) {
413
- // Check basic auth from env vars first
414
- const basicCreds = getBasicAuthFromEnv(instance);
415
- if (basicCreds) return basicCreds;
416
- 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);
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 (creds.expires_at && Date.now() >= (creds.expires_at - 300) * 1000) {
422
- if (creds.refresh_token) {
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 creds;
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 for per-user credential keying
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}): ${text}`);
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
- const text = await resp.text();
611
- throw errAuth(`Token refresh failed: ${text}`);
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
- saveCredentials(instance, newCreds, creds.username);
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
- deleteCredentials(instance, this._activeUsername());
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 = loadCredentials(instance);
932
+ const stored = this.credentialStore.load(instance);
647
933
  if (!stored || !stored.access_token) return false;
648
934
  stored.username = username;
649
- saveCredentials(instance, stored, username);
650
- deleteCredentials(instance); // remove bare-instance legacy key
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
  }