@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/README.md +88 -17
  3. package/abilities-mcp.js +182 -113
  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 +98 -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 +161 -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.js +248 -19
  37. package/lib/connection-pool.js +214 -11
  38. package/lib/router.js +29 -11
  39. package/lib/transports/oauth-http-transport.js +601 -0
  40. package/package.json +7 -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 };