@aiwg/cockpit 2026.9.15 → 2026.9.17
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.
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
// use and never logged or returned. Section9 realm defaults come from itops
|
|
10
10
|
// (`config/matric-user-secrets.yaml`, `configs/keycloak/realms/section9.json`).
|
|
11
11
|
|
|
12
|
-
import { createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
12
|
+
import { createHash, createPublicKey, verify as cryptoVerify } from 'node:crypto';
|
|
13
13
|
import { readFile, stat } from 'node:fs/promises';
|
|
14
|
+
import { createDesktopIssuerTransport } from './desktop-issuer-transport.mjs';
|
|
14
15
|
|
|
15
16
|
export const SECTION9_ISSUER = 'https://auth.s9.internal/realms/section9';
|
|
16
17
|
export const DESKTOP_ACTIONS = Object.freeze(['view', 'create', 'close', 'attach', 'control', 'observe']);
|
|
@@ -28,6 +29,14 @@ const denied = (code = 'denied') => Object.assign(new Error(code), { code });
|
|
|
28
29
|
const text = (value) => typeof value === 'string' && value.length > 0 && value.length <= 4096;
|
|
29
30
|
const b64url = (value) => Buffer.from(value, 'base64url');
|
|
30
31
|
|
|
32
|
+
/** Input comes only from the authenticated backend browser binding. */
|
|
33
|
+
export function desktopBrowserAudience({ browserSessionId, audience, workspaceId }) {
|
|
34
|
+
if (![browserSessionId, audience, workspaceId].every(text)) throw denied();
|
|
35
|
+
return `desktop-browser-v1:${createHash('sha256').update(JSON.stringify([
|
|
36
|
+
'desktop-browser-v1', browserSessionId, audience, workspaceId,
|
|
37
|
+
])).digest('hex')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
31
40
|
function decodeJwt(token) {
|
|
32
41
|
if (!text(token)) throw denied();
|
|
33
42
|
const parts = token.split('.');
|
|
@@ -117,7 +126,9 @@ export function createKeycloakDesktopVerifier({
|
|
|
117
126
|
clientId,
|
|
118
127
|
clientSecretFile,
|
|
119
128
|
delegationAudience,
|
|
120
|
-
fetch:
|
|
129
|
+
fetch: suppliedFetch,
|
|
130
|
+
issuerTls,
|
|
131
|
+
workloadCertificateThumbprint,
|
|
121
132
|
now = Date.now,
|
|
122
133
|
timeoutMs = 5000,
|
|
123
134
|
jwksTtlMs = 300_000,
|
|
@@ -129,10 +140,12 @@ export function createKeycloakDesktopVerifier({
|
|
|
129
140
|
} = {}) {
|
|
130
141
|
let base;
|
|
131
142
|
try { base = new URL(issuer); } catch { throw new TypeError('Keycloak issuer must be an HTTPS realm URL'); }
|
|
132
|
-
if (base.protocol !== 'https:' || base.search || base.hash || !/\/realms\/[^/]+$/.test(base.pathname)) throw new TypeError('Keycloak issuer must be an HTTPS realm URL');
|
|
143
|
+
if (base.protocol !== 'https:' || base.username || base.password || base.search || base.hash || !/\/realms\/[^/]+$/.test(base.pathname)) throw new TypeError('Keycloak issuer must be an HTTPS realm URL');
|
|
133
144
|
for (const [name, value] of Object.entries({ audience, clientId, clientSecretFile, delegationAudience })) {
|
|
134
145
|
if (!text(value)) throw new TypeError(`Keycloak desktop verifier requires ${name}`);
|
|
135
146
|
}
|
|
147
|
+
if (!/^[A-Za-z0-9_-]{43}$/.test(workloadCertificateThumbprint)) throw new TypeError('Workload certificate thumbprint required');
|
|
148
|
+
const fetchImpl = suppliedFetch ?? createDesktopIssuerTransport({ ...issuerTls, issuer, thumbprint: workloadCertificateThumbprint, timeoutMs });
|
|
136
149
|
if (typeof fetchImpl !== 'function') throw new TypeError('fetch implementation required');
|
|
137
150
|
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 30000) throw new TypeError('Invalid Keycloak deadline');
|
|
138
151
|
const realm = issuer.replace(/\/$/, '');
|
|
@@ -224,16 +237,26 @@ export function createKeycloakDesktopVerifier({
|
|
|
224
237
|
return claims;
|
|
225
238
|
}
|
|
226
239
|
|
|
227
|
-
async function delegation(entry, signal) {
|
|
240
|
+
async function delegation(entry, claims, expected, signal) {
|
|
241
|
+
const browserAudience = desktopBrowserAudience(expected);
|
|
228
242
|
const skew = clockSkewMs;
|
|
229
243
|
if (entry.delegation && entry.delegationExpiresAt - skew > now()) return { delegation: entry.delegation, delegationExpiresAt: entry.delegationExpiresAt };
|
|
230
244
|
const exchanged = await grant({
|
|
231
245
|
grant_type: TOKEN_EXCHANGE, subject_token: entry.accessToken, subject_token_type: ACCESS_TOKEN_TYPE,
|
|
232
246
|
requested_token_type: ACCESS_TOKEN_TYPE, audience: delegationAudience,
|
|
247
|
+
desktop_browser_session_audience: browserAudience,
|
|
233
248
|
}, signal);
|
|
234
249
|
if (!text(exchanged?.access_token) || !Number.isFinite(exchanged.expires_in) || exchanged.expires_in <= 0) throw denied('identity_unavailable');
|
|
250
|
+
const payload = verifyJws(exchanged.access_token, await keys(signal));
|
|
251
|
+
const aud = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
|
|
252
|
+
if (payload.iss !== realm || aud.length !== 1 || aud[0] !== delegationAudience ||
|
|
253
|
+
payload.azp !== clientId || payload.sub !== claims.sub || payload.sid !== (claims.sid ?? claims.session_state) ||
|
|
254
|
+
payload.workspace_id !== expected.workspaceId || payload.desktop_browser_session_audience !== browserAudience ||
|
|
255
|
+
payload.cnf?.['x5t#S256'] !== workloadCertificateThumbprint ||
|
|
256
|
+
!Number.isFinite(payload.exp) || payload.exp * 1000 <= now() ||
|
|
257
|
+
payload.nbf !== undefined && (!Number.isFinite(payload.nbf) || payload.nbf * 1000 > now())) throw denied();
|
|
235
258
|
entry.delegation = exchanged.access_token;
|
|
236
|
-
entry.delegationExpiresAt = now() + exchanged.expires_in * 1000;
|
|
259
|
+
entry.delegationExpiresAt = Math.min(now() + exchanged.expires_in * 1000, payload.exp * 1000);
|
|
237
260
|
return { delegation: entry.delegation, delegationExpiresAt: entry.delegationExpiresAt };
|
|
238
261
|
}
|
|
239
262
|
|
|
@@ -245,11 +268,16 @@ export function createKeycloakDesktopVerifier({
|
|
|
245
268
|
const payload = verifyJws(evidence.accessToken, await keys(signal));
|
|
246
269
|
if (payload.iss !== realm || !audienceMatches(payload.aud, audience)) throw denied();
|
|
247
270
|
entry = { accessToken: evidence.accessToken, refreshToken: text(evidence.refreshToken) ? evidence.refreshToken : undefined,
|
|
271
|
+
subject: payload.sub, userSessionId: payload.sid ?? payload.session_state, browserAudience: desktopBrowserAudience(expected),
|
|
248
272
|
authTime: Number.isFinite(payload.auth_time) ? payload.auth_time * 1000 : now() };
|
|
249
|
-
} else if (!entry) throw denied();
|
|
273
|
+
} else if (!entry || entry.browserAudience !== desktopBrowserAudience(expected)) throw denied();
|
|
250
274
|
let claims;
|
|
251
275
|
try { claims = await freshClaims(entry, signal); }
|
|
252
276
|
catch (error) { sessions.delete(expected.browserSessionId); throw error; }
|
|
277
|
+
if (claims.sub !== entry.subject || (claims.sid ?? claims.session_state) !== entry.userSessionId) {
|
|
278
|
+
sessions.delete(expected.browserSessionId);
|
|
279
|
+
throw denied();
|
|
280
|
+
}
|
|
253
281
|
const checkedAt = now();
|
|
254
282
|
const mapped = mapClaims(claims) ?? {};
|
|
255
283
|
const instanceIds = typeof resolveInstances === 'function'
|
|
@@ -262,7 +290,7 @@ export function createKeycloakDesktopVerifier({
|
|
|
262
290
|
sessions.delete(expected.browserSessionId);
|
|
263
291
|
throw denied();
|
|
264
292
|
}
|
|
265
|
-
const { delegation: token, delegationExpiresAt } = await delegation(entry, signal);
|
|
293
|
+
const { delegation: token, delegationExpiresAt } = await delegation(entry, claims, expected, signal);
|
|
266
294
|
const expiresAt = Math.min(entry.authTime + sessionMaxMs, checkedAt + sessionMaxMs);
|
|
267
295
|
if (operation === 'bind') sessions.set(expected.browserSessionId, entry);
|
|
268
296
|
return {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { open } from 'node:fs/promises';
|
|
3
|
+
import { X509Certificate } from 'node:crypto';
|
|
4
|
+
import { request } from 'node:https';
|
|
5
|
+
|
|
6
|
+
const unavailable = () => Object.assign(new Error('identity_unavailable'), { code: 'identity_unavailable' });
|
|
7
|
+
|
|
8
|
+
async function credential(file, secret = false) {
|
|
9
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
10
|
+
try {
|
|
11
|
+
const info = await handle.stat();
|
|
12
|
+
if (!info.isFile() || info.nlink !== 1 || ![0, process.getuid()].includes(info.uid) ||
|
|
13
|
+
(info.mode & (secret ? 0o077 : 0o022)) || info.size < 1 || info.size > 65536) throw unavailable();
|
|
14
|
+
const buffer = Buffer.alloc(65537);
|
|
15
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
16
|
+
if (bytesRead > 65536) throw unavailable();
|
|
17
|
+
return buffer.subarray(0, bytesRead);
|
|
18
|
+
} finally { await handle.close(); }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Backend-only HTTPS transport, pinned to one realm and one workload leaf. */
|
|
22
|
+
export function createDesktopIssuerTransport({ issuer, certificateFile, keyFile, caFile, thumbprint, timeoutMs = 5000 }) {
|
|
23
|
+
const realm = new URL(issuer);
|
|
24
|
+
if (realm.protocol !== 'https:' || realm.username || realm.password || realm.search || realm.hash ||
|
|
25
|
+
!/\/realms\/[^/]+$/.test(realm.pathname) || !certificateFile || !keyFile ||
|
|
26
|
+
!/^[A-Za-z0-9_-]{43}$/.test(thumbprint) || !Number.isFinite(timeoutMs) || timeoutMs <= 0 || timeoutMs > 30000) {
|
|
27
|
+
throw new TypeError('Explicit desktop issuer TLS configuration required');
|
|
28
|
+
}
|
|
29
|
+
return async (url, init = {}) => {
|
|
30
|
+
try {
|
|
31
|
+
const target = new URL(url);
|
|
32
|
+
if (target.origin !== realm.origin || !target.pathname.startsWith(`${realm.pathname}/protocol/openid-connect/`) ||
|
|
33
|
+
target.username || target.password || target.search || target.hash || init.signal?.aborted) throw unavailable();
|
|
34
|
+
const [cert, key, ca] = await Promise.all([
|
|
35
|
+
credential(certificateFile), credential(keyFile, true), caFile ? credential(caFile) : undefined,
|
|
36
|
+
]);
|
|
37
|
+
const leaf = new X509Certificate(cert);
|
|
38
|
+
const pin = Buffer.from(leaf.fingerprint256.replaceAll(':', ''), 'hex').toString('base64url');
|
|
39
|
+
if (pin !== thumbprint || Date.parse(leaf.validFrom) > Date.now() || Date.parse(leaf.validTo) <= Date.now()) throw unavailable();
|
|
40
|
+
return await new Promise((resolve, reject) => {
|
|
41
|
+
const req = request(target, { method: init.method ?? 'GET', headers: init.headers, cert, key, ca,
|
|
42
|
+
rejectUnauthorized: true, minVersion: 'TLSv1.2', agent: false, signal: init.signal }, (res) => {
|
|
43
|
+
const chunks = []; let size = 0;
|
|
44
|
+
res.on('data', (chunk) => {
|
|
45
|
+
size += chunk.length;
|
|
46
|
+
if (size > 65536) req.destroy(unavailable());
|
|
47
|
+
else chunks.push(chunk);
|
|
48
|
+
});
|
|
49
|
+
res.on('error', () => reject(unavailable()));
|
|
50
|
+
res.on('end', () => {
|
|
51
|
+
if (size > 65536 || res.statusCode >= 300 && res.statusCode < 400) return reject(unavailable());
|
|
52
|
+
resolve(new Response(res.statusCode === 204 ? null : Buffer.concat(chunks), { status: res.statusCode }));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
const timer = setTimeout(() => req.destroy(unavailable()), timeoutMs);
|
|
56
|
+
req.on('close', () => clearTimeout(timer));
|
|
57
|
+
req.on('error', () => reject(unavailable()));
|
|
58
|
+
req.end(init.body);
|
|
59
|
+
});
|
|
60
|
+
} catch { throw unavailable(); }
|
|
61
|
+
};
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwg/cockpit",
|
|
3
|
-
"version": "2026.9.
|
|
3
|
+
"version": "2026.9.17",
|
|
4
4
|
"description": "AIWG Cockpit — UX-first control plane over AIWG + multi-stack agentic sessions. Opt-in, separately published; NOT shipped in the base aiwg npm package (guarded by test/smoke/cockpit-base-footprint.test.js).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|