@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +191 -114
  4. package/lib/auth/bridge-identity-provider.js +34 -0
  5. package/lib/auth/browser-launcher.js +67 -0
  6. package/lib/auth/config-migration.js +322 -0
  7. package/lib/auth/dcr-client.js +123 -0
  8. package/lib/auth/discovery-client.js +273 -0
  9. package/lib/auth/errors.js +114 -0
  10. package/lib/auth/events.js +55 -0
  11. package/lib/auth/fresh-each-time-identity.js +101 -0
  12. package/lib/auth/http-json.js +151 -0
  13. package/lib/auth/index.js +88 -0
  14. package/lib/auth/keychain-secret-store.js +265 -0
  15. package/lib/auth/loopback-server.js +249 -0
  16. package/lib/auth/memory-secret-store.js +0 -0
  17. package/lib/auth/oauth-client.js +357 -0
  18. package/lib/auth/pkce.js +93 -0
  19. package/lib/auth/schema-v2.js +110 -0
  20. package/lib/auth/secret-store.js +78 -0
  21. package/lib/auth/token-manager.js +378 -0
  22. package/lib/cli/commands/add-site.js +226 -0
  23. package/lib/cli/commands/force-downgrade.js +93 -0
  24. package/lib/cli/commands/list-sites.js +93 -0
  25. package/lib/cli/commands/reauth.js +108 -0
  26. package/lib/cli/commands/revoke.js +127 -0
  27. package/lib/cli/commands/self-check.js +158 -0
  28. package/lib/cli/commands/test.js +174 -0
  29. package/lib/cli/commands/upgrade-auth.js +259 -0
  30. package/lib/cli/config-store.js +328 -0
  31. package/lib/cli/context.js +102 -0
  32. package/lib/cli/errors.js +227 -0
  33. package/lib/cli/index.js +173 -0
  34. package/lib/cli/output.js +175 -0
  35. package/lib/cli/parse-args.js +80 -0
  36. package/lib/config-source-line.js +85 -0
  37. package/lib/config.js +282 -22
  38. package/lib/connection-pool.js +214 -11
  39. package/lib/router.js +29 -11
  40. package/lib/transports/oauth-http-transport.js +601 -0
  41. 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