@wickedevolutions/abilities-mcp 1.3.1 → 1.5.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/CHANGELOG.md +61 -0
- package/README.md +88 -17
- package/abilities-mcp.js +182 -113
- 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 +98 -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 +161 -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.js +248 -19
- 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 +7 -2
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('node:events');
|
|
4
|
+
|
|
5
|
+
const { STATES, TERMINAL_STATES, EVENTS } = require('./events');
|
|
6
|
+
const { discover } = require('./discovery-client');
|
|
7
|
+
const { register } = require('./dcr-client');
|
|
8
|
+
const { LoopbackServer } = require('./loopback-server');
|
|
9
|
+
const { openBrowser } = require('./browser-launcher');
|
|
10
|
+
const { generatePkce, generateState } = require('./pkce');
|
|
11
|
+
const { postForm } = require('./http-json');
|
|
12
|
+
const {
|
|
13
|
+
AuthError,
|
|
14
|
+
TokenExchangeError,
|
|
15
|
+
} = require('./errors');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* OAuthClient — event-emitting state machine for the authorization-code +
|
|
19
|
+
* PKCE flow. Defined in the design doc's "Architectural constraint: ship
|
|
20
|
+
* CLI, architect for console" section as binding for v1.0.
|
|
21
|
+
*
|
|
22
|
+
* States (binding):
|
|
23
|
+
* idle → discovering → registering → awaiting_consent → exchanging
|
|
24
|
+
* → complete | failed
|
|
25
|
+
*
|
|
26
|
+
* Events emitted (see lib/auth/events.js):
|
|
27
|
+
* 'state' every transition, payload `{ from, to, data }`
|
|
28
|
+
* 'progress' sub-step info (e.g. discovery probe results)
|
|
29
|
+
* 'complete' terminal success
|
|
30
|
+
* 'failed' terminal failure
|
|
31
|
+
* plus one event per state name for observers that prefer named handlers.
|
|
32
|
+
*
|
|
33
|
+
* Public API:
|
|
34
|
+
* const client = new OAuthClient({ siteUrl, identityProvider, scope, ... });
|
|
35
|
+
* client.on('state', ({ from, to, data }) => { ... });
|
|
36
|
+
* const result = await client.run();
|
|
37
|
+
* // result = { tokens, scopes, clientId, asMetadata, prMetadata, capabilityPin }
|
|
38
|
+
*
|
|
39
|
+
* Constraints (from issue #12 and the sprint plan):
|
|
40
|
+
* - No console.* / process.* writes anywhere in this module.
|
|
41
|
+
* - Public methods map 1:1 to future CLI subcommands.
|
|
42
|
+
* - The state machine is the entire flow — no one-shot helpers that bypass
|
|
43
|
+
* it for "convenience."
|
|
44
|
+
*
|
|
45
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
46
|
+
* @license GPL-2.0-or-later
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
const DEFAULT_SCOPE = 'abilities:read abilities:write';
|
|
50
|
+
const DEFAULT_LOOPBACK_TIMEOUT_MS = 5 * 60_000;
|
|
51
|
+
|
|
52
|
+
class OAuthClient extends EventEmitter {
|
|
53
|
+
/**
|
|
54
|
+
* @param {object} args
|
|
55
|
+
* @param {string} args.siteUrl Canonical site URL
|
|
56
|
+
* @param {string} args.clientName e.g. "<user>'s Operator (host.local)"
|
|
57
|
+
* @param {string} args.softwareVersion Bridge package version
|
|
58
|
+
* @param {string|string[]} [args.scope] Default 'abilities:read abilities:write'
|
|
59
|
+
* @param {string} [args.resource] RFC 8707 resource indicator. If omitted, derived from prMetadata.
|
|
60
|
+
* @param {object} args.identityProvider BridgeIdentityProvider
|
|
61
|
+
* @param {object} [args.capabilityPin] { firstSeenAt: ISO } — pass when site is OAuth-pinned
|
|
62
|
+
* @param {boolean} [args.allowInsecure]
|
|
63
|
+
* @param {object} [args.loopback] LoopbackServer override (DI for tests)
|
|
64
|
+
* @param {object} [args.deps] DI of low-level helpers (tests)
|
|
65
|
+
* @param {number} [args.loopbackTimeoutMs]
|
|
66
|
+
* @param {number} [args.timeoutMs] HTTP timeout for non-loopback calls
|
|
67
|
+
*/
|
|
68
|
+
constructor(args) {
|
|
69
|
+
super();
|
|
70
|
+
if (!args || typeof args.siteUrl !== 'string') {
|
|
71
|
+
throw new Error('OAuthClient requires siteUrl');
|
|
72
|
+
}
|
|
73
|
+
if (typeof args.clientName !== 'string' || !args.clientName) {
|
|
74
|
+
throw new Error('OAuthClient requires clientName');
|
|
75
|
+
}
|
|
76
|
+
if (typeof args.softwareVersion !== 'string' || !args.softwareVersion) {
|
|
77
|
+
throw new Error('OAuthClient requires softwareVersion');
|
|
78
|
+
}
|
|
79
|
+
if (!args.identityProvider || typeof args.identityProvider.getClientId !== 'function') {
|
|
80
|
+
throw new Error('OAuthClient requires identityProvider with BridgeIdentityProvider shape');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
this.siteUrl = args.siteUrl;
|
|
84
|
+
this.clientName = args.clientName;
|
|
85
|
+
this.softwareVersion = args.softwareVersion;
|
|
86
|
+
this.scope = args.scope || DEFAULT_SCOPE;
|
|
87
|
+
this.resource = args.resource || null;
|
|
88
|
+
this.identityProvider = args.identityProvider;
|
|
89
|
+
this.capabilityPin = args.capabilityPin || null;
|
|
90
|
+
this.allowInsecure = !!args.allowInsecure;
|
|
91
|
+
this.loopbackTimeoutMs = args.loopbackTimeoutMs || DEFAULT_LOOPBACK_TIMEOUT_MS;
|
|
92
|
+
this.timeoutMs = args.timeoutMs;
|
|
93
|
+
|
|
94
|
+
// Dependency injection seams — production callers don't pass these.
|
|
95
|
+
const deps = args.deps || {};
|
|
96
|
+
this._discover = deps.discover || discover;
|
|
97
|
+
this._register = deps.register || register;
|
|
98
|
+
this._postForm = deps.postForm || postForm;
|
|
99
|
+
this._openBrowser = deps.openBrowser || openBrowser;
|
|
100
|
+
this._LoopbackServer = deps.LoopbackServer || LoopbackServer;
|
|
101
|
+
this._loopbackOverride = args.loopback || null;
|
|
102
|
+
|
|
103
|
+
this.state = STATES.IDLE;
|
|
104
|
+
this._lastError = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------
|
|
108
|
+
// State helpers
|
|
109
|
+
// ---------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
_transition(to, data) {
|
|
112
|
+
const from = this.state;
|
|
113
|
+
this.state = to;
|
|
114
|
+
const payload = { from, to, data: data || null };
|
|
115
|
+
this.emit(EVENTS.STATE, payload);
|
|
116
|
+
this.emit(to, payload);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
_progress(message, data) {
|
|
120
|
+
this.emit(EVENTS.PROGRESS, { state: this.state, message, data: data || null });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
_fail(err) {
|
|
124
|
+
this._lastError = err;
|
|
125
|
+
this._transition(STATES.FAILED, { error: err });
|
|
126
|
+
this.emit(EVENTS.FAILED, { error: err, state: STATES.FAILED });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------
|
|
130
|
+
// Public API
|
|
131
|
+
// ---------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Run the full state machine. Resolves with the result on success, throws
|
|
135
|
+
* on failure (the 'failed' event is emitted first so subscribers see the
|
|
136
|
+
* terminal state before the rejection propagates).
|
|
137
|
+
*
|
|
138
|
+
* @returns {Promise<{
|
|
139
|
+
* tokens: object,
|
|
140
|
+
* scopes: string[],
|
|
141
|
+
* clientId: string,
|
|
142
|
+
* asMetadata: object,
|
|
143
|
+
* prMetadata: object|null,
|
|
144
|
+
* capabilityPin: {firstSeenAt: string, lastConfirmedAt: string},
|
|
145
|
+
* resource: string|null,
|
|
146
|
+
* }>}
|
|
147
|
+
*/
|
|
148
|
+
async run() {
|
|
149
|
+
if (TERMINAL_STATES.has(this.state)) {
|
|
150
|
+
throw new Error(`OAuthClient.run() called on terminal state: ${this.state}`);
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
const discovered = await this._runDiscover();
|
|
154
|
+
const registered = await this._runRegister(discovered);
|
|
155
|
+
const consent = await this._runAwaitConsent(discovered, registered);
|
|
156
|
+
const exchanged = await this._runExchange(discovered, registered, consent);
|
|
157
|
+
const result = this._buildResult(discovered, registered, exchanged);
|
|
158
|
+
this._transition(STATES.COMPLETE, result);
|
|
159
|
+
this.emit(EVENTS.COMPLETE, result);
|
|
160
|
+
return result;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
const wrapped = err instanceof AuthError ? err : new AuthError(err.message, { cause: err, state: this.state });
|
|
163
|
+
this._fail(wrapped);
|
|
164
|
+
throw wrapped;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------
|
|
169
|
+
// State implementations
|
|
170
|
+
// ---------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
async _runDiscover() {
|
|
173
|
+
this._transition(STATES.DISCOVERING, { siteUrl: this.siteUrl });
|
|
174
|
+
const pinnedFirstSeenAt = this.capabilityPin && this.capabilityPin.firstSeenAt
|
|
175
|
+
? this.capabilityPin.firstSeenAt
|
|
176
|
+
: null;
|
|
177
|
+
const discovered = await this._discover(this.siteUrl, {
|
|
178
|
+
pinned: !!this.capabilityPin,
|
|
179
|
+
pinnedFirstSeenAt,
|
|
180
|
+
allowInsecure: this.allowInsecure,
|
|
181
|
+
timeoutMs: this.timeoutMs,
|
|
182
|
+
});
|
|
183
|
+
this._progress('discovery_succeeded', {
|
|
184
|
+
asMetadataUrl: discovered.asMetadataUrl,
|
|
185
|
+
probeResults: discovered.probeResults,
|
|
186
|
+
});
|
|
187
|
+
return discovered;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async _runRegister(discovered) {
|
|
191
|
+
this._transition(STATES.REGISTERING, {
|
|
192
|
+
registrationEndpoint: discovered.asMetadata.registration_endpoint,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// H-8: never reuse a persisted client_id from this code path.
|
|
196
|
+
//
|
|
197
|
+
// The previous early-return looked up identityProvider.getClientId() and
|
|
198
|
+
// returned the persisted client_id without checking whether the registered
|
|
199
|
+
// loopback redirect_uri's port matched the live loopback port. v1.0 was
|
|
200
|
+
// safe by accident because FreshEachTimeIdentityProvider.getClientId()
|
|
201
|
+
// always returns null. v1.1 (Option C, persistent client_id per Appendix
|
|
202
|
+
// H.3.2) would have surfaced the bug: a stale persisted client_id whose
|
|
203
|
+
// server-side registered redirect_uri pinned a port no longer in use
|
|
204
|
+
// would cause the next /oauth/authorize to fail redirect_uri_valid().
|
|
205
|
+
//
|
|
206
|
+
// Defensive guard: clear any persisted client_id before DCR. v1.0
|
|
207
|
+
// FreshEachTime is a no-op; v1.1+ implementations of clearClientId() get
|
|
208
|
+
// a chance to remove a stale entry before we mint a new one. Reuse paths
|
|
209
|
+
// that *already know* their loopback port matches the registration must
|
|
210
|
+
// not flow through _runRegister — they need their own short-circuit at
|
|
211
|
+
// the OAuthClient.run() level.
|
|
212
|
+
await this.identityProvider.clearClientId(this.siteUrl);
|
|
213
|
+
|
|
214
|
+
if (!discovered.asMetadata.registration_endpoint) {
|
|
215
|
+
throw new AuthError('Authorization server metadata missing registration_endpoint', {
|
|
216
|
+
code: 'no_registration_endpoint', state: STATES.REGISTERING,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// We need a redirect_uri to register. Spin up the loopback server now
|
|
221
|
+
// so its port is known to DCR (even though we don't await callbacks
|
|
222
|
+
// until later).
|
|
223
|
+
const expectedState = generateState();
|
|
224
|
+
const loopback = this._loopbackOverride || new this._LoopbackServer({ expectedState });
|
|
225
|
+
await loopback.start();
|
|
226
|
+
this._loopback = loopback;
|
|
227
|
+
this._expectedState = expectedState;
|
|
228
|
+
|
|
229
|
+
let registration;
|
|
230
|
+
try {
|
|
231
|
+
registration = await this._register({
|
|
232
|
+
registrationEndpoint: discovered.asMetadata.registration_endpoint,
|
|
233
|
+
clientName: this.clientName,
|
|
234
|
+
redirectUri: loopback.redirectUri,
|
|
235
|
+
scope: this.scope,
|
|
236
|
+
softwareVersion: this.softwareVersion,
|
|
237
|
+
allowInsecure: this.allowInsecure,
|
|
238
|
+
timeoutMs: this.timeoutMs,
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
await loopback.stop().catch(() => {});
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
await this.identityProvider.persistClientId(this.siteUrl, registration.clientId);
|
|
246
|
+
this._progress('registered', { clientId: registration.clientId });
|
|
247
|
+
return registration;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async _runAwaitConsent(discovered, registered) {
|
|
251
|
+
if (!this._loopback) {
|
|
252
|
+
throw new AuthError('Internal error: loopback server not started', { code: 'internal_error' });
|
|
253
|
+
}
|
|
254
|
+
const pkce = generatePkce();
|
|
255
|
+
this._pkce = pkce;
|
|
256
|
+
|
|
257
|
+
const resource = this.resource
|
|
258
|
+
|| (discovered.prMetadata && discovered.prMetadata.resource)
|
|
259
|
+
|| null;
|
|
260
|
+
|
|
261
|
+
const authorizeUrl = new URL(discovered.asMetadata.authorization_endpoint);
|
|
262
|
+
authorizeUrl.searchParams.set('response_type', 'code');
|
|
263
|
+
authorizeUrl.searchParams.set('client_id', registered.clientId);
|
|
264
|
+
authorizeUrl.searchParams.set('redirect_uri', this._loopback.redirectUri);
|
|
265
|
+
authorizeUrl.searchParams.set('scope', Array.isArray(this.scope) ? this.scope.join(' ') : this.scope);
|
|
266
|
+
authorizeUrl.searchParams.set('state', this._expectedState);
|
|
267
|
+
authorizeUrl.searchParams.set('code_challenge', pkce.challenge);
|
|
268
|
+
authorizeUrl.searchParams.set('code_challenge_method', pkce.method);
|
|
269
|
+
if (resource) authorizeUrl.searchParams.set('resource', resource);
|
|
270
|
+
|
|
271
|
+
this._transition(STATES.AWAITING_CONSENT, {
|
|
272
|
+
authorizeUrl: authorizeUrl.toString(),
|
|
273
|
+
redirectUri: this._loopback.redirectUri,
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Launch browser as a side-effect and wait for the loopback callback.
|
|
277
|
+
// We swallow browser-launch errors because the caller may already have
|
|
278
|
+
// displayed the URL for manual paste (e.g. headless SSH session).
|
|
279
|
+
this._openBrowser(authorizeUrl.toString()).catch((err) => {
|
|
280
|
+
this._progress('browser_launch_failed', { error: err.message });
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
let callback;
|
|
284
|
+
try {
|
|
285
|
+
callback = await this._loopback.waitForCallback({ timeoutMs: this.loopbackTimeoutMs });
|
|
286
|
+
} finally {
|
|
287
|
+
await this._loopback.stop().catch(() => {});
|
|
288
|
+
}
|
|
289
|
+
this._progress('callback_received', { codePresent: !!callback.code });
|
|
290
|
+
return { ...callback, resource };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async _runExchange(discovered, registered, consent) {
|
|
294
|
+
this._transition(STATES.EXCHANGING, { tokenEndpoint: discovered.asMetadata.token_endpoint });
|
|
295
|
+
|
|
296
|
+
const params = {
|
|
297
|
+
grant_type: 'authorization_code',
|
|
298
|
+
code: consent.code,
|
|
299
|
+
redirect_uri: this._loopback ? this._loopback.redirectUri : undefined,
|
|
300
|
+
client_id: registered.clientId,
|
|
301
|
+
code_verifier: this._pkce.verifier,
|
|
302
|
+
};
|
|
303
|
+
if (consent.resource) params.resource = consent.resource;
|
|
304
|
+
// Strip undefined.
|
|
305
|
+
for (const k of Object.keys(params)) if (params[k] === undefined) delete params[k];
|
|
306
|
+
|
|
307
|
+
let res;
|
|
308
|
+
try {
|
|
309
|
+
res = await this._postForm(discovered.asMetadata.token_endpoint, params, {
|
|
310
|
+
allowInsecure: this.allowInsecure,
|
|
311
|
+
timeoutMs: this.timeoutMs,
|
|
312
|
+
});
|
|
313
|
+
} catch (err) {
|
|
314
|
+
throw new TokenExchangeError(`Token exchange request failed: ${err.message}`, {
|
|
315
|
+
cause: err, state: STATES.EXCHANGING,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
if (res.statusCode < 200 || res.statusCode >= 300 || !res.json) {
|
|
319
|
+
throw new TokenExchangeError(`Token endpoint returned ${res.statusCode}`, {
|
|
320
|
+
cause: { statusCode: res.statusCode, body: res.body, json: res.json },
|
|
321
|
+
state: STATES.EXCHANGING,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
if (typeof res.json.access_token !== 'string') {
|
|
325
|
+
throw new TokenExchangeError('Token response missing access_token', {
|
|
326
|
+
cause: res.json, state: STATES.EXCHANGING,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return res.json;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
_buildResult(discovered, registered, tokens) {
|
|
333
|
+
const now = new Date().toISOString();
|
|
334
|
+
const grantedScope = typeof tokens.scope === 'string'
|
|
335
|
+
? tokens.scope.split(/\s+/).filter(Boolean)
|
|
336
|
+
: (Array.isArray(this.scope) ? this.scope : String(this.scope).split(/\s+/).filter(Boolean));
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
tokens,
|
|
340
|
+
scopes: grantedScope,
|
|
341
|
+
clientId: registered.clientId,
|
|
342
|
+
asMetadata: discovered.asMetadata,
|
|
343
|
+
asMetadataUrl: discovered.asMetadataUrl,
|
|
344
|
+
prMetadata: discovered.prMetadata,
|
|
345
|
+
prMetadataUrl: discovered.prMetadataUrl,
|
|
346
|
+
capabilityPin: {
|
|
347
|
+
firstSeenAt: this.capabilityPin && this.capabilityPin.firstSeenAt
|
|
348
|
+
? this.capabilityPin.firstSeenAt
|
|
349
|
+
: now,
|
|
350
|
+
lastConfirmedAt: now,
|
|
351
|
+
},
|
|
352
|
+
resource: this.resource || (discovered.prMetadata && discovered.prMetadata.resource) || null,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = { OAuthClient, DEFAULT_SCOPE };
|
package/lib/auth/pkce.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { randomBytes, createHash, timingSafeEqual: cryptoTimingSafeEqual } = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* PKCE (RFC 7636) and CSRF state primitives.
|
|
7
|
+
*
|
|
8
|
+
* Per design doc:
|
|
9
|
+
* - PKCE method MUST be S256 (Appendix D.1, H.3.6, discovery metadata).
|
|
10
|
+
* - state = bin2hex(random_bytes(16)) → 128 bits of entropy (Appendix H.3.5).
|
|
11
|
+
* - State comparison on loopback callback uses timingSafeEqual (H.3.5, H.4.5).
|
|
12
|
+
*
|
|
13
|
+
* The verifier byte length is implementer's choice within RFC 7636 (43–128
|
|
14
|
+
* chars after base64url). We use 32 bytes → 43-char base64url string, the
|
|
15
|
+
* common choice.
|
|
16
|
+
*
|
|
17
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
18
|
+
* @license GPL-2.0-or-later
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const VERIFIER_BYTES = 32; // → 43-char base64url verifier (RFC 7636 §4.1)
|
|
22
|
+
const STATE_BYTES = 16; // → 32-char hex state (128 bits, per H.3.5)
|
|
23
|
+
const STATE_MAX_LENGTH = 256; // server enforces; mirrored here for safety
|
|
24
|
+
|
|
25
|
+
function base64url(buf) {
|
|
26
|
+
return Buffer.from(buf).toString('base64')
|
|
27
|
+
.replace(/\+/g, '-')
|
|
28
|
+
.replace(/\//g, '_')
|
|
29
|
+
.replace(/=+$/g, '');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Generate a fresh PKCE pair.
|
|
34
|
+
* @returns {{verifier: string, challenge: string, method: 'S256'}}
|
|
35
|
+
*/
|
|
36
|
+
function generatePkce() {
|
|
37
|
+
const verifierBytes = randomBytes(VERIFIER_BYTES);
|
|
38
|
+
const verifier = base64url(verifierBytes);
|
|
39
|
+
const challenge = base64url(createHash('sha256').update(verifier).digest());
|
|
40
|
+
return { verifier, challenge, method: 'S256' };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Compute the S256 challenge for a given verifier — useful for tests.
|
|
45
|
+
* @param {string} verifier
|
|
46
|
+
* @returns {string}
|
|
47
|
+
*/
|
|
48
|
+
function challengeFromVerifier(verifier) {
|
|
49
|
+
return base64url(createHash('sha256').update(verifier).digest());
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Generate a fresh CSRF state token. 128 bits of entropy, hex-encoded.
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function generateState() {
|
|
57
|
+
return randomBytes(STATE_BYTES).toString('hex');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Constant-time equality check on two strings. Used to compare the loopback
|
|
62
|
+
* callback's `state` query param against the bridge-generated value (H.3.5,
|
|
63
|
+
* H.4.5). Returns false on any error including length mismatch.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} expected
|
|
66
|
+
* @param {string} received
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function safeStateEquals(expected, received) {
|
|
70
|
+
if (typeof expected !== 'string' || typeof received !== 'string') return false;
|
|
71
|
+
if (expected.length === 0 || received.length === 0) return false;
|
|
72
|
+
if (expected.length > STATE_MAX_LENGTH || received.length > STATE_MAX_LENGTH) return false;
|
|
73
|
+
if (expected.length !== received.length) {
|
|
74
|
+
// Avoid throwing in timingSafeEqual on length mismatch — but burn a few
|
|
75
|
+
// CPU cycles so timing doesn't trivially leak the length difference.
|
|
76
|
+
const filler = Buffer.alloc(expected.length, 0);
|
|
77
|
+
try { cryptoTimingSafeEqual(filler, filler); } catch { /* ignore */ }
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const a = Buffer.from(expected, 'utf8');
|
|
81
|
+
const b = Buffer.from(received, 'utf8');
|
|
82
|
+
return cryptoTimingSafeEqual(a, b);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = {
|
|
86
|
+
generatePkce,
|
|
87
|
+
challengeFromVerifier,
|
|
88
|
+
generateState,
|
|
89
|
+
safeStateEquals,
|
|
90
|
+
VERIFIER_BYTES,
|
|
91
|
+
STATE_BYTES,
|
|
92
|
+
STATE_MAX_LENGTH,
|
|
93
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AUTH_STATUS } = require('./events');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* wp-sites.json schema v2 — defined in design doc Appendix F.5 with
|
|
7
|
+
* amendments from H.2.3 (oauth_capability_pinned).
|
|
8
|
+
*
|
|
9
|
+
* The bridge supports both `oauth` and `apppassword` per site. OAuth always
|
|
10
|
+
* takes precedence; `apppassword_fallback` fires only when OAuth discovery
|
|
11
|
+
* returns 404 (no pin) — silent fallback for reverse compatibility. A pinned
|
|
12
|
+
* site that loses OAuth fails loud (H.2.3 — handled in discovery-client).
|
|
13
|
+
*
|
|
14
|
+
* This module ships a minimal validator. The bridge's existing config.js
|
|
15
|
+
* does richer transport-specific validation; v2 adds:
|
|
16
|
+
* - schema_version === 2 sentinel
|
|
17
|
+
* - auth.method ∈ {'oauth','apppassword'} per site
|
|
18
|
+
* - auth_status ∈ {'active','expired','revoked','pending-reauth'}
|
|
19
|
+
* - oauth_capability_pinned shape (when present)
|
|
20
|
+
*
|
|
21
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
22
|
+
* @license GPL-2.0-or-later
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const SCHEMA_VERSION = 2;
|
|
26
|
+
const AUTH_METHODS = Object.freeze(['oauth', 'apppassword']);
|
|
27
|
+
const VALID_AUTH_STATUS = Object.freeze(Object.values(AUTH_STATUS));
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {object} config Parsed wp-sites.json
|
|
31
|
+
* @returns {{ok: true} | {ok: false, errors: string[]}}
|
|
32
|
+
*/
|
|
33
|
+
function validate(config) {
|
|
34
|
+
const errors = [];
|
|
35
|
+
if (!config || typeof config !== 'object') {
|
|
36
|
+
return { ok: false, errors: ['config is not an object'] };
|
|
37
|
+
}
|
|
38
|
+
if (config.schema_version !== SCHEMA_VERSION) {
|
|
39
|
+
errors.push(`schema_version must be ${SCHEMA_VERSION}, got ${JSON.stringify(config.schema_version)}`);
|
|
40
|
+
}
|
|
41
|
+
if (!config.sites || typeof config.sites !== 'object') {
|
|
42
|
+
return { ok: false, errors: errors.concat(['sites object is missing']) };
|
|
43
|
+
}
|
|
44
|
+
for (const [siteId, site] of Object.entries(config.sites)) {
|
|
45
|
+
const prefix = `sites.${siteId}`;
|
|
46
|
+
if (!site || typeof site !== 'object') {
|
|
47
|
+
errors.push(`${prefix} is not an object`);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (typeof site.url !== 'string' || site.url.length === 0) {
|
|
51
|
+
errors.push(`${prefix}.url is missing`);
|
|
52
|
+
}
|
|
53
|
+
if (!site.auth || typeof site.auth !== 'object') {
|
|
54
|
+
errors.push(`${prefix}.auth is missing`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!AUTH_METHODS.includes(site.auth.method)) {
|
|
58
|
+
errors.push(`${prefix}.auth.method must be one of ${AUTH_METHODS.join(', ')}`);
|
|
59
|
+
}
|
|
60
|
+
if (site.auth_status && !VALID_AUTH_STATUS.includes(site.auth_status)) {
|
|
61
|
+
errors.push(`${prefix}.auth_status must be one of ${VALID_AUTH_STATUS.join(', ')}`);
|
|
62
|
+
}
|
|
63
|
+
if (site.auth.method === 'oauth') {
|
|
64
|
+
for (const k of ['client_id', 'access_token_ref', 'refresh_token_ref']) {
|
|
65
|
+
if (typeof site.auth[k] !== 'string') {
|
|
66
|
+
errors.push(`${prefix}.auth.${k} is required for OAuth sites`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} else if (site.auth.method === 'apppassword') {
|
|
70
|
+
if (typeof site.auth.username !== 'string') {
|
|
71
|
+
errors.push(`${prefix}.auth.username is required for App Password sites`);
|
|
72
|
+
}
|
|
73
|
+
if (typeof site.auth.password_ref !== 'string') {
|
|
74
|
+
errors.push(`${prefix}.auth.password_ref is required for App Password sites`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (site.oauth_capability_pinned) {
|
|
78
|
+
const pin = site.oauth_capability_pinned;
|
|
79
|
+
if (typeof pin !== 'object'
|
|
80
|
+
|| typeof pin.first_seen_at !== 'string'
|
|
81
|
+
|| typeof pin.last_confirmed_at !== 'string') {
|
|
82
|
+
errors.push(`${prefix}.oauth_capability_pinned must be { first_seen_at, last_confirmed_at }`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build a minimal v2 config skeleton.
|
|
91
|
+
* @param {object} [opts]
|
|
92
|
+
* @param {string} [opts.defaultSite]
|
|
93
|
+
* @returns {object}
|
|
94
|
+
*/
|
|
95
|
+
function emptyConfig(opts = {}) {
|
|
96
|
+
return {
|
|
97
|
+
$schema: 'https://wickedevolutions.com/schemas/abilities-mcp/wp-sites/v2.json',
|
|
98
|
+
schema_version: SCHEMA_VERSION,
|
|
99
|
+
defaultSite: opts.defaultSite,
|
|
100
|
+
sites: {},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
SCHEMA_VERSION,
|
|
106
|
+
AUTH_METHODS,
|
|
107
|
+
VALID_AUTH_STATUS,
|
|
108
|
+
validate,
|
|
109
|
+
emptyConfig,
|
|
110
|
+
};
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SecretStore — interface for persisting tokens, refresh tokens, App Passwords,
|
|
5
|
+
* and (future, see Appendix H.3.2) bridge identity material.
|
|
6
|
+
*
|
|
7
|
+
* The interface is a JSDoc typedef — there is no abstract base class. Any
|
|
8
|
+
* object implementing the four methods below counts as a SecretStore.
|
|
9
|
+
*
|
|
10
|
+
* @typedef {object} SecretStore
|
|
11
|
+
* @property {(service: string, account: string) => Promise<string|null>} get
|
|
12
|
+
* @property {(service: string, account: string, secret: string) => Promise<void>} set
|
|
13
|
+
* @property {(service: string, account: string) => Promise<boolean>} delete
|
|
14
|
+
* @property {(service: string) => Promise<Array<{account: string, password: string}>>} findAll
|
|
15
|
+
*
|
|
16
|
+
* Service / account naming convention:
|
|
17
|
+
* service = 'abilities-mcp'
|
|
18
|
+
* account = '<siteId>/<kind>' where kind is 'access' | 'refresh' | 'apppassword' | 'apppassword-legacy' | 'client_id'
|
|
19
|
+
*
|
|
20
|
+
* Keychain references in wp-sites.json take the form:
|
|
21
|
+
* keychain://<service>/<account>
|
|
22
|
+
*
|
|
23
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
24
|
+
* @license GPL-2.0-or-later
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const KEYCHAIN_REF_SCHEME = 'keychain://';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Build a `keychain://service/account` reference for storage in wp-sites.json.
|
|
31
|
+
* @param {string} service
|
|
32
|
+
* @param {string} account
|
|
33
|
+
* @returns {string}
|
|
34
|
+
*/
|
|
35
|
+
function makeRef(service, account) {
|
|
36
|
+
if (!service || !account) {
|
|
37
|
+
throw new Error('makeRef requires both service and account');
|
|
38
|
+
}
|
|
39
|
+
return `${KEYCHAIN_REF_SCHEME}${service}/${account}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Parse a keychain reference back into service + account.
|
|
44
|
+
* @param {string} ref
|
|
45
|
+
* @returns {{service: string, account: string}}
|
|
46
|
+
*/
|
|
47
|
+
function parseRef(ref) {
|
|
48
|
+
if (typeof ref !== 'string' || !ref.startsWith(KEYCHAIN_REF_SCHEME)) {
|
|
49
|
+
throw new Error(`Not a keychain reference: ${ref}`);
|
|
50
|
+
}
|
|
51
|
+
const remainder = ref.slice(KEYCHAIN_REF_SCHEME.length);
|
|
52
|
+
const slashIdx = remainder.indexOf('/');
|
|
53
|
+
if (slashIdx <= 0) {
|
|
54
|
+
throw new Error(`Malformed keychain reference (expected service/account): ${ref}`);
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
service: remainder.slice(0, slashIdx),
|
|
58
|
+
account: remainder.slice(slashIdx + 1),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolve a keychain reference through a SecretStore. Returns the secret value
|
|
64
|
+
* or throws if not found.
|
|
65
|
+
* @param {SecretStore} store
|
|
66
|
+
* @param {string} ref
|
|
67
|
+
* @returns {Promise<string>}
|
|
68
|
+
*/
|
|
69
|
+
async function resolveRef(store, ref) {
|
|
70
|
+
const { service, account } = parseRef(ref);
|
|
71
|
+
const value = await store.get(service, account);
|
|
72
|
+
if (value === null || value === undefined) {
|
|
73
|
+
throw new Error(`Keychain reference not found: ${ref}`);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { KEYCHAIN_REF_SCHEME, makeRef, parseRef, resolveRef };
|