@jacebenson/jsn 4.3.10 → 4.3.11-dev.69
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/bin/jsn.js +7 -1
- package/package.json +3 -3
- package/src/app.js +10 -23
- package/src/auth/browser-session.js +21 -0
- package/src/auth/credential-state.js +124 -0
- package/src/auth/credential-store.js +177 -0
- package/src/auth/oauth.js +66 -0
- package/src/auth.js +11 -378
- package/src/cli.js +3 -1
- package/src/commands/docs/db.js +22 -2
- package/src/commands/docs/docs.js +13 -13
- package/src/commands/docs/embed.js +33 -33
- package/src/commands/docs/ingest.js +58 -58
- package/src/commands/docs/refresh.js +91 -91
- package/src/commands/docs/search.js +108 -108
- package/src/commands/docs/serve.js +4 -1
- package/src/commands/midservers.js +1 -1
- package/src/commands/setup.js +27 -25
- package/src/commands/skill.js +1 -1
- package/src/commands/skipped-updates.js +1 -1
- package/src/commands/updatesets.js +1 -1
- package/src/commands/version.js +16 -2
- package/src/flow-designer-interactive.js +2 -2
- package/src/frontmatter.js +5 -3
- package/src/helpers.js +1 -1
- package/src/paginated-search.js +162 -161
- package/src/prompts.js +19 -7
- package/src/sdk.js +79 -92
package/bin/jsn.js
CHANGED
|
@@ -21,4 +21,10 @@ process.emitWarning = function emitWarning(warning, ...args) {
|
|
|
21
21
|
return originalEmitWarning.apply(this, [warning, ...args]);
|
|
22
22
|
};
|
|
23
23
|
|
|
24
|
-
import('../src/cli.js').then((m) =>
|
|
24
|
+
import('../src/cli.js').then(async (m) => {
|
|
25
|
+
try {
|
|
26
|
+
await m.cli.parse();
|
|
27
|
+
} finally {
|
|
28
|
+
m.cancelBackgroundChecks();
|
|
29
|
+
}
|
|
30
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jacebenson/jsn",
|
|
3
|
-
"version": "4.3.
|
|
3
|
+
"version": "4.3.11-dev.69",
|
|
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": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"main": "src/cli.js",
|
|
10
10
|
"scripts": {
|
|
11
|
-
"test": "node
|
|
11
|
+
"test": "node scripts/test.mjs",
|
|
12
12
|
"test:mutation": "stryker run",
|
|
13
13
|
"lint": "npx eslint src/ bin/ test/",
|
|
14
14
|
"start": "node bin/jsn.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@inquirer/core": "^10.1.0",
|
|
32
32
|
"@inquirer/prompts": "^7.5.1",
|
|
33
33
|
"better-sqlite3": "^13.0.3",
|
|
34
|
-
"js-yaml": "^
|
|
34
|
+
"js-yaml": "^5.5.0",
|
|
35
35
|
"turndown": "^7.2.4"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
package/src/app.js
CHANGED
|
@@ -98,30 +98,17 @@ export class App {
|
|
|
98
98
|
displayUserName = displayUserName.slice(0, 6) + '...';
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
101
|
+
// Both lookups depend on the user, but not on one another.
|
|
102
|
+
const [application, selectedUpdateSet] = userSysID ? await Promise.all([
|
|
103
|
+
getCurrentApplication(this.sdk, userSysID).catch(() => null),
|
|
104
|
+
getCurrentUpdateSet(this.sdk, userSysID).catch(() => null),
|
|
105
|
+
]) : [null, null];
|
|
106
|
+
const scope = application?.scope || 'global';
|
|
107
|
+
const updateSet = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
|
|
108
|
+
? selectedUpdateSet.name : 'Default';
|
|
109
|
+
const updateSetSysID = selectedUpdateSet?.name && selectedUpdateSet.name !== '-'
|
|
110
|
+
? selectedUpdateSet.sys_id : '';
|
|
110
111
|
this.context.scope = scope;
|
|
111
|
-
|
|
112
|
-
let updateSet = 'Default';
|
|
113
|
-
let updateSetSysID = '';
|
|
114
|
-
if (userSysID) {
|
|
115
|
-
try {
|
|
116
|
-
const us = await getCurrentUpdateSet(this.sdk, userSysID);
|
|
117
|
-
if (us && us.name && us.name !== '-') {
|
|
118
|
-
updateSet = us.name;
|
|
119
|
-
updateSetSysID = us.sys_id;
|
|
120
|
-
}
|
|
121
|
-
} catch {
|
|
122
|
-
// ignore
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
112
|
this.context.updateSet = updateSet;
|
|
126
113
|
|
|
127
114
|
const instance = this.getEffectiveInstance();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { errAuth } from '../errors.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Extract the browser session token and cookies from pasted DevTools headers
|
|
5
|
+
* or a "Copy as cURL" command. Only these two values are retained.
|
|
6
|
+
*/
|
|
7
|
+
export function parseBrowserSessionInput(input) {
|
|
8
|
+
const text = String(input || '');
|
|
9
|
+
const tokenMatch = text.match(/(?:x-usertoken|x-user-token)\s*:\s*([^\s'";,]+)/i);
|
|
10
|
+
const cookieArgMatch = text.match(/(?:^|\s)(?:-b|--cookie)\s+(['"])(.*?)\1/s);
|
|
11
|
+
const quotedCookieMatch = text.match(/cookie\s*:\s*(['"])(.*?)\1/i);
|
|
12
|
+
const plainCookieMatch = text.match(/cookie\s*:\s*([^\r\n]+)/i);
|
|
13
|
+
const token = tokenMatch?.[1]?.trim() || '';
|
|
14
|
+
const cookies = (cookieArgMatch?.[2] || quotedCookieMatch?.[2] || plainCookieMatch?.[1] || '')
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/['"`]\s*(?:\\\s*)?$/, '');
|
|
17
|
+
if (!token) throw errAuth('No X-UserToken header found');
|
|
18
|
+
if (!cookies) throw errAuth('No Cookie header found. Paste the complete request headers or cURL command.');
|
|
19
|
+
return { auth_method: 'gck', access_token: token, cookies, auth_source: 'gck' };
|
|
20
|
+
}
|
|
21
|
+
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const AUTH_METHODS = new Set(['oauth', 'basic', 'gck']);
|
|
2
|
+
const AUTH_SOURCES = new Set(['env_token', 'env_basic', 'gck', 'keyring', 'file', 'unavailable']);
|
|
3
|
+
|
|
4
|
+
export function normalizeAuthMethod(value, fallback = 'unconfigured') {
|
|
5
|
+
return AUTH_METHODS.has(value) ? value : fallback;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function normalizeAuthSource(value, fallback = 'unavailable') {
|
|
9
|
+
return AUTH_SOURCES.has(value) ? value : fallback;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function sourceMethod(source) {
|
|
13
|
+
return { env_token: 'oauth', env_basic: 'basic', gck: 'gck' }[source] || null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PROBE_FAILURES = {
|
|
17
|
+
missing_credentials: {
|
|
18
|
+
message: 'Credentials are not available for this profile.',
|
|
19
|
+
hint: 'Run: jsn auth login',
|
|
20
|
+
},
|
|
21
|
+
credentials_expired: {
|
|
22
|
+
message: 'Credentials have expired and cannot be refreshed.',
|
|
23
|
+
hint: 'Run: jsn auth login',
|
|
24
|
+
},
|
|
25
|
+
refresh_required: {
|
|
26
|
+
message: 'Credentials require refresh before probing.',
|
|
27
|
+
hint: 'Run: jsn auth refresh',
|
|
28
|
+
},
|
|
29
|
+
refresh_failed: {
|
|
30
|
+
message: 'Credential refresh failed before the authenticated probe.',
|
|
31
|
+
hint: 'Run: jsn auth login',
|
|
32
|
+
},
|
|
33
|
+
invalid_browser_session: {
|
|
34
|
+
message: 'Browser session credentials are incomplete or invalid.',
|
|
35
|
+
hint: 'Capture a fresh ServiceNow browser request and run: jsn auth login --gck',
|
|
36
|
+
},
|
|
37
|
+
malformed_credentials: {
|
|
38
|
+
message: 'Credentials are malformed for the selected authentication method.',
|
|
39
|
+
hint: 'Run: jsn auth login',
|
|
40
|
+
},
|
|
41
|
+
permission_denied: {
|
|
42
|
+
message: 'The authenticated user lacks permission for the current-user probe.',
|
|
43
|
+
hint: 'Ask an administrator to grant access to the sys_user table/API.',
|
|
44
|
+
},
|
|
45
|
+
unauthorized: {
|
|
46
|
+
message: 'The instance rejected the selected credentials.',
|
|
47
|
+
hint: 'Run: jsn auth login',
|
|
48
|
+
},
|
|
49
|
+
network: {
|
|
50
|
+
message: 'The authenticated probe could not reach the instance.',
|
|
51
|
+
hint: 'Check your internet connection and instance URL.',
|
|
52
|
+
},
|
|
53
|
+
unknown: {
|
|
54
|
+
message: 'The authenticated probe failed for an unknown reason.',
|
|
55
|
+
hint: 'Retry the probe; if it persists, check the instance logs.',
|
|
56
|
+
},
|
|
57
|
+
missing_instance: {
|
|
58
|
+
message: 'No instance is configured for this profile.',
|
|
59
|
+
hint: 'Set an instance URL and run: jsn auth login',
|
|
60
|
+
},
|
|
61
|
+
sdk_construction_failed: {
|
|
62
|
+
message: 'The authenticated probe could not be initialized for this profile.',
|
|
63
|
+
hint: 'Check the profile configuration and run: jsn auth status again',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const PROBE_CLASSIFICATIONS = {
|
|
68
|
+
missing_credentials: 'unavailable', credentials_expired: 'expired', refresh_required: 'refresh_required',
|
|
69
|
+
refresh_failed: 'refresh_failed', invalid_browser_session: 'browser_session_invalid',
|
|
70
|
+
malformed_credentials: 'malformed', permission_denied: 'permission_denied', unauthorized: 'unauthorized',
|
|
71
|
+
network: 'network_error', unknown: 'unknown',
|
|
72
|
+
missing_instance: 'unavailable', sdk_construction_failed: 'configuration_error',
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export function probeFailure(code, status = 'failed') {
|
|
76
|
+
return { status, code, classification: PROBE_CLASSIFICATIONS[code] || 'unknown', ...PROBE_FAILURES[code] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function classifyProbeError(error, method) {
|
|
80
|
+
if (error?.status === 401 || error?.status === 403) {
|
|
81
|
+
return error.status === 403 ? 'permission_denied' : 'unauthorized';
|
|
82
|
+
}
|
|
83
|
+
if (error?.code === 'network_error' || ['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT'].includes(error?.code)) {
|
|
84
|
+
return 'network';
|
|
85
|
+
}
|
|
86
|
+
const text = String(error?.message || '').toLowerCase();
|
|
87
|
+
if (error?.code === 'auth_error' && text.includes('refresh')) return 'refresh_failed';
|
|
88
|
+
if (method === 'gck' && error?.code === 'auth_error') return 'invalid_browser_session';
|
|
89
|
+
return 'unknown';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Classify credentials without returning or logging any credential material.
|
|
94
|
+
* This is deliberately pure so diagnostics cannot refresh, persist, or probe.
|
|
95
|
+
*/
|
|
96
|
+
export function classifyCredentialState(credentials, method, now = Date.now()) {
|
|
97
|
+
if (!credentials) return 'missing';
|
|
98
|
+
if (!AUTH_METHODS.has(method)) return 'malformed';
|
|
99
|
+
|
|
100
|
+
if (method === 'basic') {
|
|
101
|
+
const hasUser = typeof credentials.username === 'string' && credentials.username.length > 0;
|
|
102
|
+
const hasPassword = typeof credentials.password === 'string' && credentials.password.length > 0;
|
|
103
|
+
return hasUser && hasPassword ? 'available' : 'malformed';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (method === 'gck') {
|
|
107
|
+
const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
|
|
108
|
+
const hasCookies = typeof credentials.cookies === 'string' && credentials.cookies.length > 0;
|
|
109
|
+
return hasToken && hasCookies ? 'available' : 'malformed';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (credentials.access_token !== undefined && typeof credentials.access_token !== 'string') return 'malformed';
|
|
113
|
+
if (credentials.refresh_token !== undefined && typeof credentials.refresh_token !== 'string') return 'malformed';
|
|
114
|
+
|
|
115
|
+
const hasToken = typeof credentials.access_token === 'string' && credentials.access_token.length > 0;
|
|
116
|
+
if (!hasToken) return credentials.refresh_token ? 'refreshable' : 'missing';
|
|
117
|
+
if (credentials.expires_at !== undefined && credentials.expires_at !== null && credentials.expires_at !== 0) {
|
|
118
|
+
const expiresAt = Number(credentials.expires_at);
|
|
119
|
+
if (!Number.isFinite(expiresAt)) return 'malformed';
|
|
120
|
+
if (now >= (expiresAt - 300) * 1000) return credentials.refresh_token ? 'refreshable' : 'expired';
|
|
121
|
+
}
|
|
122
|
+
return 'available';
|
|
123
|
+
}
|
|
124
|
+
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { globalConfigDir } from '../config.js';
|
|
5
|
+
|
|
6
|
+
// ─── Credential key helpers ───
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sanitize an instance URL into a filesystem/arg-safe key part.
|
|
10
|
+
*
|
|
11
|
+
* Whitelist approach (issue #143 findings #2/#3): first map the `://`
|
|
12
|
+
* protocol separator to `_` exactly as the Go version's encoding does
|
|
13
|
+
* (so existing keyring keys like `https_dev437538.service-now.com` keep
|
|
14
|
+
* matching), then drop everything outside [a-zA-Z0-9._-]. Blacklist
|
|
15
|
+
* stripping of just `/` and `:` left quotes, `$()`, backticks, `;`,
|
|
16
|
+
* spaces, and backslashes — which broke out of double-quoted shell args
|
|
17
|
+
* in keyring calls and, on Windows, out of path.join() credential dirs.
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeKeyPart(instance) {
|
|
20
|
+
return instance.replace(/:\/\//g, '_').replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a compound key for credential storage: <username>@<instance>
|
|
25
|
+
* When username is omitted (legacy), just the normalized instance URL is used.
|
|
26
|
+
*/
|
|
27
|
+
export function credKey(instance, username) {
|
|
28
|
+
const normalized = sanitizeKeyPart(instance);
|
|
29
|
+
if (!username) return normalized;
|
|
30
|
+
return `${username.replace(/[^a-zA-Z0-9._@-]/g, '_')}@${normalized}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Keychain constants — same as Go version (internal/auth/store.go)
|
|
34
|
+
const KEYRING_SERVICE = 'servicenow-cli';
|
|
35
|
+
const KEYRING_ATTR_SERVICE = 'service';
|
|
36
|
+
const KEYRING_ATTR_USERNAME = 'username';
|
|
37
|
+
|
|
38
|
+
function credentialsFilePath(key) {
|
|
39
|
+
const dir = path.join(globalConfigDir(), 'credentials');
|
|
40
|
+
// Match Go's filename encoding: replace :// and / and : with _
|
|
41
|
+
const filename = key + '.json';
|
|
42
|
+
return path.join(dir, filename);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function credentialsPath(key) {
|
|
46
|
+
const filePath = credentialsFilePath(key);
|
|
47
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
48
|
+
return filePath;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Keyring via secret-tool (libsecret, same backend as Go's go-keyring) ───
|
|
52
|
+
|
|
53
|
+
function keyringLookup(key) {
|
|
54
|
+
try {
|
|
55
|
+
// execFileSync with an arg array — no shell, so `key` can never
|
|
56
|
+
// break out into command injection (issue #143 finding #2).
|
|
57
|
+
const result = execFileSync(
|
|
58
|
+
'secret-tool',
|
|
59
|
+
['lookup', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
60
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf-8' }
|
|
61
|
+
);
|
|
62
|
+
const trimmed = result.trim();
|
|
63
|
+
if (!trimmed) return null;
|
|
64
|
+
const parsed = JSON.parse(trimmed);
|
|
65
|
+
// Spread all fields from keyring first, then normalize known fields
|
|
66
|
+
// so username, password, and other extras are preserved.
|
|
67
|
+
return {
|
|
68
|
+
...parsed,
|
|
69
|
+
auth_method: parsed.auth_method || 'oauth',
|
|
70
|
+
access_token: parsed.access_token || parsed.AccessToken || '',
|
|
71
|
+
refresh_token: parsed.refresh_token || parsed.RefreshToken || '',
|
|
72
|
+
expires_at: parsed.expires_at || parsed.ExpiresAt || 0,
|
|
73
|
+
created_at: parsed.created_at || parsed.CreatedAt || 0,
|
|
74
|
+
auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(parsed.auth_source)
|
|
75
|
+
? (parsed.auth_method === 'gck' ? 'gck' : 'keyring')
|
|
76
|
+
: parsed.auth_source,
|
|
77
|
+
};
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function keyringStore(key, creds) {
|
|
84
|
+
try {
|
|
85
|
+
execFileSync(
|
|
86
|
+
'secret-tool',
|
|
87
|
+
['store', `--label=Password for '${key}' on '${KEYRING_SERVICE}'`,
|
|
88
|
+
KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
89
|
+
{ stdio: ['pipe', 'ignore', 'ignore'], input: JSON.stringify(creds) }
|
|
90
|
+
);
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function keyringDelete(key) {
|
|
98
|
+
try {
|
|
99
|
+
execFileSync(
|
|
100
|
+
'secret-tool',
|
|
101
|
+
['clear', KEYRING_ATTR_SERVICE, KEYRING_SERVICE, KEYRING_ATTR_USERNAME, key],
|
|
102
|
+
{ stdio: 'ignore' }
|
|
103
|
+
);
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── File-based storage (fallback) ───
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Load credentials for an instance keyed by <username>@<instance>.
|
|
113
|
+
* When username is omitted (legacy configs without profile user), uses
|
|
114
|
+
* bare instance key for lookup.
|
|
115
|
+
*/
|
|
116
|
+
export function loadCredentials(instance, username, injectedStore) {
|
|
117
|
+
if (injectedStore) return injectedStore.load(instance, username);
|
|
118
|
+
const key = username ? credKey(instance, username) : credKey(instance);
|
|
119
|
+
const keyringCreds = keyringLookup(key);
|
|
120
|
+
if (keyringCreds) {
|
|
121
|
+
return {
|
|
122
|
+
...keyringCreds,
|
|
123
|
+
auth_source: keyringCreds.auth_source || (keyringCreds.auth_method === 'gck' ? 'gck' : 'keyring'),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
const data = fs.readFileSync(credentialsFilePath(key), 'utf-8');
|
|
128
|
+
const creds = JSON.parse(data);
|
|
129
|
+
return {
|
|
130
|
+
...creds,
|
|
131
|
+
auth_source: ['oauth', 'env_token', 'env_basic', undefined].includes(creds.auth_source)
|
|
132
|
+
? (creds.auth_method === 'gck' ? 'gck' : 'file')
|
|
133
|
+
: creds.auth_source,
|
|
134
|
+
};
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Save credentials for an instance.
|
|
142
|
+
* Uses <username>@<instance> key when username is available.
|
|
143
|
+
*/
|
|
144
|
+
export function saveCredentials(instance, creds, username, injectedStore) {
|
|
145
|
+
if (injectedStore) {
|
|
146
|
+
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
147
|
+
return injectedStore.save(instance, creds, username);
|
|
148
|
+
}
|
|
149
|
+
const key = username ? credKey(instance, username) : credKey(instance);
|
|
150
|
+
// Stamp last_seen on every credential save
|
|
151
|
+
creds.last_seen = creds.last_seen || Math.floor(Date.now() / 1000);
|
|
152
|
+
// Try keyring first, fall back to file
|
|
153
|
+
if (!keyringStore(key, creds)) {
|
|
154
|
+
fs.writeFileSync(credentialsPath(key), JSON.stringify(creds, null, 2), { mode: 0o600 });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Delete credentials for an instance keyed by <username>@<instance>.
|
|
160
|
+
*/
|
|
161
|
+
export function deleteCredentials(instance, username, injectedStore) {
|
|
162
|
+
if (injectedStore) return injectedStore.delete(instance, username);
|
|
163
|
+
const key = username ? credKey(instance, username) : credKey(instance);
|
|
164
|
+
keyringDelete(key);
|
|
165
|
+
try {
|
|
166
|
+
fs.unlinkSync(credentialsPath(key));
|
|
167
|
+
} catch {
|
|
168
|
+
// ignore
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const defaultCredentialStore = {
|
|
173
|
+
load: loadCredentials,
|
|
174
|
+
save: saveCredentials,
|
|
175
|
+
delete: deleteCredentials,
|
|
176
|
+
};
|
|
177
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { globalConfigDir } from '../config.js';
|
|
5
|
+
import { credKey } from './credential-store.js';
|
|
6
|
+
|
|
7
|
+
// ─── PKCE state persistence (shared with Go version) ───
|
|
8
|
+
|
|
9
|
+
function pkceStatePath(instance, username) {
|
|
10
|
+
const dir = path.join(globalConfigDir(), 'pkce');
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
12
|
+
// A known profile identity gets its own pending-login slot. Keep the bare
|
|
13
|
+
// instance slot for legacy/no-profile flows, where no identity exists yet.
|
|
14
|
+
const filename = credKey(instance, username) + '.json';
|
|
15
|
+
return path.join(dir, filename);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function savePKCEState(instance, pkce, username) {
|
|
19
|
+
const filePath = pkceStatePath(instance, username);
|
|
20
|
+
fs.writeFileSync(filePath, JSON.stringify(pkce, null, 2), { mode: 0o600 });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadPKCEState(instance, username) {
|
|
24
|
+
try {
|
|
25
|
+
const data = fs.readFileSync(pkceStatePath(instance, username), 'utf-8');
|
|
26
|
+
return JSON.parse(data);
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function removePKCEState(instance, username) {
|
|
33
|
+
try {
|
|
34
|
+
fs.unlinkSync(pkceStatePath(instance, username));
|
|
35
|
+
} catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const DEFAULT_OAUTH_CLIENT_ID = '543e5655f77746a28228c6009a599dfb';
|
|
41
|
+
export const REDIRECT_URI = '/sdk-oauth.do';
|
|
42
|
+
|
|
43
|
+
export function getOAuthClientID() {
|
|
44
|
+
return process.env.SERVICENOW_OAUTH_CLIENT_ID || DEFAULT_OAUTH_CLIENT_ID;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function generatePKCE() {
|
|
48
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
49
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
50
|
+
const state = crypto.randomBytes(16).toString('base64url');
|
|
51
|
+
return { code_verifier: verifier, code_challenge: challenge, state };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function buildAuthURL(instanceURL, clientID, pkce) {
|
|
55
|
+
const u = new URL('/oauth_auth.do', instanceURL);
|
|
56
|
+
u.searchParams.set('response_type', 'code');
|
|
57
|
+
u.searchParams.set('client_id', clientID);
|
|
58
|
+
u.searchParams.set('redirect_uri', REDIRECT_URI);
|
|
59
|
+
u.searchParams.set('state', pkce.state);
|
|
60
|
+
u.searchParams.set('code_challenge', pkce.code_challenge);
|
|
61
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
62
|
+
u.searchParams.set('scope', 'openid');
|
|
63
|
+
u.searchParams.set('approval_prompt', 'force');
|
|
64
|
+
return u.toString();
|
|
65
|
+
}
|
|
66
|
+
|