@aiwg/cockpit 2026.9.12 → 2026.9.16

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: fetchImpl = globalThis.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@aiwg/cockpit",
3
- "version": "2026.9.12",
4
- "description": "AIWG Cockpit \u2014 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).",
3
+ "version": "2026.9.16",
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",
7
7
  "repository": {