@jacebenson/jsn 4.3.13-dev.71.1 → 4.3.13-dev.72.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jacebenson/jsn",
3
- "version": "4.3.13-dev.71.1",
3
+ "version": "4.3.13-dev.72.1",
4
4
  "description": "A command-line interface for ServiceNow that follows the Unix philosophy: simple, composable, and scriptable.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/app.js CHANGED
@@ -8,6 +8,7 @@
8
8
  // command handlers that already call them.
9
9
 
10
10
  import { AuthManager } from './auth.js';
11
+ import { createAuthRecovery } from './auth-recovery.js';
11
12
  import { SDKClient } from './sdk.js';
12
13
  import { OutputWriter, FormatAuto, FormatJSON, FormatMarkdown, FormatQuiet, FormatStyled, hyperlink } from './output.js';
13
14
  import { saveConfig } from './config.js';
@@ -53,8 +54,11 @@ export class App {
53
54
 
54
55
  /** Build (or rebuild) the SDK client for an instance + session. */
55
56
  _buildSdk(instance, session = this.session) {
56
- this.sdk = new SDKClient(instance, this.auth, {
57
+ this.sdk = new SDKClient(instance, this.auth.createProfileProvider(instance, {
58
+ username: session?.username, authMethod: session?.profile?.auth_method,
59
+ }), {
57
60
  domain: session?.domain || '',
61
+ recovery: createAuthRecovery({ app: this, session }),
58
62
  });
59
63
  }
60
64
 
@@ -0,0 +1,74 @@
1
+ import { errAPI } from './errors.js';
2
+ import { canUsePicker, quoteShellArg } from './helpers.js';
3
+ import { confirm } from './prompts.js';
4
+
5
+ // Recovery is scoped to the SDK's resolved identity, never the mutable default.
6
+ // error supplies the same safe guidance if the single retry is also rejected.
7
+ export function createAuthRecovery({ app, session, argv = () => app._authArgv || {},
8
+ canInteract = canUsePicker, prompt = confirm,
9
+ renewBrowser = async (request, targetApp) => {
10
+ const { refreshProfile } = await import('./commands/auth.js');
11
+ return refreshProfile(request, targetApp);
12
+ },
13
+ }) {
14
+ const { instance, profileName, username, profile } = session;
15
+ const target = profileName ? `--profile ${quoteShellArg(profileName)}` : quoteShellArg(instance);
16
+ const label = profileName ? `profile ${quoteShellArg(profileName)}` : 'the active profile';
17
+ function failure(method = profile?.auth_method) {
18
+ const error = errAPI(401, '');
19
+ const login = `jsn auth login ${target}`;
20
+ error.message = `Authentication rejected for ${label} (HTTP 401).`;
21
+ error.profile = profileName || null;
22
+ error.recovery = method === 'basic'
23
+ ? `jsn auth login --basic ${quoteShellArg(instance)}${profileName ? ` --profile ${quoteShellArg(profileName)}` : ''}`
24
+ : method === 'gck' ? `jsn auth refresh ${target} --browser`
25
+ : method === 'oauth' ? `jsn auth refresh ${target}` : login;
26
+ error.hint = method === 'basic'
27
+ ? `Set SN_USERNAME/SN_PASSWORD to the correct credentials, then run: ${error.recovery}`
28
+ : method === 'oauth'
29
+ ? `Run interactively: ${error.recovery}. If refresh fails, run: ${login}`
30
+ : `Run: ${error.recovery}`;
31
+ return error;
32
+ }
33
+ // Keep the settled promise too: this provider gets only one recovery attempt.
34
+ let recoveryAttempt;
35
+ const provider = {
36
+ error: failure(),
37
+ async recover({ status }) {
38
+ if (status !== 401) return false;
39
+ return recoveryAttempt ??= recoverOnce();
40
+ },
41
+ };
42
+ async function recoverOnce() {
43
+ let creds;
44
+ try {
45
+ creds = await app.auth.getCredentialsFor(instance, username, {
46
+ username, authMethod: profile?.auth_method, refresh: false,
47
+ });
48
+ } catch { throw provider.error; }
49
+ const method = profile?.auth_method || creds.auth_method;
50
+ provider.error = failure(method);
51
+ if (method === 'oauth' && creds.refresh_token) {
52
+ try {
53
+ await app.auth.refreshToken(instance, creds, username ?? null);
54
+ return true;
55
+ } catch { throw provider.error; }
56
+ }
57
+ if (method === 'gck' && profileName && ['auto', 'confirm'].includes(profile?.reauth ?? 'confirm')
58
+ && canInteract(app, argv())) {
59
+ try {
60
+ if ((profile?.reauth ?? 'confirm') === 'confirm'
61
+ && !await prompt({ message: `Sign in again in a browser for ${label}?`, default: false })) throw provider.error;
62
+ // Reuse the canonical GCK refresh/login operation without emitting a
63
+ // second success envelope or replacing the in-flight command's SDK.
64
+ const targetApp = Object.create(app);
65
+ targetApp.ok = () => {};
66
+ targetApp.sdk = app.getSDKForProfile(instance, { profile, authMethod: 'gck', username });
67
+ await renewBrowser({ profile: profileName, browser: true }, targetApp);
68
+ return true;
69
+ } catch { throw provider.error; }
70
+ }
71
+ throw provider.error;
72
+ }
73
+ return provider;
74
+ }
package/src/cli.js CHANGED
@@ -213,6 +213,7 @@ export function buildCLI() {
213
213
  hint: 'Run "jsn auth status" to list profiles.',
214
214
  });
215
215
  }
216
+ app._authArgv = argv;
216
217
  applySession(app, session);
217
218
  } catch (err) {
218
219
  guardExit(argv, { code: err.code || 'usage', message: err.message, hint: err.hint });
@@ -382,13 +382,18 @@ export async function modifyProfile(app, argv = {}) {
382
382
  const name = argv.name || argv.profile || await pickProfile(app, 'Modify which profile?', argv);
383
383
  const profile = app.config.profiles?.[name];
384
384
  if (!profile) throw new Error(`Profile not found: ${name}`);
385
- const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts'].some(flag => argv[flag] !== undefined);
385
+ if (argv.reauth !== undefined && !['auto', 'confirm', 'never'].includes(argv.reauth)) {
386
+ throw errUsage('--reauth must be auto, confirm, or never.');
387
+ }
388
+ const explicitSettings = ['read-only', 'skip-confirmations', 'include-counts', 'reauth'].some(flag => argv[flag] !== undefined);
386
389
  if (explicitSettings) {
387
390
  if (argv.domain !== undefined) throw errUsage('Set profile preferences and domain separately.');
388
391
  const updated = await configureProfileOptions({ ...profile }, argv);
392
+ if (argv.reauth !== undefined) updated.reauth = argv.reauth;
389
393
  app.config.profiles[name] = updated;
390
394
  await saveConfig(app.config);
391
- app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false },
395
+ app.ok({ profile: name, read_only: updated.read_only === true, confirm_deletes: updated.skip_confirmations !== true, include_counts: updated.include_counts !== false,
396
+ ...(argv.reauth !== undefined ? { reauth: updated.reauth } : {}) },
392
397
  { summary: `Settings saved for ${name}` });
393
398
  return;
394
399
  }
@@ -1134,6 +1139,7 @@ export function authCmd(wrap) {
1134
1139
  .option('read-only', { type: 'boolean', describe: 'Set read-only access (use --no-read-only to allow writes)' })
1135
1140
  .option('include-counts', { type: 'boolean', describe: 'Set result counts (use --no-include-counts to disable)' })
1136
1141
  .option('confirm-deletes', { type: 'boolean', describe: 'Set delete confirmations (use --no-confirm-deletes to disable)' })
1142
+ .option('reauth', { type: 'string', choices: ['auto', 'confirm', 'never'], describe: 'Browser recovery policy: auto, confirm (default), or never; interactive only' })
1137
1143
  .option('skip-confirmations', { type: 'boolean', describe: 'Compatibility flag: disable delete confirmations' })
1138
1144
  .positional('name', {
1139
1145
  describe: 'Profile name to modify',
package/src/errors.js CHANGED
@@ -77,6 +77,16 @@ export function errNetwork(cause) {
77
77
  }
78
78
 
79
79
  export function errAPI(status, msg) {
80
+ // Authentication/authorization bodies can echo credentials. Never render them.
81
+ if (status === 401) {
82
+ const error = new AppError(CodeAuth, 'Authentication rejected for the active profile (HTTP 401).', 'Run: jsn auth login', 401);
83
+ error.recovery = 'jsn auth login';
84
+ return error;
85
+ }
86
+ if (status === 403) {
87
+ return new AppError(CodeForbidden, 'Access forbidden (HTTP 403).',
88
+ 'This is an authorization/ACL/role problem, not a login problem. Check access to this endpoint.', 403);
89
+ }
80
90
  let hint = 'Check the API documentation for this endpoint.';
81
91
  if (status >= 500) {
82
92
  hint = 'The ServiceNow instance may be experiencing issues. Try again later.';
@@ -229,6 +239,11 @@ export function renderAppError(err, { app = null, argv = null, identifier = '' }
229
239
  return { stream: 'stderr', text, exitCode: 1 };
230
240
  }
231
241
 
242
+ if (code === CodeForbidden) {
243
+ if (resolveJsonMode(app, argv) && app) return { stream: 'stdout', text: null, exitCode: 1, appErr: true };
244
+ return { stream: 'stderr', text: `Error (forbidden): ${err.message}\n\nHint: ${err.hint}\n`, exitCode: 1 };
245
+ }
246
+
232
247
  if (code === CodeConfirmationRequired) {
233
248
  // Structured error for AI agents / piped consumers: the JSON envelope
234
249
  // goes to stdout (where tooling reads) so the agent can surface the
package/src/output.js CHANGED
@@ -160,6 +160,11 @@ export class OutputWriter {
160
160
  const format = this.effectiveFormat();
161
161
  const e = error.code ? error : { code: 'unknown', message: String(error), hint: '' };
162
162
  const resp = { ok: false, error: e.message, code: e.code, hint: e.hint || '' };
163
+ if (e.status === 401 || e.status === 403) resp.status = e.status;
164
+ if (e.recovery) {
165
+ resp.recovery = e.recovery;
166
+ if (Object.hasOwn(e, 'profile')) resp.profile = e.profile;
167
+ }
163
168
  switch (format) {
164
169
  case FormatJSON:
165
170
  case FormatQuiet:
package/src/sdk.js CHANGED
@@ -47,6 +47,7 @@ export class SDKClient {
47
47
  constructor(baseURL, authProvider, opts = {}) {
48
48
  this.baseURL = baseURL.replace(/\/$/, '');
49
49
  this.authProvider = authProvider;
50
+ this.recovery = opts.recovery;
50
51
  this.timeout = opts.timeout || DEFAULT_TIMEOUT;
51
52
  this.domain = opts.domain || '';
52
53
  this.oauthCookies = null;
@@ -100,23 +101,42 @@ export class SDKClient {
100
101
  async _withAuthenticatedRequest(endpoint, opts, {
101
102
  timeout = this.timeout, prepare, skipOAuthSession = false, normalizeErrors = false,
102
103
  }, consume = (response) => response) {
103
- const controller = new AbortController();
104
- const timer = setTimeout(() => controller.abort(), timeout);
105
- try {
106
- const req = new Request(endpoint, { ...opts, signal: controller.signal });
107
- if (prepare) prepare(req);
108
- await this._setAuth(req, { skipOAuthSession });
109
- return await consume(await fetch(req));
110
- } catch (err) {
111
- if (normalizeErrors) {
112
- if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
113
- if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
114
- throw errNetwork(err);
104
+ for (let attempt = 0; attempt < 2; attempt++) {
105
+ const controller = new AbortController();
106
+ const timer = setTimeout(() => controller.abort(), timeout);
107
+ let rejection;
108
+ try {
109
+ const req = new Request(endpoint, { ...opts, signal: controller.signal });
110
+ if (prepare) prepare(req);
111
+ await this._setAuth(req, { skipOAuthSession });
112
+ const response = await fetch(req);
113
+ if (!skipOAuthSession && response.status === 401 && this.recovery) {
114
+ // Only a definitive HTTP rejection permits replay, including POST.
115
+ // Do not consume/echo the provider's potentially sensitive body.
116
+ rejection = { status: response.status, method: req.method, url: req.url };
117
+ await response.body?.cancel();
118
+ } else {
119
+ return await consume(response);
115
120
  }
121
+ } catch (err) {
122
+ if (normalizeErrors) {
123
+ if (err.name === 'AbortError') throw errNetwork(new Error('Request timed out'));
124
+ if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND' || err.code === 'ETIMEDOUT') {
125
+ throw errNetwork(err);
126
+ }
127
+ }
128
+ throw err;
129
+ } finally {
130
+ clearTimeout(timer);
116
131
  }
117
- throw err;
118
- } finally {
119
- clearTimeout(timer);
132
+ // Browser renewal has its own existing hard timeout. Never leave a
133
+ // request timeout running while waiting for the human to authenticate.
134
+ if (attempt === 0 && await this.recovery.recover(rejection)) {
135
+ this.oauthCookies = null;
136
+ this.sessionUserToken = '';
137
+ continue;
138
+ }
139
+ throw this.recovery.error || errAPI(401, '');
120
140
  }
121
141
  }
122
142