@wickedevolutions/abilities-mcp 1.3.1 → 1.5.3
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/CHANGELOG.md +111 -0
- package/README.md +88 -17
- package/abilities-mcp.js +191 -114
- package/lib/auth/bridge-identity-provider.js +34 -0
- package/lib/auth/browser-launcher.js +67 -0
- package/lib/auth/config-migration.js +322 -0
- package/lib/auth/dcr-client.js +123 -0
- package/lib/auth/discovery-client.js +273 -0
- package/lib/auth/errors.js +114 -0
- package/lib/auth/events.js +55 -0
- package/lib/auth/fresh-each-time-identity.js +101 -0
- package/lib/auth/http-json.js +151 -0
- package/lib/auth/index.js +88 -0
- package/lib/auth/keychain-secret-store.js +265 -0
- package/lib/auth/loopback-server.js +249 -0
- package/lib/auth/memory-secret-store.js +0 -0
- package/lib/auth/oauth-client.js +357 -0
- package/lib/auth/pkce.js +93 -0
- package/lib/auth/schema-v2.js +110 -0
- package/lib/auth/secret-store.js +78 -0
- package/lib/auth/token-manager.js +378 -0
- package/lib/cli/commands/add-site.js +226 -0
- package/lib/cli/commands/force-downgrade.js +93 -0
- package/lib/cli/commands/list-sites.js +93 -0
- package/lib/cli/commands/reauth.js +108 -0
- package/lib/cli/commands/revoke.js +127 -0
- package/lib/cli/commands/self-check.js +158 -0
- package/lib/cli/commands/test.js +174 -0
- package/lib/cli/commands/upgrade-auth.js +259 -0
- package/lib/cli/config-store.js +328 -0
- package/lib/cli/context.js +102 -0
- package/lib/cli/errors.js +227 -0
- package/lib/cli/index.js +173 -0
- package/lib/cli/output.js +175 -0
- package/lib/cli/parse-args.js +80 -0
- package/lib/config-source-line.js +85 -0
- package/lib/config.js +282 -22
- package/lib/connection-pool.js +214 -11
- package/lib/router.js +29 -11
- package/lib/transports/oauth-http-transport.js +601 -0
- package/package.json +8 -2
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Public surface for `lib/auth/`.
|
|
5
|
+
*
|
|
6
|
+
* Every export here maps 1:1 to a future CLI subcommand or is a building
|
|
7
|
+
* block consumed by the state machine. The module is callable from any
|
|
8
|
+
* consumer (CLI today, GUI tomorrow). It contains zero CLI dependencies —
|
|
9
|
+
* no console.log, no process.exit, no readline.
|
|
10
|
+
*
|
|
11
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
12
|
+
* @license GPL-2.0-or-later
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const events = require('./events');
|
|
16
|
+
const errors = require('./errors');
|
|
17
|
+
const secretStore = require('./secret-store');
|
|
18
|
+
const { MemorySecretStore } = require('./memory-secret-store');
|
|
19
|
+
const { KeychainSecretStore } = require('./keychain-secret-store');
|
|
20
|
+
const bridgeIdentity = require('./bridge-identity-provider');
|
|
21
|
+
const { FreshEachTimeIdentityProvider } = require('./fresh-each-time-identity');
|
|
22
|
+
const pkce = require('./pkce');
|
|
23
|
+
const discoveryClient = require('./discovery-client');
|
|
24
|
+
const dcrClient = require('./dcr-client');
|
|
25
|
+
const { LoopbackServer, PORT_MIN, PORT_MAX } = require('./loopback-server');
|
|
26
|
+
const { openBrowser } = require('./browser-launcher');
|
|
27
|
+
const { OAuthClient, DEFAULT_SCOPE } = require('./oauth-client');
|
|
28
|
+
const { TokenManager, REFRESH_WINDOW_SECONDS, HTTP_TIMEOUT_MS, MAX_RETRIES } = require('./token-manager');
|
|
29
|
+
const schemaV2 = require('./schema-v2');
|
|
30
|
+
const configMigration = require('./config-migration');
|
|
31
|
+
|
|
32
|
+
module.exports = {
|
|
33
|
+
// Constants and enums
|
|
34
|
+
STATES: events.STATES,
|
|
35
|
+
TERMINAL_STATES: events.TERMINAL_STATES,
|
|
36
|
+
AUTH_STATUS: events.AUTH_STATUS,
|
|
37
|
+
EVENTS: events.EVENTS,
|
|
38
|
+
DEFAULT_SCOPE,
|
|
39
|
+
REFRESH_WINDOW_SECONDS,
|
|
40
|
+
HTTP_TIMEOUT_MS,
|
|
41
|
+
MAX_RETRIES,
|
|
42
|
+
PORT_MIN,
|
|
43
|
+
PORT_MAX,
|
|
44
|
+
IDENTITY_BUNDLE_VERSION: bridgeIdentity.IDENTITY_BUNDLE_VERSION,
|
|
45
|
+
SCHEMA_VERSION: schemaV2.SCHEMA_VERSION,
|
|
46
|
+
AUTH_METHODS: schemaV2.AUTH_METHODS,
|
|
47
|
+
VALID_AUTH_STATUS: schemaV2.VALID_AUTH_STATUS,
|
|
48
|
+
|
|
49
|
+
// Errors
|
|
50
|
+
AuthError: errors.AuthError,
|
|
51
|
+
DiscoveryError: errors.DiscoveryError,
|
|
52
|
+
CapabilityPinningError: errors.CapabilityPinningError,
|
|
53
|
+
RegistrationError: errors.RegistrationError,
|
|
54
|
+
TokenExchangeError: errors.TokenExchangeError,
|
|
55
|
+
StateMismatchError: errors.StateMismatchError,
|
|
56
|
+
UserDeniedError: errors.UserDeniedError,
|
|
57
|
+
RefreshError: errors.RefreshError,
|
|
58
|
+
SecretStoreError: errors.SecretStoreError,
|
|
59
|
+
MigrationError: errors.MigrationError,
|
|
60
|
+
|
|
61
|
+
// Secret store
|
|
62
|
+
SecretStore: {
|
|
63
|
+
KEYCHAIN_REF_SCHEME: secretStore.KEYCHAIN_REF_SCHEME,
|
|
64
|
+
makeRef: secretStore.makeRef,
|
|
65
|
+
parseRef: secretStore.parseRef,
|
|
66
|
+
resolveRef: secretStore.resolveRef,
|
|
67
|
+
},
|
|
68
|
+
MemorySecretStore,
|
|
69
|
+
KeychainSecretStore,
|
|
70
|
+
|
|
71
|
+
// Identity
|
|
72
|
+
FreshEachTimeIdentityProvider,
|
|
73
|
+
|
|
74
|
+
// Primitives
|
|
75
|
+
pkce,
|
|
76
|
+
discoveryClient,
|
|
77
|
+
dcrClient,
|
|
78
|
+
LoopbackServer,
|
|
79
|
+
openBrowser,
|
|
80
|
+
|
|
81
|
+
// Orchestration
|
|
82
|
+
OAuthClient,
|
|
83
|
+
TokenManager,
|
|
84
|
+
|
|
85
|
+
// Config schema
|
|
86
|
+
schemaV2,
|
|
87
|
+
configMigration,
|
|
88
|
+
};
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execFile } = require('node:child_process');
|
|
4
|
+
|
|
5
|
+
const { SecretStoreError } = require('./errors');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* KeychainSecretStore — keytar-backed SecretStore with a darwin-only
|
|
9
|
+
* `security` CLI fallback for Claude Desktop's hardened-runtime barrier.
|
|
10
|
+
*
|
|
11
|
+
* keytar wraps macOS Keychain, Windows Credential Manager, and Linux libsecret
|
|
12
|
+
* via a native binding (`build/Release/keytar.node`). It is declared as an
|
|
13
|
+
* `optionalDependency` so a failed native build does not break `npm install`
|
|
14
|
+
* for env-var-only operators.
|
|
15
|
+
*
|
|
16
|
+
* **The darwin fallback (issue #39).** When the bundled keytar binary fails
|
|
17
|
+
* to dlopen inside Claude Desktop's hardened-runtime process — the macOS
|
|
18
|
+
* code-signing rejects native binaries with mismatched Team IDs, Anthropic-
|
|
19
|
+
* signed Claude Desktop refuses to load npm-distribution-signed keytar.node —
|
|
20
|
+
* we fall back to shelling out to the macOS `security` CLI via
|
|
21
|
+
* `child_process.execFile`. `security` is always installed on macOS, doesn't
|
|
22
|
+
* require dynamic native loading, operates against the same macOS Keychain,
|
|
23
|
+
* and runs as a child process out from under Claude Desktop's hardened-
|
|
24
|
+
* runtime restrictions. The fallback fires only on darwin; on linux/win32 a
|
|
25
|
+
* keytar load failure still throws `keytar_unavailable` (current behavior).
|
|
26
|
+
*
|
|
27
|
+
* Outside the .mcpb path — system Node, CLI install, npx, source clone —
|
|
28
|
+
* keytar loads normally and the fallback never engages.
|
|
29
|
+
*
|
|
30
|
+
* If keytar is unavailable at runtime AND the darwin fallback also can't run,
|
|
31
|
+
* every method throws `SecretStoreError` with code `keytar_unavailable` —
|
|
32
|
+
* callers can detect that and fall back to a different store (e.g.
|
|
33
|
+
* MemorySecretStore for tests, or surface to the user).
|
|
34
|
+
*
|
|
35
|
+
* Implements the SecretStore interface defined in `secret-store.js`.
|
|
36
|
+
*
|
|
37
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
38
|
+
* @license GPL-2.0-or-later
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
class KeychainSecretStore {
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} [opts]
|
|
44
|
+
* @param {object} [opts.keytar] Inject a keytar module — primarily for tests.
|
|
45
|
+
* When omitted, keytar is required lazily on
|
|
46
|
+
* first use.
|
|
47
|
+
* @param {Function} [opts.requireKeytar] Override the require call used to load
|
|
48
|
+
* keytar. Test seam: pass a function that
|
|
49
|
+
* throws to simulate the .mcpb-path dlopen
|
|
50
|
+
* rejection without breaking the real
|
|
51
|
+
* require('keytar') in the test runtime.
|
|
52
|
+
* @param {string} [opts.platform] Override `process.platform` for the
|
|
53
|
+
* fallback-eligibility decision. Test seam.
|
|
54
|
+
* @param {Function} [opts.exec] Override `child_process.execFile`. Test
|
|
55
|
+
* seam for the security-CLI fallback path.
|
|
56
|
+
*/
|
|
57
|
+
constructor(opts = {}) {
|
|
58
|
+
this._injected = opts.keytar || null;
|
|
59
|
+
this._keytar = null;
|
|
60
|
+
this._loadAttempted = false;
|
|
61
|
+
this._loadError = null;
|
|
62
|
+
this._fallbackMode = null; // null | 'security-cli'
|
|
63
|
+
|
|
64
|
+
this._requireKeytar = opts.requireKeytar || ((id) => require(id));
|
|
65
|
+
this._platform = opts.platform || process.platform;
|
|
66
|
+
this._exec = opts.exec || execFile;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Lazy load. Sets one of three terminal states:
|
|
71
|
+
* - `this._keytar` populated (keytar loaded normally; primary path)
|
|
72
|
+
* - `this._fallbackMode === 'security-cli'` (darwin fallback engaged)
|
|
73
|
+
* - `this._loadError` set + throws (non-darwin keytar failure)
|
|
74
|
+
*/
|
|
75
|
+
_load() {
|
|
76
|
+
if (this._keytar || this._fallbackMode) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (this._loadAttempted) {
|
|
80
|
+
// Previously failed and we cached the error.
|
|
81
|
+
throw new SecretStoreError(
|
|
82
|
+
`OS keychain unavailable: ${this._loadError.message}`,
|
|
83
|
+
{ code: 'keytar_unavailable', cause: this._loadError }
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
this._loadAttempted = true;
|
|
87
|
+
|
|
88
|
+
if (this._injected) {
|
|
89
|
+
this._keytar = this._injected;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
this._keytar = this._requireKeytar('keytar');
|
|
95
|
+
return;
|
|
96
|
+
} catch (err) {
|
|
97
|
+
// darwin: Claude Desktop's hardened-runtime rejects bundled keytar.node
|
|
98
|
+
// with a Team ID mismatch (issue #39). Fall back to the `security` CLI
|
|
99
|
+
// rather than throwing — the bridge keeps working against the same
|
|
100
|
+
// macOS Keychain, just via shell-out.
|
|
101
|
+
if (this._platform === 'darwin') {
|
|
102
|
+
this._fallbackMode = 'security-cli';
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
this._loadError = err;
|
|
106
|
+
throw new SecretStoreError(
|
|
107
|
+
`OS keychain unavailable: ${err.message}`,
|
|
108
|
+
{ code: 'keytar_unavailable', cause: err }
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* @returns {Promise<boolean>} true if keytar can be loaded on this host OR
|
|
115
|
+
* the darwin security-CLI fallback is engaged.
|
|
116
|
+
*/
|
|
117
|
+
async isAvailable() {
|
|
118
|
+
try {
|
|
119
|
+
this._load();
|
|
120
|
+
return true;
|
|
121
|
+
} catch {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async get(service, account) {
|
|
127
|
+
this._load();
|
|
128
|
+
if (this._fallbackMode === 'security-cli') {
|
|
129
|
+
return this._securityGet(service, account);
|
|
130
|
+
}
|
|
131
|
+
return this._keytar.getPassword(service, account);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async set(service, account, secret) {
|
|
135
|
+
if (typeof secret !== 'string') {
|
|
136
|
+
throw new TypeError('SecretStore.set: secret must be a string');
|
|
137
|
+
}
|
|
138
|
+
this._load();
|
|
139
|
+
if (this._fallbackMode === 'security-cli') {
|
|
140
|
+
return this._securitySet(service, account, secret);
|
|
141
|
+
}
|
|
142
|
+
await this._keytar.setPassword(service, account, secret);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async delete(service, account) {
|
|
146
|
+
this._load();
|
|
147
|
+
if (this._fallbackMode === 'security-cli') {
|
|
148
|
+
return this._securityDelete(service, account);
|
|
149
|
+
}
|
|
150
|
+
return this._keytar.deletePassword(service, account);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async findAll(service) {
|
|
154
|
+
this._load();
|
|
155
|
+
if (this._fallbackMode === 'security-cli') {
|
|
156
|
+
// The macOS `security` CLI has no clean enumerate-by-service mode.
|
|
157
|
+
// Returning [] here is safe because the bridge runtime path never
|
|
158
|
+
// calls findAll — only the CLI subcommand `list-sites` does, and that
|
|
159
|
+
// runs in system Node where keytar loads normally and this branch
|
|
160
|
+
// is never taken. Documented in the issue body's "findAll" note.
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
return this._keytar.findCredentials(service);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------
|
|
167
|
+
// darwin `security` CLI fallback — internal helpers.
|
|
168
|
+
// ---------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Run the `security` CLI with the given args. Returns { stdout, stderr }
|
|
172
|
+
* on success, rejects with an error carrying `.stderr` / `.stdout` /
|
|
173
|
+
* `.code` (exit code) on failure. Uses `execFile` (not `exec`) so args
|
|
174
|
+
* are not shell-interpreted — the password / account / service strings
|
|
175
|
+
* pass through verbatim, no shell injection surface.
|
|
176
|
+
*/
|
|
177
|
+
_execSecurity(args) {
|
|
178
|
+
return new Promise((resolve, reject) => {
|
|
179
|
+
this._exec('security', args, {}, (err, stdout, stderr) => {
|
|
180
|
+
const stdoutStr = typeof stdout === 'string'
|
|
181
|
+
? stdout
|
|
182
|
+
: (stdout ? stdout.toString() : '');
|
|
183
|
+
const stderrStr = typeof stderr === 'string'
|
|
184
|
+
? stderr
|
|
185
|
+
: (stderr ? stderr.toString() : '');
|
|
186
|
+
if (err) {
|
|
187
|
+
// Real child_process.execFile populates err.stderr / err.stdout
|
|
188
|
+
// and ALSO passes them as cb args. Preserve whichever the caller
|
|
189
|
+
// already attached (some test doubles attach to the err object
|
|
190
|
+
// and don't pass via cb args); fall back to the cb args otherwise.
|
|
191
|
+
if (typeof err.stderr !== 'string') err.stderr = stderrStr;
|
|
192
|
+
if (typeof err.stdout !== 'string') err.stdout = stdoutStr;
|
|
193
|
+
return reject(err);
|
|
194
|
+
}
|
|
195
|
+
resolve({ stdout: stdoutStr, stderr: stderrStr });
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async _securityGet(service, account) {
|
|
201
|
+
try {
|
|
202
|
+
const { stdout } = await this._execSecurity([
|
|
203
|
+
'find-generic-password', '-s', service, '-a', account, '-w',
|
|
204
|
+
]);
|
|
205
|
+
// -w prints just the password to stdout, terminated by a newline.
|
|
206
|
+
return stdout.replace(/\n$/, '');
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (_isNotFound(err)) return null;
|
|
209
|
+
throw new SecretStoreError(
|
|
210
|
+
`security find-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
|
|
211
|
+
{ code: 'security_cli_failed', cause: err }
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async _securitySet(service, account, secret) {
|
|
217
|
+
// -U updates the existing entry if present, adds it otherwise.
|
|
218
|
+
// Note: passing the password as the last argv element is the standard
|
|
219
|
+
// pattern for non-interactive `security` use; the macOS `security` CLI
|
|
220
|
+
// exposes no stdin-only password input mode for non-interactive callers.
|
|
221
|
+
// This is the same trade-off keytar's own native binding makes — the
|
|
222
|
+
// password lives in process memory until the syscall completes.
|
|
223
|
+
try {
|
|
224
|
+
await this._execSecurity([
|
|
225
|
+
'add-generic-password', '-U', '-s', service, '-a', account, '-w', secret,
|
|
226
|
+
]);
|
|
227
|
+
} catch (err) {
|
|
228
|
+
throw new SecretStoreError(
|
|
229
|
+
`security add-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
|
|
230
|
+
{ code: 'security_cli_failed', cause: err }
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async _securityDelete(service, account) {
|
|
236
|
+
try {
|
|
237
|
+
await this._execSecurity([
|
|
238
|
+
'delete-generic-password', '-s', service, '-a', account,
|
|
239
|
+
]);
|
|
240
|
+
return true;
|
|
241
|
+
} catch (err) {
|
|
242
|
+
if (_isNotFound(err)) return false;
|
|
243
|
+
throw new SecretStoreError(
|
|
244
|
+
`security delete-generic-password failed: ${(err.stderr || err.message || '').trim()}`,
|
|
245
|
+
{ code: 'security_cli_failed', cause: err }
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Detect the macOS `security` CLI's "entry not found" condition. Stderr from
|
|
253
|
+
* `find-generic-password` / `delete-generic-password` against a missing entry
|
|
254
|
+
* looks like:
|
|
255
|
+
* security: SecKeychainSearchCopyNext: The specified item could not be
|
|
256
|
+
* found in the keychain.
|
|
257
|
+
* Match on the substring "could not be found" (case-insensitive) to map it
|
|
258
|
+
* to keytar's null/false return semantics.
|
|
259
|
+
*/
|
|
260
|
+
function _isNotFound(err) {
|
|
261
|
+
const stderr = (err && err.stderr) || '';
|
|
262
|
+
return /could not be found/i.test(stderr);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = { KeychainSecretStore };
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('node:http');
|
|
4
|
+
const { randomInt } = require('node:crypto');
|
|
5
|
+
const { URL } = require('node:url');
|
|
6
|
+
|
|
7
|
+
const { safeStateEquals } = require('./pkce');
|
|
8
|
+
const { StateMismatchError, UserDeniedError, AuthError } = require('./errors');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Loopback callback server for the OAuth authorization-code flow.
|
|
12
|
+
*
|
|
13
|
+
* Per Appendix H.4.5 (binding):
|
|
14
|
+
* - Bind on 127.0.0.1, random high port in [49152, 65535].
|
|
15
|
+
* - SO_REUSEADDR=false. In Node, that maps to `listen({ exclusive: true })`
|
|
16
|
+
* — the server will not be shared with other workers and will fail with
|
|
17
|
+
* EADDRINUSE if the port is already bound.
|
|
18
|
+
* - State token validates the callback (H.3.5: timingSafeEqual on equal
|
|
19
|
+
* length, mismatch → reject without exchanging code).
|
|
20
|
+
*
|
|
21
|
+
* Public API:
|
|
22
|
+
* const server = new LoopbackServer({ expectedState });
|
|
23
|
+
* const { port, redirectUri } = await server.start();
|
|
24
|
+
* // → operator browser flow happens
|
|
25
|
+
* const callback = await server.waitForCallback({ timeoutMs });
|
|
26
|
+
* // callback = { code, state } | throws StateMismatchError | UserDeniedError | AuthError
|
|
27
|
+
* await server.stop();
|
|
28
|
+
*
|
|
29
|
+
* The server responds to the browser with a small HTML success or error page
|
|
30
|
+
* depending on the outcome. The callback Promise resolves AFTER the response
|
|
31
|
+
* is flushed so the operator's browser shows the page even if the caller
|
|
32
|
+
* stops the server immediately.
|
|
33
|
+
*
|
|
34
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
35
|
+
* @license GPL-2.0-or-later
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const PORT_MIN = 49_152;
|
|
39
|
+
const PORT_MAX = 65_535;
|
|
40
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60_000; // 5 min for operator to complete browser flow
|
|
41
|
+
const DEFAULT_BIND_RETRIES = 5;
|
|
42
|
+
|
|
43
|
+
const SUCCESS_HTML = `<!DOCTYPE html>
|
|
44
|
+
<html><head><meta charset="utf-8"><title>Authorization complete</title>
|
|
45
|
+
<style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:36rem;margin:6rem auto;padding:0 1.5rem;color:#111}h1{margin:0 0 1rem}p{margin:.25rem 0}</style>
|
|
46
|
+
</head><body><h1>Authorization complete</h1>
|
|
47
|
+
<p>You can close this tab and return to your terminal.</p></body></html>`;
|
|
48
|
+
|
|
49
|
+
const DENIED_HTML = `<!DOCTYPE html>
|
|
50
|
+
<html><head><meta charset="utf-8"><title>Authorization denied</title>
|
|
51
|
+
<style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:36rem;margin:6rem auto;padding:0 1.5rem;color:#111}h1{margin:0 0 1rem;color:#a00}p{margin:.25rem 0}</style>
|
|
52
|
+
</head><body><h1>Authorization denied</h1>
|
|
53
|
+
<p>The site reported that authorization was denied. You can close this tab.</p></body></html>`;
|
|
54
|
+
|
|
55
|
+
const ERROR_HTML = `<!DOCTYPE html>
|
|
56
|
+
<html><head><meta charset="utf-8"><title>Authorization error</title>
|
|
57
|
+
<style>body{font:16px/1.5 -apple-system,system-ui,sans-serif;max-width:36rem;margin:6rem auto;padding:0 1.5rem;color:#111}h1{margin:0 0 1rem;color:#a00}p{margin:.25rem 0}</style>
|
|
58
|
+
</head><body><h1>Authorization error</h1>
|
|
59
|
+
<p>An unexpected response was received. Check your terminal for details.</p></body></html>`;
|
|
60
|
+
|
|
61
|
+
class LoopbackServer {
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} args
|
|
64
|
+
* @param {string} args.expectedState Bridge-generated state token
|
|
65
|
+
* @param {string} [args.callbackPath] Defaults to '/callback'
|
|
66
|
+
* @param {number} [args.bindRetries]
|
|
67
|
+
* @param {(min:number,max:number)=>number} [args.portFn] Test seam.
|
|
68
|
+
*/
|
|
69
|
+
constructor(args) {
|
|
70
|
+
if (!args || typeof args.expectedState !== 'string' || args.expectedState.length === 0) {
|
|
71
|
+
throw new Error('LoopbackServer requires expectedState');
|
|
72
|
+
}
|
|
73
|
+
this._expectedState = args.expectedState;
|
|
74
|
+
this._callbackPath = args.callbackPath || '/callback';
|
|
75
|
+
this._bindRetries = args.bindRetries ?? DEFAULT_BIND_RETRIES;
|
|
76
|
+
this._portFn = args.portFn || (() => randomInt(PORT_MIN, PORT_MAX + 1));
|
|
77
|
+
this._server = null;
|
|
78
|
+
this._port = null;
|
|
79
|
+
this._callbackPromise = null;
|
|
80
|
+
this._resolveCallback = null;
|
|
81
|
+
this._rejectCallback = null;
|
|
82
|
+
this._stopped = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @returns {{port: number, redirectUri: string}} */
|
|
86
|
+
async start() {
|
|
87
|
+
let lastErr;
|
|
88
|
+
for (let attempt = 0; attempt <= this._bindRetries; attempt++) {
|
|
89
|
+
const port = this._portFn(PORT_MIN, PORT_MAX);
|
|
90
|
+
try {
|
|
91
|
+
await this._listen(port);
|
|
92
|
+
this._port = port;
|
|
93
|
+
return { port, redirectUri: this.redirectUri };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
lastErr = err;
|
|
96
|
+
if (err && err.code !== 'EADDRINUSE') break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
throw new AuthError(
|
|
100
|
+
`Loopback server failed to bind on a free port after ${this._bindRetries + 1} attempts`,
|
|
101
|
+
{ code: 'loopback_bind_failed', cause: lastErr }
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get redirectUri() {
|
|
106
|
+
if (this._port == null) throw new Error('LoopbackServer not started');
|
|
107
|
+
return `http://127.0.0.1:${this._port}${this._callbackPath}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Wait for the OAuth provider to redirect the operator's browser to our
|
|
112
|
+
* callback URL. Resolves with `{ code, state }` once a valid callback
|
|
113
|
+
* arrives, or rejects with a typed error.
|
|
114
|
+
*
|
|
115
|
+
* @param {object} [opts]
|
|
116
|
+
* @param {number} [opts.timeoutMs]
|
|
117
|
+
* @returns {Promise<{code: string, state: string}>}
|
|
118
|
+
*/
|
|
119
|
+
waitForCallback(opts = {}) {
|
|
120
|
+
if (this._callbackPromise) return this._callbackPromise;
|
|
121
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
122
|
+
this._callbackPromise = new Promise((resolve, reject) => {
|
|
123
|
+
let timer = null;
|
|
124
|
+
const settle = (fn, value) => {
|
|
125
|
+
if (timer) clearTimeout(timer);
|
|
126
|
+
timer = null;
|
|
127
|
+
this._resolveCallback = null;
|
|
128
|
+
this._rejectCallback = null;
|
|
129
|
+
fn(value);
|
|
130
|
+
};
|
|
131
|
+
this._resolveCallback = (value) => settle(resolve, value);
|
|
132
|
+
this._rejectCallback = (err) => settle(reject, err);
|
|
133
|
+
if (timeoutMs > 0) {
|
|
134
|
+
timer = setTimeout(() => {
|
|
135
|
+
// The settle path clears `timer` to avoid double-clear here.
|
|
136
|
+
this._reject(new AuthError(
|
|
137
|
+
`Loopback callback timed out after ${timeoutMs}ms`,
|
|
138
|
+
{ code: 'loopback_timeout', state: 'awaiting_consent' }
|
|
139
|
+
));
|
|
140
|
+
}, timeoutMs);
|
|
141
|
+
if (timer.unref) timer.unref();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
return this._callbackPromise;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async stop() {
|
|
148
|
+
this._stopped = true;
|
|
149
|
+
if (!this._server) return;
|
|
150
|
+
await new Promise((resolve) => this._server.close(() => resolve()));
|
|
151
|
+
this._server = null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
_listen(port) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const server = http.createServer((req, res) => this._onRequest(req, res));
|
|
159
|
+
server.on('error', (err) => {
|
|
160
|
+
if (this._port == null) reject(err);
|
|
161
|
+
});
|
|
162
|
+
// exclusive: true → SO_REUSEADDR=false in Node's worker model. Per
|
|
163
|
+
// Appendix H.4.5 we do not share the port with other listeners.
|
|
164
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
|
|
165
|
+
this._server = server;
|
|
166
|
+
resolve();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
_onRequest(req, res) {
|
|
172
|
+
if (this._stopped) {
|
|
173
|
+
res.statusCode = 503;
|
|
174
|
+
res.end();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
let parsed;
|
|
178
|
+
try { parsed = new URL(req.url, `http://127.0.0.1:${this._port}`); }
|
|
179
|
+
catch {
|
|
180
|
+
this._respond(res, 400, ERROR_HTML);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (parsed.pathname !== this._callbackPath) {
|
|
184
|
+
this._respond(res, 404, ERROR_HTML);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const params = parsed.searchParams;
|
|
189
|
+
const error = params.get('error');
|
|
190
|
+
const errorDescription = params.get('error_description');
|
|
191
|
+
const code = params.get('code');
|
|
192
|
+
const receivedState = params.get('state');
|
|
193
|
+
|
|
194
|
+
if (error) {
|
|
195
|
+
this._respond(res, 200, error === 'access_denied' ? DENIED_HTML : ERROR_HTML);
|
|
196
|
+
const Cls = error === 'access_denied' ? UserDeniedError : AuthError;
|
|
197
|
+
this._reject(new Cls(
|
|
198
|
+
errorDescription || `Authorization server returned error: ${error}`,
|
|
199
|
+
{ code: error, state: 'awaiting_consent' }
|
|
200
|
+
));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!code || !receivedState) {
|
|
205
|
+
this._respond(res, 400, ERROR_HTML);
|
|
206
|
+
this._reject(new AuthError(
|
|
207
|
+
'Loopback callback missing code or state',
|
|
208
|
+
{ code: 'invalid_callback', state: 'awaiting_consent' }
|
|
209
|
+
));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!safeStateEquals(this._expectedState, receivedState)) {
|
|
214
|
+
this._respond(res, 400, ERROR_HTML);
|
|
215
|
+
this._reject(new StateMismatchError(undefined, { state: 'awaiting_consent' }));
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
this._respond(res, 200, SUCCESS_HTML);
|
|
220
|
+
this._resolve({ code, state: receivedState });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_respond(res, status, html) {
|
|
224
|
+
res.statusCode = status;
|
|
225
|
+
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
226
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
227
|
+
res.end(html);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
_resolve(value) {
|
|
231
|
+
if (this._resolveCallback) {
|
|
232
|
+
const fn = this._resolveCallback;
|
|
233
|
+
this._resolveCallback = null;
|
|
234
|
+
this._rejectCallback = null;
|
|
235
|
+
fn(value);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
_reject(err) {
|
|
240
|
+
if (this._rejectCallback) {
|
|
241
|
+
const fn = this._rejectCallback;
|
|
242
|
+
this._resolveCallback = null;
|
|
243
|
+
this._rejectCallback = null;
|
|
244
|
+
fn(err);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = { LoopbackServer, PORT_MIN, PORT_MAX, DEFAULT_TIMEOUT_MS };
|
|
Binary file
|