@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,273 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('node:https');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const { URL } = require('node:url');
|
|
6
|
+
|
|
7
|
+
const { DiscoveryError, CapabilityPinningError } = require('./errors');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Discovery client for OAuth 2.1 .well-known endpoints.
|
|
11
|
+
*
|
|
12
|
+
* Per design doc:
|
|
13
|
+
* - L1 (Appendix D.2): MCP TypeScript SDK probes three well-known paths.
|
|
14
|
+
* We try them in this order until one returns 200 with valid metadata:
|
|
15
|
+
* 1. {origin}/.well-known/oauth-authorization-server{path}
|
|
16
|
+
* 2. {origin}/.well-known/openid-configuration{path}
|
|
17
|
+
* 3. {origin}{path}/.well-known/openid-configuration
|
|
18
|
+
* The protected-resource metadata lives at {origin}/.well-known/oauth-protected-resource.
|
|
19
|
+
* - HTTPS-only on .well-known paths (Appendix H.2.3). The discovery client
|
|
20
|
+
* refuses HTTP URLs unless the caller passes `allowInsecure: true` for
|
|
21
|
+
* localhost development.
|
|
22
|
+
* - No redirect-following. A 3xx on a .well-known path is treated as a
|
|
23
|
+
* non-discovery (move to next candidate). This mitigates Location-header
|
|
24
|
+
* injection attacks (H.2.3).
|
|
25
|
+
* - HTTP timeout: 30s read/write (H.2.1 mandate is for token-manager but
|
|
26
|
+
* we apply the same ceiling here for parity).
|
|
27
|
+
*
|
|
28
|
+
* Capability pinning (Appendix H.2.3):
|
|
29
|
+
* - On first successful discovery, callers should write
|
|
30
|
+
* `oauth_capability_pinned.first_seen_at` to site config.
|
|
31
|
+
* - On every subsequent connection, refresh `last_confirmed_at`.
|
|
32
|
+
* - If pinned AND discovery returns 404, throw CapabilityPinningError —
|
|
33
|
+
* do NOT silently downgrade to App Password.
|
|
34
|
+
*
|
|
35
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
36
|
+
* @license GPL-2.0-or-later
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the L1 probe order for a given site URL.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} siteUrl e.g. "https://example.com" or "https://example.com/site2"
|
|
45
|
+
* @returns {{authorizationServer: string[], protectedResource: string}}
|
|
46
|
+
*/
|
|
47
|
+
function buildProbeOrder(siteUrl) {
|
|
48
|
+
const u = new URL(siteUrl);
|
|
49
|
+
const origin = u.origin;
|
|
50
|
+
const pathPart = u.pathname.replace(/\/+$/g, '');
|
|
51
|
+
|
|
52
|
+
const authorizationServer = [
|
|
53
|
+
`${origin}/.well-known/oauth-authorization-server${pathPart}`,
|
|
54
|
+
`${origin}/.well-known/openid-configuration${pathPart}`,
|
|
55
|
+
`${origin}${pathPart}/.well-known/openid-configuration`,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
// De-dupe (when pathPart is empty the three collapse to two distinct URLs).
|
|
59
|
+
const seen = new Set();
|
|
60
|
+
const dedupedAuth = [];
|
|
61
|
+
for (const url of authorizationServer) {
|
|
62
|
+
if (!seen.has(url)) { seen.add(url); dedupedAuth.push(url); }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const protectedResource = `${origin}/.well-known/oauth-protected-resource`;
|
|
66
|
+
return { authorizationServer: dedupedAuth, protectedResource };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* GET a .well-known URL with explicit security constraints.
|
|
71
|
+
*
|
|
72
|
+
* Returns either { ok: true, json, status, url } or { ok: false, status, url }.
|
|
73
|
+
* Network errors raise.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} url
|
|
76
|
+
* @param {object} [opts]
|
|
77
|
+
* @param {number} [opts.timeoutMs]
|
|
78
|
+
* @param {boolean} [opts.allowInsecure] Localhost-only escape hatch
|
|
79
|
+
* @param {object} [opts.httpsAgent] Test injection
|
|
80
|
+
*/
|
|
81
|
+
function getWellKnown(url, opts = {}) {
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
let parsed;
|
|
84
|
+
try { parsed = new URL(url); }
|
|
85
|
+
catch (err) { reject(new DiscoveryError(`Invalid URL: ${url}`, { cause: err })); return; }
|
|
86
|
+
|
|
87
|
+
const isHttps = parsed.protocol === 'https:';
|
|
88
|
+
const isHttp = parsed.protocol === 'http:';
|
|
89
|
+
if (!isHttps && !isHttp) {
|
|
90
|
+
reject(new DiscoveryError(`Discovery URL must be http(s): ${url}`));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const isLocal = parsed.hostname === 'localhost'
|
|
94
|
+
|| parsed.hostname === '127.0.0.1'
|
|
95
|
+
|| parsed.hostname === '::1';
|
|
96
|
+
if (isHttp && !(isLocal && opts.allowInsecure)) {
|
|
97
|
+
reject(new DiscoveryError(
|
|
98
|
+
`Discovery refused: HTTPS required for ${url}. ` +
|
|
99
|
+
`Per Appendix H.2.3 the bridge does not perform OAuth discovery over plain HTTP.`
|
|
100
|
+
));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const mod = isHttps ? https : http;
|
|
105
|
+
const requestOpts = {
|
|
106
|
+
method: 'GET',
|
|
107
|
+
hostname: parsed.hostname,
|
|
108
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
109
|
+
path: parsed.pathname + parsed.search,
|
|
110
|
+
headers: { 'Accept': 'application/json' },
|
|
111
|
+
timeout: opts.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
112
|
+
};
|
|
113
|
+
if (opts.httpsAgent && isHttps) requestOpts.agent = opts.httpsAgent;
|
|
114
|
+
|
|
115
|
+
const req = mod.request(requestOpts, (res) => {
|
|
116
|
+
// Per H.2.3: do NOT follow redirects on .well-known paths.
|
|
117
|
+
if (res.statusCode >= 300 && res.statusCode < 400) {
|
|
118
|
+
res.resume();
|
|
119
|
+
resolve({ ok: false, status: res.statusCode, url, redirected: true });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const chunks = [];
|
|
123
|
+
res.on('data', (c) => chunks.push(c));
|
|
124
|
+
res.on('end', () => {
|
|
125
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
126
|
+
if (res.statusCode !== 200) {
|
|
127
|
+
resolve({ ok: false, status: res.statusCode, url, body });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
let json;
|
|
131
|
+
try { json = JSON.parse(body); }
|
|
132
|
+
catch (err) {
|
|
133
|
+
resolve({ ok: false, status: res.statusCode, url, parseError: err.message });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
resolve({ ok: true, status: res.statusCode, url, json });
|
|
137
|
+
});
|
|
138
|
+
res.on('error', (err) => reject(new DiscoveryError(`Response error from ${url}: ${err.message}`, { cause: err })));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
req.on('error', (err) => reject(new DiscoveryError(`Request failed for ${url}: ${err.message}`, { cause: err })));
|
|
142
|
+
req.on('timeout', () => req.destroy(new DiscoveryError(`Discovery timed out at ${requestOpts.timeout}ms: ${url}`)));
|
|
143
|
+
req.end();
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Validate the shape of an authorization-server metadata document.
|
|
149
|
+
* @returns {{ok: true} | {ok: false, reason: string}}
|
|
150
|
+
*/
|
|
151
|
+
function validateAsMetadata(json) {
|
|
152
|
+
if (!json || typeof json !== 'object') return { ok: false, reason: 'metadata is not an object' };
|
|
153
|
+
for (const required of ['issuer', 'authorization_endpoint', 'token_endpoint']) {
|
|
154
|
+
if (typeof json[required] !== 'string') {
|
|
155
|
+
return { ok: false, reason: `missing field: ${required}` };
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (Array.isArray(json.code_challenge_methods_supported)
|
|
159
|
+
&& !json.code_challenge_methods_supported.includes('S256')) {
|
|
160
|
+
return { ok: false, reason: 'server does not advertise S256 PKCE support' };
|
|
161
|
+
}
|
|
162
|
+
return { ok: true };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Validate the shape of a protected-resource metadata document.
|
|
167
|
+
* @returns {{ok: true} | {ok: false, reason: string}}
|
|
168
|
+
*/
|
|
169
|
+
function validatePrMetadata(json) {
|
|
170
|
+
if (!json || typeof json !== 'object') return { ok: false, reason: 'metadata is not an object' };
|
|
171
|
+
if (typeof json.resource !== 'string') return { ok: false, reason: 'missing field: resource' };
|
|
172
|
+
if (!Array.isArray(json.authorization_servers) || json.authorization_servers.length === 0) {
|
|
173
|
+
return { ok: false, reason: 'missing field: authorization_servers' };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run the full discovery flow against a site.
|
|
180
|
+
*
|
|
181
|
+
* @param {string} siteUrl
|
|
182
|
+
* @param {object} [opts]
|
|
183
|
+
* @param {boolean} [opts.pinned] true if site is already OAuth-pinned (H.2.3)
|
|
184
|
+
* @param {string} [opts.pinnedFirstSeenAt] for the failure message
|
|
185
|
+
* @param {boolean} [opts.allowInsecure]
|
|
186
|
+
* @param {number} [opts.timeoutMs]
|
|
187
|
+
* @param {object} [opts.httpsAgent] test injection
|
|
188
|
+
* @returns {Promise<{
|
|
189
|
+
* asMetadata: object,
|
|
190
|
+
* asMetadataUrl: string,
|
|
191
|
+
* prMetadata: object|null,
|
|
192
|
+
* prMetadataUrl: string|null,
|
|
193
|
+
* probeResults: Array<object>,
|
|
194
|
+
* }>}
|
|
195
|
+
*/
|
|
196
|
+
async function discover(siteUrl, opts = {}) {
|
|
197
|
+
const probes = buildProbeOrder(siteUrl);
|
|
198
|
+
const probeResults = [];
|
|
199
|
+
|
|
200
|
+
let asMetadata = null;
|
|
201
|
+
let asMetadataUrl = null;
|
|
202
|
+
|
|
203
|
+
for (const url of probes.authorizationServer) {
|
|
204
|
+
let res;
|
|
205
|
+
try {
|
|
206
|
+
res = await getWellKnown(url, opts);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
probeResults.push({ url, ok: false, error: err.message });
|
|
209
|
+
// HTTPS-required is a configuration error, not a probe miss — surface
|
|
210
|
+
// it immediately so the caller sees the real reason. Per Appendix
|
|
211
|
+
// H.2.3 the bridge does not perform OAuth discovery over plain HTTP.
|
|
212
|
+
if (/HTTPS required/i.test(err.message)) throw err;
|
|
213
|
+
// Other hard errors (network, TLS) — record and continue to next
|
|
214
|
+
// candidate path.
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
probeResults.push({ url, ok: res.ok, status: res.status, redirected: res.redirected });
|
|
218
|
+
if (res.ok) {
|
|
219
|
+
const validation = validateAsMetadata(res.json);
|
|
220
|
+
if (!validation.ok) {
|
|
221
|
+
probeResults[probeResults.length - 1].invalid = validation.reason;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
asMetadata = res.json;
|
|
225
|
+
asMetadataUrl = url;
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (!asMetadata) {
|
|
231
|
+
const all404 = probeResults.length > 0 && probeResults.every((p) => p.status === 404);
|
|
232
|
+
if (all404 && opts.pinned) {
|
|
233
|
+
throw new CapabilityPinningError(
|
|
234
|
+
`Site ${siteUrl} previously supported OAuth (first seen ${opts.pinnedFirstSeenAt || 'unknown'}) ` +
|
|
235
|
+
`but now reports no OAuth. This may indicate a network attack. ` +
|
|
236
|
+
`Refusing to silently downgrade to App Password.`,
|
|
237
|
+
{ state: 'discovering' }
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
throw new DiscoveryError(
|
|
241
|
+
`OAuth discovery failed for ${siteUrl} — none of ${probes.authorizationServer.length} probe URLs returned valid metadata.`,
|
|
242
|
+
{ state: 'discovering', cause: { probeResults } }
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Protected-resource metadata is informative but not strictly required to
|
|
247
|
+
// proceed. We try once at the canonical path.
|
|
248
|
+
let prMetadata = null;
|
|
249
|
+
let prMetadataUrl = null;
|
|
250
|
+
try {
|
|
251
|
+
const res = await getWellKnown(probes.protectedResource, opts);
|
|
252
|
+
if (res.ok) {
|
|
253
|
+
const validation = validatePrMetadata(res.json);
|
|
254
|
+
if (validation.ok) {
|
|
255
|
+
prMetadata = res.json;
|
|
256
|
+
prMetadataUrl = probes.protectedResource;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// Non-fatal — proceed without protected-resource metadata.
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return { asMetadata, asMetadataUrl, prMetadata, prMetadataUrl, probeResults };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = {
|
|
267
|
+
buildProbeOrder,
|
|
268
|
+
getWellKnown,
|
|
269
|
+
validateAsMetadata,
|
|
270
|
+
validatePrMetadata,
|
|
271
|
+
discover,
|
|
272
|
+
DEFAULT_TIMEOUT_MS,
|
|
273
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Typed error classes for the OAuth flow.
|
|
5
|
+
*
|
|
6
|
+
* Code paths in lib/auth/ MUST throw or emit these instead of writing to
|
|
7
|
+
* stderr. Callers (CLI today, GUI tomorrow) decide how to surface them.
|
|
8
|
+
*
|
|
9
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
10
|
+
* @license GPL-2.0-or-later
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class AuthError extends Error {
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} message
|
|
16
|
+
* @param {object} [opts]
|
|
17
|
+
* @param {string} [opts.code] machine-readable error code
|
|
18
|
+
* @param {string} [opts.state] state machine state at the time of failure
|
|
19
|
+
* @param {object} [opts.cause] underlying cause
|
|
20
|
+
*/
|
|
21
|
+
constructor(message, opts = {}) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = 'AuthError';
|
|
24
|
+
this.code = opts.code || 'auth_error';
|
|
25
|
+
if (opts.state) this.state = opts.state;
|
|
26
|
+
if (opts.cause) this.cause = opts.cause;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class DiscoveryError extends AuthError {
|
|
31
|
+
constructor(message, opts = {}) {
|
|
32
|
+
super(message, { code: 'discovery_failed', ...opts });
|
|
33
|
+
this.name = 'DiscoveryError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class CapabilityPinningError extends AuthError {
|
|
38
|
+
/**
|
|
39
|
+
* Raised when a previously-pinned OAuth-capable site returns 404 on
|
|
40
|
+
* discovery — per Appendix H.2.3 we fail loud rather than silently
|
|
41
|
+
* downgrading to App Password.
|
|
42
|
+
*/
|
|
43
|
+
constructor(message, opts = {}) {
|
|
44
|
+
super(message, { code: 'oauth_capability_lost', ...opts });
|
|
45
|
+
this.name = 'CapabilityPinningError';
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
class RegistrationError extends AuthError {
|
|
50
|
+
constructor(message, opts = {}) {
|
|
51
|
+
super(message, { code: 'registration_failed', ...opts });
|
|
52
|
+
this.name = 'RegistrationError';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class TokenExchangeError extends AuthError {
|
|
57
|
+
constructor(message, opts = {}) {
|
|
58
|
+
super(message, { code: 'token_exchange_failed', ...opts });
|
|
59
|
+
this.name = 'TokenExchangeError';
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class StateMismatchError extends AuthError {
|
|
64
|
+
/** CSRF protection — Appendix H.3.5. Loopback callback `state` did not
|
|
65
|
+
* match the value the bridge generated for this flow. */
|
|
66
|
+
constructor(message = 'OAuth state parameter mismatch — CSRF protection rejected callback', opts = {}) {
|
|
67
|
+
super(message, { code: 'state_mismatch', ...opts });
|
|
68
|
+
this.name = 'StateMismatchError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class UserDeniedError extends AuthError {
|
|
73
|
+
/** Operator clicked "Deny" on the consent screen. */
|
|
74
|
+
constructor(message = 'Operator denied authorization', opts = {}) {
|
|
75
|
+
super(message, { code: 'access_denied', ...opts });
|
|
76
|
+
this.name = 'UserDeniedError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class RefreshError extends AuthError {
|
|
81
|
+
/** A refresh-token exchange failed with a 4xx — token is unusable.
|
|
82
|
+
* Caller should mark `auth_status: "expired"` and prompt reauth. */
|
|
83
|
+
constructor(message, opts = {}) {
|
|
84
|
+
super(message, { code: 'refresh_failed', ...opts });
|
|
85
|
+
this.name = 'RefreshError';
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class SecretStoreError extends AuthError {
|
|
90
|
+
constructor(message, opts = {}) {
|
|
91
|
+
super(message, { code: 'secret_store_error', ...opts });
|
|
92
|
+
this.name = 'SecretStoreError';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
class MigrationError extends AuthError {
|
|
97
|
+
constructor(message, opts = {}) {
|
|
98
|
+
super(message, { code: 'migration_failed', ...opts });
|
|
99
|
+
this.name = 'MigrationError';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = {
|
|
104
|
+
AuthError,
|
|
105
|
+
DiscoveryError,
|
|
106
|
+
CapabilityPinningError,
|
|
107
|
+
RegistrationError,
|
|
108
|
+
TokenExchangeError,
|
|
109
|
+
StateMismatchError,
|
|
110
|
+
UserDeniedError,
|
|
111
|
+
RefreshError,
|
|
112
|
+
SecretStoreError,
|
|
113
|
+
MigrationError,
|
|
114
|
+
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* State machine constants and event names for the OAuth flow.
|
|
5
|
+
*
|
|
6
|
+
* Per design doc (architectural-constraint section): the bridge OAuth flow is
|
|
7
|
+
* an event-emitting state machine, not a one-shot function. The CLI subscribes
|
|
8
|
+
* and prints progress lines. A future GUI subscribes and renders progress UI.
|
|
9
|
+
* Same machine, different observers.
|
|
10
|
+
*
|
|
11
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
12
|
+
* @license GPL-2.0-or-later
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const STATES = Object.freeze({
|
|
16
|
+
IDLE: 'idle',
|
|
17
|
+
DISCOVERING: 'discovering',
|
|
18
|
+
REGISTERING: 'registering',
|
|
19
|
+
AWAITING_CONSENT: 'awaiting_consent',
|
|
20
|
+
EXCHANGING: 'exchanging',
|
|
21
|
+
COMPLETE: 'complete',
|
|
22
|
+
FAILED: 'failed',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const TERMINAL_STATES = new Set([STATES.COMPLETE, STATES.FAILED]);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Auth-status enum used in wp-sites.json v2 (Appendix F.5).
|
|
29
|
+
*/
|
|
30
|
+
const AUTH_STATUS = Object.freeze({
|
|
31
|
+
ACTIVE: 'active',
|
|
32
|
+
EXPIRED: 'expired',
|
|
33
|
+
REVOKED: 'revoked',
|
|
34
|
+
PENDING_REAUTH: 'pending-reauth',
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Events emitted on the OAuth state machine. Consumers can listen to:
|
|
39
|
+
* - 'state' : every transition, payload `{ from, to, data }`
|
|
40
|
+
* - 'progress' : sub-step info inside a state (e.g. discovery probe results)
|
|
41
|
+
* - 'error' : non-fatal warnings during the flow
|
|
42
|
+
* - 'complete' : terminal success, payload `{ tokens, scopes, ... }`
|
|
43
|
+
* - 'failed' : terminal failure, payload `{ error, state }`
|
|
44
|
+
* - one event per state name (e.g. 'discovering', 'registering', ...)
|
|
45
|
+
* with the same data payload as the 'state' event.
|
|
46
|
+
*/
|
|
47
|
+
const EVENTS = Object.freeze({
|
|
48
|
+
STATE: 'state',
|
|
49
|
+
PROGRESS: 'progress',
|
|
50
|
+
ERROR: 'error',
|
|
51
|
+
COMPLETE: 'complete',
|
|
52
|
+
FAILED: 'failed',
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
module.exports = { STATES, TERMINAL_STATES, AUTH_STATUS, EVENTS };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* FreshEachTimeIdentityProvider — v1.0 BridgeIdentityProvider.
|
|
5
|
+
*
|
|
6
|
+
* Per Appendix H.3.2 (binding amendment to F.4):
|
|
7
|
+
*
|
|
8
|
+
* - getClientId() always returns null → triggers a fresh DCR on every
|
|
9
|
+
* add-site / reauth call.
|
|
10
|
+
* - persistClientId() is intentionally a NO-OP. It does NOT write the
|
|
11
|
+
* client_id to the keychain. v1.1 (Option C) will switch this on, but
|
|
12
|
+
* turning it on safely requires:
|
|
13
|
+
* * a `keychain_schema_version: 2` companion key
|
|
14
|
+
* * defensive clearClientId() before DCR in add-site
|
|
15
|
+
* * orphan-client-id detection UX
|
|
16
|
+
* None of which exist in v1.0.
|
|
17
|
+
* - clearClientId() is also a NO-OP at the keychain layer (there is
|
|
18
|
+
* nothing persisted to clear). It accepts the call so callers can be
|
|
19
|
+
* written symmetrically against the future v1.1 implementation.
|
|
20
|
+
* - exportIdentity() returns null.
|
|
21
|
+
* - importIdentity() throws.
|
|
22
|
+
*
|
|
23
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
24
|
+
* @license GPL-2.0-or-later
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
class FreshEachTimeIdentityProvider {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} [opts]
|
|
30
|
+
* @param {import('./secret-store').SecretStore} [opts.store]
|
|
31
|
+
* Held for symmetry with v1.1+ implementations. v1.0 does not use it.
|
|
32
|
+
*/
|
|
33
|
+
constructor(opts = {}) {
|
|
34
|
+
this._store = opts.store || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* v1.0: always null. Force fresh DCR every flow.
|
|
39
|
+
* @param {string} _siteId
|
|
40
|
+
* @returns {Promise<string|null>}
|
|
41
|
+
*/
|
|
42
|
+
async getClientId(_siteId) {
|
|
43
|
+
// v1.0: intentionally never reads from storage.
|
|
44
|
+
// Uncomment in v1.1 (Option C) — and ONLY after implementing the upgrade
|
|
45
|
+
// contract documented in Appendix H.3.2.
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* v1.0: intentionally does nothing.
|
|
51
|
+
*
|
|
52
|
+
* Forward-compat note: v1.1 (Option C) WILL persist client_id to the
|
|
53
|
+
* keychain. It is NOT safe to enable that here without also implementing:
|
|
54
|
+
* - keychain_schema_version key alongside client_id (v1.1+ only)
|
|
55
|
+
* - clearClientId() called automatically on add-site (defensive cleanup)
|
|
56
|
+
* - operator UX for orphaned-client-id detection
|
|
57
|
+
*
|
|
58
|
+
* See Appendix H.3.2 in DESIGN — OAuth 2.1 in the Adapter for the upgrade
|
|
59
|
+
* contract. Do NOT uncomment a write here without reading that section
|
|
60
|
+
* first.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} _siteId
|
|
63
|
+
* @param {string} _clientId
|
|
64
|
+
* @returns {Promise<void>}
|
|
65
|
+
*/
|
|
66
|
+
async persistClientId(_siteId, _clientId) {
|
|
67
|
+
// NO-OP — see contract above.
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* v1.0: NO-OP (nothing was persisted).
|
|
72
|
+
* @param {string} _siteId
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
async clearClientId(_siteId) {
|
|
76
|
+
// NO-OP — symmetry with v1.1+ contract.
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* v1.0: not supported. Returns null per F.4.
|
|
81
|
+
* @param {string} _siteId
|
|
82
|
+
* @returns {Promise<null>}
|
|
83
|
+
*/
|
|
84
|
+
async exportIdentity(_siteId) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* v1.0: not supported. Throws per F.4.
|
|
90
|
+
* @param {string} _siteId
|
|
91
|
+
* @param {object} _bundle
|
|
92
|
+
* @returns {Promise<never>}
|
|
93
|
+
*/
|
|
94
|
+
async importIdentity(_siteId, _bundle) {
|
|
95
|
+
const err = new Error('Identity import not supported in v1.0. Upgrade to v1.1+.');
|
|
96
|
+
err.code = 'not_implemented';
|
|
97
|
+
throw err;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { FreshEachTimeIdentityProvider };
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('node:https');
|
|
4
|
+
const http = require('node:http');
|
|
5
|
+
const { URL } = require('node:url');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Minimal HTTP/JSON helper for OAuth endpoint calls.
|
|
9
|
+
*
|
|
10
|
+
* Behavior:
|
|
11
|
+
* - HTTPS-required for non-localhost hosts; HTTP allowed only when
|
|
12
|
+
* `allowInsecure: true` and the host is loopback.
|
|
13
|
+
* - Default 30s timeout (Appendix H.2.1 mandate for token-manager; we mirror
|
|
14
|
+
* it here for parity).
|
|
15
|
+
* - Does NOT follow redirects on token / register / revoke / discovery
|
|
16
|
+
* endpoints (mirrors H.2.3 posture).
|
|
17
|
+
* - Returns `{ statusCode, headers, body, json }` where `json` is parsed
|
|
18
|
+
* when content-type indicates JSON, else null.
|
|
19
|
+
*
|
|
20
|
+
* Copyright (C) 2026 Influencentricity | Wicked Evolutions
|
|
21
|
+
* @license GPL-2.0-or-later
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
25
|
+
|
|
26
|
+
function _buildOptions(parsed, method, headers, isHttps) {
|
|
27
|
+
return {
|
|
28
|
+
method,
|
|
29
|
+
hostname: parsed.hostname,
|
|
30
|
+
port: parsed.port || (isHttps ? 443 : 80),
|
|
31
|
+
path: parsed.pathname + parsed.search,
|
|
32
|
+
headers,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send an HTTP(S) request and return the parsed response.
|
|
38
|
+
* @param {object} args
|
|
39
|
+
* @param {string} args.url
|
|
40
|
+
* @param {string} args.method GET | POST
|
|
41
|
+
* @param {object} [args.headers]
|
|
42
|
+
* @param {string|Buffer|null} [args.body]
|
|
43
|
+
* @param {boolean} [args.allowInsecure]
|
|
44
|
+
* @param {number} [args.timeoutMs]
|
|
45
|
+
* @param {object} [args.httpsAgent] test injection
|
|
46
|
+
* @returns {Promise<{statusCode:number, headers:object, body:string, json:object|null}>}
|
|
47
|
+
*/
|
|
48
|
+
function request(args) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
let parsed;
|
|
51
|
+
try { parsed = new URL(args.url); }
|
|
52
|
+
catch (err) { reject(new Error(`Invalid URL: ${args.url}`)); return; }
|
|
53
|
+
|
|
54
|
+
const isHttps = parsed.protocol === 'https:';
|
|
55
|
+
const isHttp = parsed.protocol === 'http:';
|
|
56
|
+
if (!isHttps && !isHttp) {
|
|
57
|
+
reject(new Error(`URL must be http(s): ${args.url}`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const isLocal = parsed.hostname === 'localhost'
|
|
61
|
+
|| parsed.hostname === '127.0.0.1'
|
|
62
|
+
|| parsed.hostname === '::1';
|
|
63
|
+
if (isHttp && !(isLocal && args.allowInsecure)) {
|
|
64
|
+
reject(new Error(`HTTPS required for ${args.url}`));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const headers = Object.assign({ 'Accept': 'application/json' }, args.headers || {});
|
|
69
|
+
if (args.body && !headers['Content-Length']) {
|
|
70
|
+
headers['Content-Length'] = Buffer.byteLength(args.body);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const mod = isHttps ? https : http;
|
|
74
|
+
const reqOpts = _buildOptions(parsed, args.method || 'GET', headers, isHttps);
|
|
75
|
+
reqOpts.timeout = args.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
76
|
+
if (args.httpsAgent && isHttps) reqOpts.agent = args.httpsAgent;
|
|
77
|
+
|
|
78
|
+
const req = mod.request(reqOpts, (res) => {
|
|
79
|
+
// Do not auto-follow redirects on auth endpoints.
|
|
80
|
+
const chunks = [];
|
|
81
|
+
res.on('data', (c) => chunks.push(c));
|
|
82
|
+
res.on('end', () => {
|
|
83
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
84
|
+
let json = null;
|
|
85
|
+
const ctype = (res.headers['content-type'] || '').toLowerCase();
|
|
86
|
+
if (ctype.includes('json') && body.length > 0) {
|
|
87
|
+
try { json = JSON.parse(body); } catch { /* leave null */ }
|
|
88
|
+
}
|
|
89
|
+
resolve({ statusCode: res.statusCode, headers: res.headers, body, json });
|
|
90
|
+
});
|
|
91
|
+
res.on('error', (err) => reject(err));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
req.on('error', (err) => reject(err));
|
|
95
|
+
req.on('timeout', () => req.destroy(new Error(`Request timed out at ${reqOpts.timeout}ms: ${args.url}`)));
|
|
96
|
+
if (args.body) req.write(args.body);
|
|
97
|
+
req.end();
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* POST application/x-www-form-urlencoded — used by /oauth/token (RFC 6749).
|
|
103
|
+
*/
|
|
104
|
+
async function postForm(url, params, opts = {}) {
|
|
105
|
+
const body = new URLSearchParams(params).toString();
|
|
106
|
+
return request({
|
|
107
|
+
url,
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: Object.assign(
|
|
110
|
+
{ 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
111
|
+
opts.headers || {}
|
|
112
|
+
),
|
|
113
|
+
body,
|
|
114
|
+
allowInsecure: opts.allowInsecure,
|
|
115
|
+
timeoutMs: opts.timeoutMs,
|
|
116
|
+
httpsAgent: opts.httpsAgent,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* POST application/json — used by /oauth/register (RFC 7591).
|
|
122
|
+
*/
|
|
123
|
+
async function postJson(url, body, opts = {}) {
|
|
124
|
+
const payload = typeof body === 'string' ? body : JSON.stringify(body);
|
|
125
|
+
return request({
|
|
126
|
+
url,
|
|
127
|
+
method: 'POST',
|
|
128
|
+
headers: Object.assign(
|
|
129
|
+
{ 'Content-Type': 'application/json' },
|
|
130
|
+
opts.headers || {}
|
|
131
|
+
),
|
|
132
|
+
body: payload,
|
|
133
|
+
allowInsecure: opts.allowInsecure,
|
|
134
|
+
timeoutMs: opts.timeoutMs,
|
|
135
|
+
httpsAgent: opts.httpsAgent,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** GET — used by L2 GET-before-POST probes. */
|
|
140
|
+
async function getJson(url, opts = {}) {
|
|
141
|
+
return request({
|
|
142
|
+
url,
|
|
143
|
+
method: 'GET',
|
|
144
|
+
headers: opts.headers,
|
|
145
|
+
allowInsecure: opts.allowInsecure,
|
|
146
|
+
timeoutMs: opts.timeoutMs,
|
|
147
|
+
httpsAgent: opts.httpsAgent,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { request, postForm, postJson, getJson, DEFAULT_TIMEOUT_MS };
|