From 409e354114d28f25a29d6df698c08d679009694c Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Tue, 23 Jun 2026 14:28:12 +0200 Subject: [PATCH 1/7] Add ai-bot delegated JWT retrieval and shared signing module ai-bot exchanges a shared secret for a user-scoped, read-only realm JWT via the realm server's /_delegate-session endpoint, so it can read a realm on a user's behalf without a blanket read grant. - runtime-common/delegation.ts: canonical HMAC sign/verify over the ${timestamp}.${rawBody} payload plus the requestDelegatedToken client; single source of truth shared by ai-bot and the realm-server verifier. - ai-bot DelegatedTokenManager: per-(user, realm) token cache, 30m TTL, proactive refresh under 2 minutes remaining; realm-server origin derived from the realm URL. - main.ts wires AI_BOT_DELEGATION_SECRET; the path is inert when unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/ai-bot/lib/delegation.ts | Bin 0 -> 4733 bytes packages/ai-bot/main.ts | 8 + packages/ai-bot/tests/delegation-test.ts | 259 +++++++++++++++++++++++ packages/ai-bot/tests/index.ts | 1 + packages/runtime-common/delegation.ts | 221 +++++++++++++++++++ 5 files changed, 489 insertions(+) create mode 100644 packages/ai-bot/lib/delegation.ts create mode 100644 packages/ai-bot/tests/delegation-test.ts create mode 100644 packages/runtime-common/delegation.ts diff --git a/packages/ai-bot/lib/delegation.ts b/packages/ai-bot/lib/delegation.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6bef3b73e1b80b8644085f97f61b82cf254e25c GIT binary patch literal 4733 zcmai2+j1Mn5zRBdqFr(s3m34I?aB*eNRdQ1mLiEM5-B~TQq*8~0Iaw>gUrqnL@}uH zNB9f#B{|(Q11u#nejp2&OJ7f)?m^XTtn+k7wOy^W8&PMf+j-S!+SAh6h6aBrlq*}W zitAI?8IP@TVVlO9!LySp-aa96`rn;y{h6-yN_kz*?X@-|Y{ZqynwfLf@si(dbXwQt zrEXj7j!yni<7pYB<200?)Y`MzG>yh*167R|)=zrJhB$Y6N`H4&6~4OBuyg5jyQW&J zlGvLG`L!loOIx}nMTIKX8VA@5+OF|$skhp(EWcKUex#-{oyS?H8&#PRwHDWYdO8m4 z@utx?+L6B9R8Wd5<=9(Wj|)}TIm?nLL7&qs4`HKi<<}ypq51qBEp}p_)2eQ$)ehpG zVWn!xntDeoiv`n)g~7JqN~w#gghqBPx>DH9j&KhKP*K}f<5REeg5I>60!c_$ES!X8|#*;(DYg&C63S?lG}!KPQFU; zGi<_T1cS}c8(XaD`&L6^*?vzMAZAKHjoyBo!$4Iv!x6y`TOuj~MRrCKl0y>2OvrT2 z0s%SM_YHcx^|RZ}YnC7e;pB^ZM`6STvLB-J3EQ87KFWeo(WW8U&XZjt8*CCMh8Ga< zYPqY-iYib4{q6S@_I`>;)%aTOsI688=2xDR7E&X5Gg?ReP+vL8K*$-36TC@I1gkmB z^&O|VGo@ZuB5^4K13nSX&TN!xwOZ7gkr?t`*AcNW#MO)lze%38@ zqU5GAh3%%Ud^X4j!+-wpFNQ|7r0kn`YAA_gQenT3()ipSe;yHI!9lcurG@0Tm**dH zi9@EmU1Ukxkp1q#g2fz!GZ`VtK}e2t&~!SbL4ekPUi5b+Uzmd0@<4GgW$txm_#Im1KYgMJt>&L2FL)wPxU*UPbEExGJnn+LRlMYUi45EE)jd3^5F_ zdpev2gJ!C>X4Qgvg0!GN$wED&#Gx8Y;|48o+Bp!2h(Yb;tDM%}Z`#S}slHXsrq($+ z&1sjuoeo(l>`?Vq?QoyzuzZLjS!S`8Vh~&LuT|)2ArLuzWL7lBdKQSYAo}FbrEu&& zF5X2>;ElBfC&K7n<2CnJg!;Jct^jw*+^>$zdKskRbD38MOjrHrkCd zPD{k$Fd2dPa(u?ZTH;ho>{aNrIsIyD5SijQ3c4l3vB$F9)OYKhbVzP|+O-^g`V6K) zVO|G$ffnR5BF6I8U4v7CTSU-e7Gd-p!--Ej+A2U(s8W@q14pAlnrmUP1t^<^1)Ala z%ilh7Lr2=$H9BtzAQdPv&IX#%vRZY#$ckV9!sB=ctn2)QR@%>HCh%)#x6$*n!&o9C z4)+XbFKJL#ErYNejL_bB`eB=n9teQB68`-3r$G9Z7#l$q&0^T_olRHQ;|A@p#w?;h zj9_{UV1dR_4tBZBPv9rdb6~T!J$7I*Ipo?2qdW<+6L9*B&E)tb2RojKC=NM3O*^ndXA`{AnG<|;P*tjX9hU*G%T_%M5xJ^7X z64{M!Cm2;lFk1R!Dv{YOw)+Y(%}(iV&vH_4;UOxIalmF!&gP=M$d;(V4yz5$(2E!J zkb~(4^rrLt5vQ|Y#2MOcj-_lUF^kPEs7&^tm)TWbnWYWUU_%Bk-(0;qpI@EL-p&5{ za{lK00}o;svpFLY^aZOiStQPuQpK3a1q);XA4mL&UvNG+$UPK|i5Sjcc)@4}_E=yz z)hN0>cz2gtLLq>2J<1{?gf zZa6)V$CI!WFPasP>cW&V@4zK-Ja7#QJIGP<`crbMjFiN0~CsIL$Fw4pQ(jGC!Q>3X{9ZTwY6d}{ky$Ht*#uk`HD)9@R z>EYz7L<2j23=7I0#aCwlt>GYe+{2Cd%k$hd$afm@aDSpn9VCQ+kH*Ac@{_ufuQ`yH zK}N40;<4|(qi@2&s-5w03@Bt_Z+NVgh|RE`$F7?T9)e;4XP6IDPo9$6Dh$C7K2>9~ zc`O7H4~=&6#rOM-cbMXnsH>mBw0A^DS~(zyYdrDik#o{95C%U^nWlt0!ulQ|J7fXS zMHb>TelH3!uqdaOu}m;!U~u6Z+cf^Lb9kNLC4>($+2LUpW8Qx#aPJnK5QhMA4a&n- z9*yyJCaK^D5iRgaf(pDN>KYnkgvYHk4Dk#Ax0Fv3LCFZLAH5r~ zffI29{Q8tQ9DFP4xfcW8NhEbvwemv-#?Jtn5EY2yxl=9ioP|+oRCD^XF2a)ugVPOY zri3hbk3k95%fDCP(H*#jZ^=W-Fqdu7`0aKmBQ>IQ>ko7S6ZyvhG!uxbjBq7_p566` zF%}Oowe*$`9Ks1Zz7pVkWQl%daot0#c=>Q?O2{NIPrj Response, +): { + fetch: typeof globalThis.fetch; + calls: { url: string; init: RequestInit }[]; +} { + let calls: { url: string; init: RequestInit }[] = []; + let fetch = (async (input: any, init: any) => { + let url = typeof input === 'string' ? input : input.url; + calls.push({ url, init }); + return handler(url, init); + }) as unknown as typeof globalThis.fetch; + return { fetch, calls }; +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +module('delegation client', () => { + test('signs the request so the server verifier accepts it', async () => { + let now = 1_700_000_000_000; + let captured: RequestInit | undefined; + let { fetch } = recordingFetch((_url, init) => { + captured = init; + return jsonResponse({ + token: makeToken(1), + realm: REALM, + permissions: ['read'], + }); + }); + + await requestDelegatedToken({ + realmServerURL: 'https://realm.example.com', + secret: SECRET, + onBehalfOf: ON_BEHALF_OF, + realm: REALM, + fetch, + now, + }); + + let headers = captured!.headers as Record; + let result = verifyDelegationRequest({ + secret: SECRET, + timestamp: headers[DELEGATION_TIMESTAMP_HEADER], + signature: headers[DELEGATION_SIGNATURE_HEADER], + rawBody: captured!.body as string, + now, + }); + assert.true(result.ok, 'server verifier accepts the client signature'); + assert.strictEqual( + headers[DELEGATION_TIMESTAMP_HEADER], + String(now), + 'timestamp header carries the signing time', + ); + }); + + test('POSTs to /_delegate-session at the given realm-server origin', async () => { + let { fetch, calls } = recordingFetch(() => + jsonResponse({ + token: makeToken(1), + realm: REALM, + permissions: ['read'], + }), + ); + await requestDelegatedToken({ + realmServerURL: 'https://realm.example.com', + secret: SECRET, + onBehalfOf: ON_BEHALF_OF, + realm: REALM, + fetch, + now: 1, + }); + assert.strictEqual( + calls[0].url, + 'https://realm.example.com/_delegate-session', + ); + assert.strictEqual(calls[0].init.method, 'POST'); + }); + + test('maps status codes to typed DelegationError kinds', async () => { + let cases: { status: number; kind: string }[] = [ + { status: 503, kind: 'disabled' }, + { status: 403, kind: 'forbidden' }, + { status: 401, kind: 'unauthorized' }, + { status: 400, kind: 'bad-request' }, + { status: 500, kind: 'unexpected' }, + ]; + for (let { status, kind } of cases) { + let { fetch } = recordingFetch(() => new Response('nope', { status })); + try { + await requestDelegatedToken({ + realmServerURL: 'https://realm.example.com', + secret: SECRET, + onBehalfOf: ON_BEHALF_OF, + realm: REALM, + fetch, + now: 1, + }); + assert.true(false, `expected ${status} to throw`); + } catch (e) { + assert.true( + e instanceof DelegationError, + `${status} → DelegationError`, + ); + assert.strictEqual( + (e as DelegationError).kind, + kind, + `${status} → ${kind}`, + ); + } + } + }); +}); + +module('DelegatedTokenManager', () => { + test('is disabled and throws when no secret is configured', async () => { + let manager = new DelegatedTokenManager(undefined); + assert.false(manager.enabled, 'manager reports disabled'); + try { + await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + assert.true(false, 'expected getToken to throw'); + } catch (e) { + assert.true(e instanceof DelegationError); + assert.strictEqual((e as DelegationError).kind, 'disabled'); + } + }); + + test('caches a valid token and does not re-mint', async () => { + let now = 1_700_000_000_000; + let nowSeconds = Math.floor(now / 1000); + let { fetch, calls } = recordingFetch(() => + jsonResponse({ + token: makeToken(nowSeconds + 1800), // 30m out, well clear of the lead time + realm: REALM, + permissions: ['read'], + }), + ); + let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + + let a = await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + let b = await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + assert.strictEqual(a, b, 'same cached token returned'); + assert.strictEqual(calls.length, 1, 'minted exactly once'); + }); + + test('proactively re-mints when within the 2-minute lead time', async () => { + let now = 1_700_000_000_000; + let nowSeconds = Math.floor(now / 1000); + // exp is 90s out — inside the 120s lead window, so the cached copy is + // considered too close to expiry to reuse. + let { fetch, calls } = recordingFetch(() => + jsonResponse({ + token: makeToken(nowSeconds + 90), + realm: REALM, + permissions: ['read'], + }), + ); + let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + + await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + assert.strictEqual(calls.length, 2, 're-minted because token was expiring'); + }); + + test('caches per (user, realm) pair independently', async () => { + let now = 1_700_000_000_000; + let nowSeconds = Math.floor(now / 1000); + let { fetch, calls } = recordingFetch(() => + jsonResponse({ + token: makeToken(nowSeconds + 1800), + realm: REALM, + permissions: ['read'], + }), + ); + let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + + await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + await manager.getToken({ + onBehalfOf: '@another-user:boxel.ai', + realm: REALM, + }); + await manager.getToken({ + onBehalfOf: ON_BEHALF_OF, + realm: 'https://realm.example.com/u/another-realm/', + }); + assert.strictEqual(calls.length, 3, 'each distinct key minted separately'); + }); + + test('derives the realm-server origin from the realm URL', async () => { + let now = 1_700_000_000_000; + let nowSeconds = Math.floor(now / 1000); + let { fetch, calls } = recordingFetch(() => + jsonResponse({ + token: makeToken(nowSeconds + 1800), + realm: REALM, + permissions: ['read'], + }), + ); + let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + await manager.getToken({ + onBehalfOf: ON_BEHALF_OF, + realm: 'https://realm.example.com/u/example-user/', + }); + assert.strictEqual( + calls[0].url, + 'https://realm.example.com/_delegate-session', + 'POSTs to the realm origin, not the realm path', + ); + }); + + test('invalidate() forces the next getToken to re-mint', async () => { + let now = 1_700_000_000_000; + let nowSeconds = Math.floor(now / 1000); + let { fetch, calls } = recordingFetch(() => + jsonResponse({ + token: makeToken(nowSeconds + 1800), + realm: REALM, + permissions: ['read'], + }), + ); + let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + manager.invalidate({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); + assert.strictEqual(calls.length, 2, 're-minted after invalidate'); + }); +}); diff --git a/packages/ai-bot/tests/index.ts b/packages/ai-bot/tests/index.ts index e31d569d96d..63811d278ae 100644 --- a/packages/ai-bot/tests/index.ts +++ b/packages/ai-bot/tests/index.ts @@ -12,5 +12,6 @@ import './modality-test.ts'; import './locking-test.ts'; import './interrupt-test.ts'; import './credit-tracking-test.ts'; +import './delegation-test.ts'; QUnit.start(); diff --git a/packages/runtime-common/delegation.ts b/packages/runtime-common/delegation.ts new file mode 100644 index 00000000000..cc70729cff4 --- /dev/null +++ b/packages/runtime-common/delegation.ts @@ -0,0 +1,221 @@ +import { createHmac, timingSafeEqual } from 'crypto'; + +// Shared-secret authentication for the realm-server /_delegate-session +// endpoint. ai-bot and the realm server hold a shared secret; ai-bot signs +// each delegation request with it, the realm server verifies it. The secret +// itself never crosses the wire — only an HMAC over the request — so TLS plus +// the timestamp window below give meaningful replay protection, and rotating +// the shared secret is the defense against it leaking from configuration. +// +// This module is the single source of truth for the signed-payload format. +// Both sides import from here so they can never drift: ai-bot calls +// `requestDelegatedToken`/`delegationSignature` to sign, and the realm +// server's /_delegate-session handler calls `verifyDelegationRequest` to +// verify — neither keeps its own copy of the canonical `${timestamp}.${rawBody}` +// construction. It is imported via the `@cardstack/runtime-common/delegation` +// subpath by node consumers only — it pulls in node `crypto`, so it is +// deliberately not re-exported from the package barrel that browser code loads. + +export const DELEGATION_TIMESTAMP_HEADER = 'x-boxel-delegation-timestamp'; +export const DELEGATION_SIGNATURE_HEADER = 'x-boxel-delegation-signature'; + +// ±60s window on the request timestamp. Cheap and stateless — it bounds the +// replay window for a captured request without a server-side nonce store. +export const DELEGATION_TIMESTAMP_WINDOW_MS = 60_000; + +// The canonical string both sides sign: `${timestamp}.${rawBody}`. `timestamp` +// is epoch milliseconds in base-10; `rawBody` is the exact request body bytes. +// HMAC-SHA256 with the shared secret, hex digest. Binding the timestamp into +// the signed payload is what makes the ±60s window enforceable — a captured +// request cannot have its timestamp rewritten without the secret. +export function delegationSignature( + secret: string, + timestamp: string, + rawBody: string, +): string { + return createHmac('sha256', secret) + .update(`${timestamp}.${rawBody}`) + .digest('hex'); +} + +export type DelegationAuthResult = { ok: true } | { ok: false; reason: string }; + +export function verifyDelegationRequest({ + secret, + timestamp, + signature, + rawBody, + now, +}: { + secret: string; + timestamp: string | undefined; + signature: string | undefined; + rawBody: string; + now: number; +}): DelegationAuthResult { + if (!timestamp || !signature) { + return { + ok: false, + reason: 'missing delegation timestamp or signature header', + }; + } + let ts = Number(timestamp); + if (!Number.isFinite(ts)) { + return { ok: false, reason: 'malformed delegation timestamp' }; + } + if (Math.abs(now - ts) > DELEGATION_TIMESTAMP_WINDOW_MS) { + return { + ok: false, + reason: 'delegation timestamp is outside the allowed window', + }; + } + let expected = delegationSignature(secret, timestamp, rawBody); + let expectedBuf = Buffer.from(expected, 'utf8'); + let providedBuf = Buffer.from(signature, 'utf8'); + // Constant-time compare. timingSafeEqual throws on a length mismatch, so + // gate on length first — both are hex SHA-256 digests (64 chars) when + // well-formed, and the length itself is not secret. + if ( + expectedBuf.length !== providedBuf.length || + !timingSafeEqual(expectedBuf, providedBuf) + ) { + return { ok: false, reason: 'invalid delegation signature' }; + } + return { ok: true }; +} + +// ─── Client ────────────────────────────────────────────────────────────── + +export interface DelegatedSession { + token: string; + realm: string; + permissions: string[]; +} + +// Why a custom error: callers (ai-bot) branch on the failure kind — `disabled` +// (the realm server has no secret configured, 503) is a "feature is off, carry +// on" signal, whereas `forbidden` (the user has no read access, 403) and the +// auth failures are genuine errors worth surfacing. +export type DelegationErrorKind = + | 'disabled' // 503: endpoint not configured on the realm server + | 'forbidden' // 403: onBehalfOf lacks read on the realm + | 'unauthorized' // 401: signature/timestamp rejected + | 'bad-request' // 400: malformed request + | 'unexpected'; // anything else + +export class DelegationError extends Error { + readonly kind: DelegationErrorKind; + readonly status?: number; + constructor(kind: DelegationErrorKind, message: string, status?: number) { + super(message); + this.name = 'DelegationError'; + this.kind = kind; + this.status = status; + } +} + +function delegationErrorKindForStatus(status: number): DelegationErrorKind { + switch (status) { + case 503: + return 'disabled'; + case 403: + return 'forbidden'; + case 401: + return 'unauthorized'; + case 400: + return 'bad-request'; + default: + return 'unexpected'; + } +} + +// Exchanges the shared secret for a 30-minute, single-realm, read-only JWT +// scoped to `onBehalfOf`'s read access on `realm`, by signing and POSTing to +// the realm server's /_delegate-session endpoint. +// +// `realmServerURL` is the origin of the realm server that fronts `realm` +// (ai-bot derives it as `new URL(realm).origin`). `now` and `fetch` are +// injectable for tests. +export async function requestDelegatedToken({ + realmServerURL, + secret, + onBehalfOf, + realm, + fetch = globalThis.fetch, + now = Date.now(), +}: { + realmServerURL: string; + secret: string; + onBehalfOf: string; + realm: string; + fetch?: typeof globalThis.fetch; + now?: number; +}): Promise { + let endpoint = new URL( + '_delegate-session', + ensureTrailingSlash(realmServerURL), + ); + let rawBody = JSON.stringify({ onBehalfOf, realm }); + let timestamp = String(now); + let signature = delegationSignature(secret, timestamp, rawBody); + + let response: Response; + try { + response = await fetch(endpoint.href, { + method: 'POST', + headers: { + 'content-type': 'application/json', + [DELEGATION_TIMESTAMP_HEADER]: timestamp, + [DELEGATION_SIGNATURE_HEADER]: signature, + }, + body: rawBody, + }); + } catch (e: any) { + throw new DelegationError( + 'unexpected', + `delegation request to ${endpoint.href} failed: ${e?.message ?? e}`, + ); + } + + if (!response.ok) { + let detail = await safeText(response); + throw new DelegationError( + delegationErrorKindForStatus(response.status), + `delegation request rejected (${response.status})${ + detail ? `: ${detail}` : '' + }`, + response.status, + ); + } + + let session: DelegatedSession; + try { + session = (await response.json()) as DelegatedSession; + } catch { + throw new DelegationError( + 'unexpected', + 'delegation response was not valid JSON', + response.status, + ); + } + if (!session?.token) { + throw new DelegationError( + 'unexpected', + 'delegation response did not include a token', + response.status, + ); + } + return session; +} + +async function safeText(response: Response): Promise { + try { + return (await response.text()).slice(0, 500); + } catch { + return ''; + } +} + +function ensureTrailingSlash(url: string): string { + return url.endsWith('/') ? url : `${url}/`; +} From 4da51ac5386593762f03122f5b2ff1fad27fc169 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Wed, 24 Jun 2026 14:30:39 +0200 Subject: [PATCH 2/7] Consolidate delegation signing onto one shared module and rename it The merged realm-server endpoint (#5287) shipped its own copy of the shared-secret signing/verification logic in realm-server/utils/delegation.ts, duplicating what this PR adds. Point the realm-server handler and its test at the runtime-common module and delete the duplicate, so the canonical ${timestamp}.${rawBody} HMAC format has exactly one definition that both ai-bot and the realm server import. Rename the module from the vague "delegation" (reads like the OOP delegation pattern) to user-delegated-realm-server-session, matching the /_delegate-session endpoint and the DelegatedSession type: - runtime-common/delegation.ts -> user-delegated-realm-server-session.ts - ai-bot/lib/delegation.ts -> user-delegated-realm-server-session.ts - ai-bot/tests/delegation-test.ts -> user-delegated-realm-server-session-test.ts Co-Authored-By: Claude Opus 4.8 (1M context) --- ...=> user-delegated-realm-server-session.ts} | Bin 4733 -> 4758 bytes packages/ai-bot/main.ts | 2 +- packages/ai-bot/tests/index.ts | 2 +- ...er-delegated-realm-server-session-test.ts} | 4 +- .../handlers/handle-delegate-session.ts | 6 +- .../server-endpoints/delegate-session-test.ts | 2 +- packages/realm-server/utils/delegation.ts | 77 ------------------ ...=> user-delegated-realm-server-session.ts} | 5 +- 8 files changed, 12 insertions(+), 86 deletions(-) rename packages/ai-bot/lib/{delegation.ts => user-delegated-realm-server-session.ts} (98%) rename packages/ai-bot/tests/{delegation-test.ts => user-delegated-realm-server-session-test.ts} (98%) delete mode 100644 packages/realm-server/utils/delegation.ts rename packages/runtime-common/{delegation.ts => user-delegated-realm-server-session.ts} (97%) diff --git a/packages/ai-bot/lib/delegation.ts b/packages/ai-bot/lib/user-delegated-realm-server-session.ts similarity index 98% rename from packages/ai-bot/lib/delegation.ts rename to packages/ai-bot/lib/user-delegated-realm-server-session.ts index a6bef3b73e1b80b8644085f97f61b82cf254e25c..5aa6e97ddc764ae9d571f8f753575f303399bde3 100644 GIT binary patch delta 43 wcmeyXGEH^DI)&2W)FR!K)ST4x#FEq$-J;aQoLpU?SQ${bIJLNV`0EjLT@&Et; delta 18 acmbQH`d4MbI`)**oYeHhl8xt12m%02We89J diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index 657fe175936..2156d66743c 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -34,7 +34,7 @@ import { } from '@cardstack/runtime-common/matrix-constants'; import { handleDebugCommands } from './lib/debug.ts'; -import { DelegatedTokenManager } from './lib/delegation.ts'; +import { DelegatedTokenManager } from './lib/user-delegated-realm-server-session.ts'; import { Responder } from './lib/responder.ts'; import { shouldSetRoomTitle, diff --git a/packages/ai-bot/tests/index.ts b/packages/ai-bot/tests/index.ts index 63811d278ae..d078081a266 100644 --- a/packages/ai-bot/tests/index.ts +++ b/packages/ai-bot/tests/index.ts @@ -12,6 +12,6 @@ import './modality-test.ts'; import './locking-test.ts'; import './interrupt-test.ts'; import './credit-tracking-test.ts'; -import './delegation-test.ts'; +import './user-delegated-realm-server-session-test.ts'; QUnit.start(); diff --git a/packages/ai-bot/tests/delegation-test.ts b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts similarity index 98% rename from packages/ai-bot/tests/delegation-test.ts rename to packages/ai-bot/tests/user-delegated-realm-server-session-test.ts index 51e48c17e35..e4b4a53b1aa 100644 --- a/packages/ai-bot/tests/delegation-test.ts +++ b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts @@ -7,8 +7,8 @@ import { DelegationError, DELEGATION_TIMESTAMP_HEADER, DELEGATION_SIGNATURE_HEADER, -} from '@cardstack/runtime-common/delegation'; -import { DelegatedTokenManager } from '../lib/delegation.ts'; +} from '@cardstack/runtime-common/user-delegated-realm-server-session'; +import { DelegatedTokenManager } from '../lib/user-delegated-realm-server-session.ts'; const SECRET = 'shared-secret-under-test'; const ON_BEHALF_OF = '@example-user:boxel.ai'; diff --git a/packages/realm-server/handlers/handle-delegate-session.ts b/packages/realm-server/handlers/handle-delegate-session.ts index d0a684e6a0e..e25ecd9b3b1 100644 --- a/packages/realm-server/handlers/handle-delegate-session.ts +++ b/packages/realm-server/handlers/handle-delegate-session.ts @@ -20,7 +20,7 @@ import { DELEGATION_SIGNATURE_HEADER, DELEGATION_TIMESTAMP_HEADER, verifyDelegationRequest, -} from '../utils/delegation.ts'; +} from '@cardstack/runtime-common/user-delegated-realm-server-session'; // Token lifetime per the v1 security design (CS-11551): 30 minutes. Long // enough to span a tool call, short enough to bound how stale a revoked @@ -31,7 +31,9 @@ const log = logger('realm:delegate-session'); // Mints a realm session JWT scoped to a named user's read access on a single // realm (CS-11552). Shared-secret authenticated (HMAC over the request body + -// timestamp, see utils/delegation.ts). The minted token carries only ['read'] +// timestamp, see @cardstack/runtime-common/user-delegated-realm-server-session). +// The minted token +// carries only ['read'] // and is flagged `delegated` so the realm accepts it read-only regardless of // the user's broader permissions; it can never read anything the user // couldn't, and can never write. diff --git a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts index 040f1d21628..838c225d6b7 100644 --- a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts +++ b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts @@ -17,7 +17,7 @@ import { DELEGATION_SIGNATURE_HEADER, DELEGATION_TIMESTAMP_HEADER, delegationSignature, -} from '../../utils/delegation.ts'; +} from '@cardstack/runtime-common/user-delegated-realm-server-session'; const onBehalfOf = '@jane:localhost'; // A user with no permission rows on the test realm. diff --git a/packages/realm-server/utils/delegation.ts b/packages/realm-server/utils/delegation.ts deleted file mode 100644 index dff76d13c87..00000000000 --- a/packages/realm-server/utils/delegation.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { createHmac, timingSafeEqual } from 'crypto'; - -// Shared-secret authentication for the realm-server /_delegate-session -// endpoint (security design CS-11551). ai-bot and the realm server hold a -// shared secret; ai-bot signs each delegation request with it. The secret -// itself never crosses the wire — only an HMAC over the request — so TLS plus -// the timestamp window below give meaningful replay protection, and secret -// rotation (CS-11567) remains the defense against the secret leaking from -// configuration. - -export const DELEGATION_TIMESTAMP_HEADER = 'x-boxel-delegation-timestamp'; -export const DELEGATION_SIGNATURE_HEADER = 'x-boxel-delegation-signature'; - -// ±60s window on the request timestamp. Cheap and stateless — it bounds the -// replay window for a captured request without a server-side nonce store. -export const DELEGATION_TIMESTAMP_WINDOW_MS = 60_000; - -// The canonical string both sides sign: `${timestamp}.${rawBody}`. `timestamp` -// is epoch milliseconds in base-10; `rawBody` is the exact request body bytes. -// HMAC-SHA256 with the shared secret, hex digest. Binding the timestamp into -// the signed payload is what makes the ±60s window enforceable — a captured -// request cannot have its timestamp rewritten without the secret. -export function delegationSignature( - secret: string, - timestamp: string, - rawBody: string, -): string { - return createHmac('sha256', secret) - .update(`${timestamp}.${rawBody}`) - .digest('hex'); -} - -export type DelegationAuthResult = { ok: true } | { ok: false; reason: string }; - -export function verifyDelegationRequest({ - secret, - timestamp, - signature, - rawBody, - now, -}: { - secret: string; - timestamp: string | undefined; - signature: string | undefined; - rawBody: string; - now: number; -}): DelegationAuthResult { - if (!timestamp || !signature) { - return { - ok: false, - reason: 'missing delegation timestamp or signature header', - }; - } - let ts = Number(timestamp); - if (!Number.isFinite(ts)) { - return { ok: false, reason: 'malformed delegation timestamp' }; - } - if (Math.abs(now - ts) > DELEGATION_TIMESTAMP_WINDOW_MS) { - return { - ok: false, - reason: 'delegation timestamp is outside the allowed window', - }; - } - let expected = delegationSignature(secret, timestamp, rawBody); - let expectedBuf = Buffer.from(expected, 'utf8'); - let providedBuf = Buffer.from(signature, 'utf8'); - // Constant-time compare. timingSafeEqual throws on a length mismatch, so - // gate on length first — both are hex SHA-256 digests (64 chars) when - // well-formed, and the length itself is not secret. - if ( - expectedBuf.length !== providedBuf.length || - !timingSafeEqual(expectedBuf, providedBuf) - ) { - return { ok: false, reason: 'invalid delegation signature' }; - } - return { ok: true }; -} diff --git a/packages/runtime-common/delegation.ts b/packages/runtime-common/user-delegated-realm-server-session.ts similarity index 97% rename from packages/runtime-common/delegation.ts rename to packages/runtime-common/user-delegated-realm-server-session.ts index cc70729cff4..43fa917d69a 100644 --- a/packages/runtime-common/delegation.ts +++ b/packages/runtime-common/user-delegated-realm-server-session.ts @@ -12,8 +12,9 @@ import { createHmac, timingSafeEqual } from 'crypto'; // `requestDelegatedToken`/`delegationSignature` to sign, and the realm // server's /_delegate-session handler calls `verifyDelegationRequest` to // verify — neither keeps its own copy of the canonical `${timestamp}.${rawBody}` -// construction. It is imported via the `@cardstack/runtime-common/delegation` -// subpath by node consumers only — it pulls in node `crypto`, so it is +// construction. It is imported via the +// `@cardstack/runtime-common/user-delegated-realm-server-session` subpath by +// node consumers only — it pulls in node `crypto`, so it is // deliberately not re-exported from the package barrel that browser code loads. export const DELEGATION_TIMESTAMP_HEADER = 'x-boxel-delegation-timestamp'; From fc5b9ff0f336c225ec3ee602a932295968db0110 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Wed, 24 Jun 2026 14:45:08 +0200 Subject: [PATCH 3/7] Rename delegation symbols to the DelegatedRealmSession* scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow the module rename through to the exported symbols, which still led with the vague "delegation"/"delegated token" stem. The concise DelegatedRealmSession prefix names what is actually delegated — a read-only session on a single realm — without reading like the OOP delegation pattern: - DelegatedSession -> DelegatedRealmSession - DelegatedTokenManager -> DelegatedRealmSessionManager - requestDelegatedToken -> requestDelegatedRealmSession - DelegationError(Kind) -> DelegatedRealmSessionError(Kind) - delegationSignature -> delegatedRealmSessionSignature - verifyDelegationRequest -> verifyDelegatedRealmSessionRequest - DELEGATION_* headers -> DELEGATED_REALM_SESSION_* (and x-boxel-delegated-realm-session-* values) - Assistant.delegatedTokens -> delegatedRealmSessions The AI_BOT_DELEGATION_SECRET env var is left unchanged (external contract). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../user-delegated-realm-server-session.ts | Bin 4758 -> 4822 bytes packages/ai-bot/main.ts | 6 +- ...ser-delegated-realm-server-session-test.ts | 69 ++++++++++------- .../handlers/handle-delegate-session.ts | 15 ++-- .../server-endpoints/delegate-session-test.ts | 12 +-- .../user-delegated-realm-server-session.ts | 70 ++++++++++-------- 6 files changed, 98 insertions(+), 74 deletions(-) diff --git a/packages/ai-bot/lib/user-delegated-realm-server-session.ts b/packages/ai-bot/lib/user-delegated-realm-server-session.ts index 5aa6e97ddc764ae9d571f8f753575f303399bde3..82c264968fe16883e670b68cc7b6e48703355a8a 100644 GIT binary patch delta 154 zcmbQHdQEjgq+?KOVoq*wYH@L9ex44Of`UtGPHK8$NooqRglkbzei2Bvq_Q9tP07aQ sUyQ6^#hYuHUa)Z^>71O(Ig1;~oXyN!rap8^nz`259cfvkb=#IT&I{f Ov+)HmLb!?irOW^YEEeMc diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index 2156d66743c..cd48cd089ec 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -34,7 +34,7 @@ import { } from '@cardstack/runtime-common/matrix-constants'; import { handleDebugCommands } from './lib/debug.ts'; -import { DelegatedTokenManager } from './lib/user-delegated-realm-server-session.ts'; +import { DelegatedRealmSessionManager } from './lib/user-delegated-realm-server-session.ts'; import { Responder } from './lib/responder.ts'; import { shouldSetRoomTitle, @@ -88,7 +88,7 @@ class Assistant { // Mints user-scoped, read-only realm tokens on demand. Inert unless // AI_BOT_DELEGATION_SECRET is configured; the pull-model skill loader is its // consumer. - delegatedTokens: DelegatedTokenManager; + delegatedRealmSessions: DelegatedRealmSessionManager; constructor(client: MatrixClient, id: string, aiBotInstanceId: string) { this.openai = new OpenAI({ @@ -99,7 +99,7 @@ class Assistant { this.client = client; this.pgAdapter = new PgAdapter(); this.aiBotInstanceId = aiBotInstanceId; - this.delegatedTokens = new DelegatedTokenManager( + this.delegatedRealmSessions = new DelegatedRealmSessionManager( process.env.AI_BOT_DELEGATION_SECRET, ); } diff --git a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts index e4b4a53b1aa..ae7df6513b8 100644 --- a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts +++ b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts @@ -2,13 +2,13 @@ import QUnit from 'qunit'; const { module, test, assert } = QUnit; import { - requestDelegatedToken, - verifyDelegationRequest, - DelegationError, - DELEGATION_TIMESTAMP_HEADER, - DELEGATION_SIGNATURE_HEADER, + requestDelegatedRealmSession, + verifyDelegatedRealmSessionRequest, + DelegatedRealmSessionError, + DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, + DELEGATED_REALM_SESSION_SIGNATURE_HEADER, } from '@cardstack/runtime-common/user-delegated-realm-server-session'; -import { DelegatedTokenManager } from '../lib/user-delegated-realm-server-session.ts'; +import { DelegatedRealmSessionManager } from '../lib/user-delegated-realm-server-session.ts'; const SECRET = 'shared-secret-under-test'; const ON_BEHALF_OF = '@example-user:boxel.ai'; @@ -48,7 +48,7 @@ function jsonResponse(body: unknown, status = 200): Response { }); } -module('delegation client', () => { +module('delegated realm session client', () => { test('signs the request so the server verifier accepts it', async () => { let now = 1_700_000_000_000; let captured: RequestInit | undefined; @@ -61,7 +61,7 @@ module('delegation client', () => { }); }); - await requestDelegatedToken({ + await requestDelegatedRealmSession({ realmServerURL: 'https://realm.example.com', secret: SECRET, onBehalfOf: ON_BEHALF_OF, @@ -71,16 +71,16 @@ module('delegation client', () => { }); let headers = captured!.headers as Record; - let result = verifyDelegationRequest({ + let result = verifyDelegatedRealmSessionRequest({ secret: SECRET, - timestamp: headers[DELEGATION_TIMESTAMP_HEADER], - signature: headers[DELEGATION_SIGNATURE_HEADER], + timestamp: headers[DELEGATED_REALM_SESSION_TIMESTAMP_HEADER], + signature: headers[DELEGATED_REALM_SESSION_SIGNATURE_HEADER], rawBody: captured!.body as string, now, }); assert.true(result.ok, 'server verifier accepts the client signature'); assert.strictEqual( - headers[DELEGATION_TIMESTAMP_HEADER], + headers[DELEGATED_REALM_SESSION_TIMESTAMP_HEADER], String(now), 'timestamp header carries the signing time', ); @@ -94,7 +94,7 @@ module('delegation client', () => { permissions: ['read'], }), ); - await requestDelegatedToken({ + await requestDelegatedRealmSession({ realmServerURL: 'https://realm.example.com', secret: SECRET, onBehalfOf: ON_BEHALF_OF, @@ -109,7 +109,7 @@ module('delegation client', () => { assert.strictEqual(calls[0].init.method, 'POST'); }); - test('maps status codes to typed DelegationError kinds', async () => { + test('maps status codes to typed DelegatedRealmSessionError kinds', async () => { let cases: { status: number; kind: string }[] = [ { status: 503, kind: 'disabled' }, { status: 403, kind: 'forbidden' }, @@ -120,7 +120,7 @@ module('delegation client', () => { for (let { status, kind } of cases) { let { fetch } = recordingFetch(() => new Response('nope', { status })); try { - await requestDelegatedToken({ + await requestDelegatedRealmSession({ realmServerURL: 'https://realm.example.com', secret: SECRET, onBehalfOf: ON_BEHALF_OF, @@ -131,11 +131,11 @@ module('delegation client', () => { assert.true(false, `expected ${status} to throw`); } catch (e) { assert.true( - e instanceof DelegationError, - `${status} → DelegationError`, + e instanceof DelegatedRealmSessionError, + `${status} → DelegatedRealmSessionError`, ); assert.strictEqual( - (e as DelegationError).kind, + (e as DelegatedRealmSessionError).kind, kind, `${status} → ${kind}`, ); @@ -144,16 +144,16 @@ module('delegation client', () => { }); }); -module('DelegatedTokenManager', () => { +module('DelegatedRealmSessionManager', () => { test('is disabled and throws when no secret is configured', async () => { - let manager = new DelegatedTokenManager(undefined); + let manager = new DelegatedRealmSessionManager(undefined); assert.false(manager.enabled, 'manager reports disabled'); try { await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); assert.true(false, 'expected getToken to throw'); } catch (e) { - assert.true(e instanceof DelegationError); - assert.strictEqual((e as DelegationError).kind, 'disabled'); + assert.true(e instanceof DelegatedRealmSessionError); + assert.strictEqual((e as DelegatedRealmSessionError).kind, 'disabled'); } }); @@ -167,7 +167,10 @@ module('DelegatedTokenManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + let manager = new DelegatedRealmSessionManager(SECRET, { + fetch, + now: () => now, + }); let a = await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); let b = await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); @@ -187,7 +190,10 @@ module('DelegatedTokenManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + let manager = new DelegatedRealmSessionManager(SECRET, { + fetch, + now: () => now, + }); await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); @@ -204,7 +210,10 @@ module('DelegatedTokenManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + let manager = new DelegatedRealmSessionManager(SECRET, { + fetch, + now: () => now, + }); await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); await manager.getToken({ @@ -228,7 +237,10 @@ module('DelegatedTokenManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + let manager = new DelegatedRealmSessionManager(SECRET, { + fetch, + now: () => now, + }); await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: 'https://realm.example.com/u/example-user/', @@ -250,7 +262,10 @@ module('DelegatedTokenManager', () => { permissions: ['read'], }), ); - let manager = new DelegatedTokenManager(SECRET, { fetch, now: () => now }); + let manager = new DelegatedRealmSessionManager(SECRET, { + fetch, + now: () => now, + }); await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); manager.invalidate({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); await manager.getToken({ onBehalfOf: ON_BEHALF_OF, realm: REALM }); diff --git a/packages/realm-server/handlers/handle-delegate-session.ts b/packages/realm-server/handlers/handle-delegate-session.ts index e25ecd9b3b1..bfcc56665ba 100644 --- a/packages/realm-server/handlers/handle-delegate-session.ts +++ b/packages/realm-server/handlers/handle-delegate-session.ts @@ -17,9 +17,9 @@ import { setContextResponse, } from '../middleware/index.ts'; import { - DELEGATION_SIGNATURE_HEADER, - DELEGATION_TIMESTAMP_HEADER, - verifyDelegationRequest, + DELEGATED_REALM_SESSION_SIGNATURE_HEADER, + DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, + verifyDelegatedRealmSessionRequest, } from '@cardstack/runtime-common/user-delegated-realm-server-session'; // Token lifetime per the v1 security design (CS-11551): 30 minutes. Long @@ -32,8 +32,7 @@ const log = logger('realm:delegate-session'); // Mints a realm session JWT scoped to a named user's read access on a single // realm (CS-11552). Shared-secret authenticated (HMAC over the request body + // timestamp, see @cardstack/runtime-common/user-delegated-realm-server-session). -// The minted token -// carries only ['read'] +// The minted token carries only ['read'] // and is flagged `delegated` so the realm accepts it read-only regardless of // the user's broader permissions; it can never read anything the user // couldn't, and can never write. @@ -59,10 +58,10 @@ export default function handleDelegateSession({ let request = await fetchRequestFromContext(ctxt); let rawBody = await request.text(); - let auth = verifyDelegationRequest({ + let auth = verifyDelegatedRealmSessionRequest({ secret: aiBotDelegationSecret, - timestamp: ctxt.get(DELEGATION_TIMESTAMP_HEADER), - signature: ctxt.get(DELEGATION_SIGNATURE_HEADER), + timestamp: ctxt.get(DELEGATED_REALM_SESSION_TIMESTAMP_HEADER), + signature: ctxt.get(DELEGATED_REALM_SESSION_SIGNATURE_HEADER), rawBody, now: Date.now(), }); diff --git a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts index 838c225d6b7..6bd3c165add 100644 --- a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts +++ b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts @@ -14,9 +14,9 @@ import { testRealmURL, } from '../helpers/index.ts'; import { - DELEGATION_SIGNATURE_HEADER, - DELEGATION_TIMESTAMP_HEADER, - delegationSignature, + DELEGATED_REALM_SESSION_SIGNATURE_HEADER, + DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, + delegatedRealmSessionSignature, } from '@cardstack/runtime-common/user-delegated-realm-server-session'; const onBehalfOf = '@jane:localhost'; @@ -37,7 +37,7 @@ function signedPost( let timestamp = String(opts.timestamp ?? Date.now()); let signature = opts.signature ?? - delegationSignature( + delegatedRealmSessionSignature( opts.secret ?? aiBotDelegationSecret, timestamp, rawBody, @@ -46,10 +46,10 @@ function signedPost( .post('/_delegate-session') .set('Content-Type', 'application/json'); if (!opts.omitTimestamp) { - req = req.set(DELEGATION_TIMESTAMP_HEADER, timestamp); + req = req.set(DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, timestamp); } if (!opts.omitSignature) { - req = req.set(DELEGATION_SIGNATURE_HEADER, signature); + req = req.set(DELEGATED_REALM_SESSION_SIGNATURE_HEADER, signature); } return req.send(rawBody); } diff --git a/packages/runtime-common/user-delegated-realm-server-session.ts b/packages/runtime-common/user-delegated-realm-server-session.ts index 43fa917d69a..5b6c61a1315 100644 --- a/packages/runtime-common/user-delegated-realm-server-session.ts +++ b/packages/runtime-common/user-delegated-realm-server-session.ts @@ -9,27 +9,29 @@ import { createHmac, timingSafeEqual } from 'crypto'; // // This module is the single source of truth for the signed-payload format. // Both sides import from here so they can never drift: ai-bot calls -// `requestDelegatedToken`/`delegationSignature` to sign, and the realm -// server's /_delegate-session handler calls `verifyDelegationRequest` to +// `requestDelegatedRealmSession`/`delegatedRealmSessionSignature` to sign, and the realm +// server's /_delegate-session handler calls `verifyDelegatedRealmSessionRequest` to // verify — neither keeps its own copy of the canonical `${timestamp}.${rawBody}` // construction. It is imported via the // `@cardstack/runtime-common/user-delegated-realm-server-session` subpath by // node consumers only — it pulls in node `crypto`, so it is // deliberately not re-exported from the package barrel that browser code loads. -export const DELEGATION_TIMESTAMP_HEADER = 'x-boxel-delegation-timestamp'; -export const DELEGATION_SIGNATURE_HEADER = 'x-boxel-delegation-signature'; +export const DELEGATED_REALM_SESSION_TIMESTAMP_HEADER = + 'x-boxel-delegated-realm-session-timestamp'; +export const DELEGATED_REALM_SESSION_SIGNATURE_HEADER = + 'x-boxel-delegated-realm-session-signature'; // ±60s window on the request timestamp. Cheap and stateless — it bounds the // replay window for a captured request without a server-side nonce store. -export const DELEGATION_TIMESTAMP_WINDOW_MS = 60_000; +export const DELEGATED_REALM_SESSION_TIMESTAMP_WINDOW_MS = 60_000; // The canonical string both sides sign: `${timestamp}.${rawBody}`. `timestamp` // is epoch milliseconds in base-10; `rawBody` is the exact request body bytes. // HMAC-SHA256 with the shared secret, hex digest. Binding the timestamp into // the signed payload is what makes the ±60s window enforceable — a captured // request cannot have its timestamp rewritten without the secret. -export function delegationSignature( +export function delegatedRealmSessionSignature( secret: string, timestamp: string, rawBody: string, @@ -39,9 +41,11 @@ export function delegationSignature( .digest('hex'); } -export type DelegationAuthResult = { ok: true } | { ok: false; reason: string }; +export type DelegatedRealmSessionAuthResult = + | { ok: true } + | { ok: false; reason: string }; -export function verifyDelegationRequest({ +export function verifyDelegatedRealmSessionRequest({ secret, timestamp, signature, @@ -53,7 +57,7 @@ export function verifyDelegationRequest({ signature: string | undefined; rawBody: string; now: number; -}): DelegationAuthResult { +}): DelegatedRealmSessionAuthResult { if (!timestamp || !signature) { return { ok: false, @@ -64,13 +68,13 @@ export function verifyDelegationRequest({ if (!Number.isFinite(ts)) { return { ok: false, reason: 'malformed delegation timestamp' }; } - if (Math.abs(now - ts) > DELEGATION_TIMESTAMP_WINDOW_MS) { + if (Math.abs(now - ts) > DELEGATED_REALM_SESSION_TIMESTAMP_WINDOW_MS) { return { ok: false, reason: 'delegation timestamp is outside the allowed window', }; } - let expected = delegationSignature(secret, timestamp, rawBody); + let expected = delegatedRealmSessionSignature(secret, timestamp, rawBody); let expectedBuf = Buffer.from(expected, 'utf8'); let providedBuf = Buffer.from(signature, 'utf8'); // Constant-time compare. timingSafeEqual throws on a length mismatch, so @@ -87,7 +91,7 @@ export function verifyDelegationRequest({ // ─── Client ────────────────────────────────────────────────────────────── -export interface DelegatedSession { +export interface DelegatedRealmSession { token: string; realm: string; permissions: string[]; @@ -97,25 +101,31 @@ export interface DelegatedSession { // (the realm server has no secret configured, 503) is a "feature is off, carry // on" signal, whereas `forbidden` (the user has no read access, 403) and the // auth failures are genuine errors worth surfacing. -export type DelegationErrorKind = +export type DelegatedRealmSessionErrorKind = | 'disabled' // 503: endpoint not configured on the realm server | 'forbidden' // 403: onBehalfOf lacks read on the realm | 'unauthorized' // 401: signature/timestamp rejected | 'bad-request' // 400: malformed request | 'unexpected'; // anything else -export class DelegationError extends Error { - readonly kind: DelegationErrorKind; +export class DelegatedRealmSessionError extends Error { + readonly kind: DelegatedRealmSessionErrorKind; readonly status?: number; - constructor(kind: DelegationErrorKind, message: string, status?: number) { + constructor( + kind: DelegatedRealmSessionErrorKind, + message: string, + status?: number, + ) { super(message); - this.name = 'DelegationError'; + this.name = 'DelegatedRealmSessionError'; this.kind = kind; this.status = status; } } -function delegationErrorKindForStatus(status: number): DelegationErrorKind { +function delegatedRealmSessionErrorKindForStatus( + status: number, +): DelegatedRealmSessionErrorKind { switch (status) { case 503: return 'disabled'; @@ -137,7 +147,7 @@ function delegationErrorKindForStatus(status: number): DelegationErrorKind { // `realmServerURL` is the origin of the realm server that fronts `realm` // (ai-bot derives it as `new URL(realm).origin`). `now` and `fetch` are // injectable for tests. -export async function requestDelegatedToken({ +export async function requestDelegatedRealmSession({ realmServerURL, secret, onBehalfOf, @@ -151,14 +161,14 @@ export async function requestDelegatedToken({ realm: string; fetch?: typeof globalThis.fetch; now?: number; -}): Promise { +}): Promise { let endpoint = new URL( '_delegate-session', ensureTrailingSlash(realmServerURL), ); let rawBody = JSON.stringify({ onBehalfOf, realm }); let timestamp = String(now); - let signature = delegationSignature(secret, timestamp, rawBody); + let signature = delegatedRealmSessionSignature(secret, timestamp, rawBody); let response: Response; try { @@ -166,13 +176,13 @@ export async function requestDelegatedToken({ method: 'POST', headers: { 'content-type': 'application/json', - [DELEGATION_TIMESTAMP_HEADER]: timestamp, - [DELEGATION_SIGNATURE_HEADER]: signature, + [DELEGATED_REALM_SESSION_TIMESTAMP_HEADER]: timestamp, + [DELEGATED_REALM_SESSION_SIGNATURE_HEADER]: signature, }, body: rawBody, }); } catch (e: any) { - throw new DelegationError( + throw new DelegatedRealmSessionError( 'unexpected', `delegation request to ${endpoint.href} failed: ${e?.message ?? e}`, ); @@ -180,8 +190,8 @@ export async function requestDelegatedToken({ if (!response.ok) { let detail = await safeText(response); - throw new DelegationError( - delegationErrorKindForStatus(response.status), + throw new DelegatedRealmSessionError( + delegatedRealmSessionErrorKindForStatus(response.status), `delegation request rejected (${response.status})${ detail ? `: ${detail}` : '' }`, @@ -189,18 +199,18 @@ export async function requestDelegatedToken({ ); } - let session: DelegatedSession; + let session: DelegatedRealmSession; try { - session = (await response.json()) as DelegatedSession; + session = (await response.json()) as DelegatedRealmSession; } catch { - throw new DelegationError( + throw new DelegatedRealmSessionError( 'unexpected', 'delegation response was not valid JSON', response.status, ); } if (!session?.token) { - throw new DelegationError( + throw new DelegatedRealmSessionError( 'unexpected', 'delegation response did not include a token', response.status, From 98e276203ed29c4b84b652f6aa09cdda14acabab Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Wed, 24 Jun 2026 14:58:44 +0200 Subject: [PATCH 4/7] Reuse the shared ensureTrailingSlash helper The module had a private copy of ensureTrailingSlash identical to the one already exported from runtime-common/paths.ts. Import the shared helper instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../runtime-common/user-delegated-realm-server-session.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/runtime-common/user-delegated-realm-server-session.ts b/packages/runtime-common/user-delegated-realm-server-session.ts index 5b6c61a1315..bd0912111a4 100644 --- a/packages/runtime-common/user-delegated-realm-server-session.ts +++ b/packages/runtime-common/user-delegated-realm-server-session.ts @@ -1,4 +1,5 @@ import { createHmac, timingSafeEqual } from 'crypto'; +import { ensureTrailingSlash } from './paths.ts'; // Shared-secret authentication for the realm-server /_delegate-session // endpoint. ai-bot and the realm server hold a shared secret; ai-bot signs @@ -226,7 +227,3 @@ async function safeText(response: Response): Promise { return ''; } } - -function ensureTrailingSlash(url: string): string { - return url.endsWith('/') ? url : `${url}/`; -} From 4615eeaada5a64affd41575199edb3d047e9c2c9 Mon Sep 17 00:00:00 2001 From: Matic Jurglic Date: Wed, 24 Jun 2026 16:38:19 +0200 Subject: [PATCH 5/7] Inline the delegation error-body read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the safeText helper with an inline await response.text().catch(() => '') at its single call site. Same behavior — the realm server's error message is still folded into the thrown error, and a body that can't be read can't throw — without the extra function or the unused length cap. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../user-delegated-realm-server-session.ts | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/runtime-common/user-delegated-realm-server-session.ts b/packages/runtime-common/user-delegated-realm-server-session.ts index bd0912111a4..67fe0f322da 100644 --- a/packages/runtime-common/user-delegated-realm-server-session.ts +++ b/packages/runtime-common/user-delegated-realm-server-session.ts @@ -190,7 +190,7 @@ export async function requestDelegatedRealmSession({ } if (!response.ok) { - let detail = await safeText(response); + let detail = await response.text().catch(() => ''); throw new DelegatedRealmSessionError( delegatedRealmSessionErrorKindForStatus(response.status), `delegation request rejected (${response.status})${ @@ -219,11 +219,3 @@ export async function requestDelegatedRealmSession({ } return session; } - -async function safeText(response: Response): Promise { - try { - return (await response.text()).slice(0, 500); - } catch { - return ''; - } -} From b72264e8e016bd4335e750ffa8b86ae01a9efbef Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 25 Jun 2026 06:48:18 +0000 Subject: [PATCH 6/7] chore: address copilot review feedback on jwt decoding --- .../user-delegated-realm-server-session.ts | Bin 4822 -> 5006 bytes ...ser-delegated-realm-server-session-test.ts | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-bot/lib/user-delegated-realm-server-session.ts b/packages/ai-bot/lib/user-delegated-realm-server-session.ts index 82c264968fe16883e670b68cc7b6e48703355a8a..6e7d2dadf4b819a75858fc6ee4822ebef4d84bdc 100644 GIT binary patch delta 196 zcmXZUF$%&k7{&3WlY?h?i`b%2ENa13aBy%C!8K{}X{$9~Nm7cFH}EWickw)?;`aV; z_RIG##WVcI;^dAw3syB2by|XhN1vAeExVI~43oI4_NsR-=%PmUD#q&(_vG zL`Jc$%y|mOuq112j|Yd7pA&dQH-DCM;wn!i? TK~rE!L$S4_e0_h*s2zR*(~Ut0 delta 12 TcmeBEzoxn&nPsyyYcU@HA4LQl diff --git a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts index ae7df6513b8..8d5c5d86dde 100644 --- a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts +++ b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts @@ -16,11 +16,11 @@ const REALM = 'https://realm.example.com/u/example-user/'; // Builds a minimally-valid JWT carrying the given `exp` (epoch seconds) in its // payload — the manager only ever reads `exp` off the token, so the header and -// signature segments are placeholders. Payload is standard base64 to match the -// manager's `atob` decode. +// signature segments are placeholders. Payload uses base64url to match real +// JWT encoding (RFC 7515). function makeToken(expSeconds: number): string { let payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString( - 'base64', + 'base64url', ); return `header.${payload}.signature`; } From c7b61e1a7779bc22c7bfe670db01ab76969ba726 Mon Sep 17 00:00:00 2001 From: Fadhlan Ridhwanallah Date: Thu, 25 Jun 2026 07:03:51 +0000 Subject: [PATCH 7/7] Revert "chore: address copilot review feedback on jwt decoding" This reverts commit b72264e8e016bd4335e750ffa8b86ae01a9efbef. --- .../user-delegated-realm-server-session.ts | Bin 5006 -> 4822 bytes ...ser-delegated-realm-server-session-test.ts | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-bot/lib/user-delegated-realm-server-session.ts b/packages/ai-bot/lib/user-delegated-realm-server-session.ts index 6e7d2dadf4b819a75858fc6ee4822ebef4d84bdc..82c264968fe16883e670b68cc7b6e48703355a8a 100644 GIT binary patch delta 12 TcmeBEzoxn&nPsyyYcU@HA4LQl delta 196 zcmXZUF$%&k7{&3WlY?h?i`b%2ENa13aBy%C!8K{}X{$9~Nm7cFH}EWickw)?;`aV; z_RIG##WVcI;^dAw3syB2by|XhN1vAeExVI~43oI4_NsR-=%PmUD#q&(_vG zL`Jc$%y|mOuq112j|Yd7pA&dQH-DCM;wn!i? TK~rE!L$S4_e0_h*s2zR*(~Ut0 diff --git a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts index 8d5c5d86dde..ae7df6513b8 100644 --- a/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts +++ b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts @@ -16,11 +16,11 @@ const REALM = 'https://realm.example.com/u/example-user/'; // Builds a minimally-valid JWT carrying the given `exp` (epoch seconds) in its // payload — the manager only ever reads `exp` off the token, so the header and -// signature segments are placeholders. Payload uses base64url to match real -// JWT encoding (RFC 7515). +// signature segments are placeholders. Payload is standard base64 to match the +// manager's `atob` decode. function makeToken(expSeconds: number): string { let payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString( - 'base64url', + 'base64', ); return `header.${payload}.signature`; }