diff --git a/packages/ai-bot/lib/user-delegated-realm-server-session.ts b/packages/ai-bot/lib/user-delegated-realm-server-session.ts new file mode 100644 index 00000000000..82c264968fe Binary files /dev/null and b/packages/ai-bot/lib/user-delegated-realm-server-session.ts differ diff --git a/packages/ai-bot/main.ts b/packages/ai-bot/main.ts index 7cbddf4a632..cd48cd089ec 100644 --- a/packages/ai-bot/main.ts +++ b/packages/ai-bot/main.ts @@ -34,6 +34,7 @@ import { } from '@cardstack/runtime-common/matrix-constants'; import { handleDebugCommands } from './lib/debug.ts'; +import { DelegatedRealmSessionManager } from './lib/user-delegated-realm-server-session.ts'; import { Responder } from './lib/responder.ts'; import { shouldSetRoomTitle, @@ -84,6 +85,10 @@ class Assistant { pgAdapter: PgAdapter; id: string; aiBotInstanceId: string; + // 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. + delegatedRealmSessions: DelegatedRealmSessionManager; constructor(client: MatrixClient, id: string, aiBotInstanceId: string) { this.openai = new OpenAI({ @@ -94,6 +99,9 @@ class Assistant { this.client = client; this.pgAdapter = new PgAdapter(); this.aiBotInstanceId = aiBotInstanceId; + this.delegatedRealmSessions = new DelegatedRealmSessionManager( + process.env.AI_BOT_DELEGATION_SECRET, + ); } getResponse(prompt: PromptParts, senderMatrixUserId?: string) { diff --git a/packages/ai-bot/tests/index.ts b/packages/ai-bot/tests/index.ts index e31d569d96d..d078081a266 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 './user-delegated-realm-server-session-test.ts'; QUnit.start(); 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 new file mode 100644 index 00000000000..ae7df6513b8 --- /dev/null +++ b/packages/ai-bot/tests/user-delegated-realm-server-session-test.ts @@ -0,0 +1,274 @@ +import QUnit from 'qunit'; +const { module, test, assert } = QUnit; + +import { + requestDelegatedRealmSession, + verifyDelegatedRealmSessionRequest, + DelegatedRealmSessionError, + DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, + DELEGATED_REALM_SESSION_SIGNATURE_HEADER, +} from '@cardstack/runtime-common/user-delegated-realm-server-session'; +import { DelegatedRealmSessionManager } from '../lib/user-delegated-realm-server-session.ts'; + +const SECRET = 'shared-secret-under-test'; +const ON_BEHALF_OF = '@example-user:boxel.ai'; +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. +function makeToken(expSeconds: number): string { + let payload = Buffer.from(JSON.stringify({ exp: expSeconds })).toString( + 'base64', + ); + return `header.${payload}.signature`; +} + +// A fake fetch that records each call and returns a scripted Response. +function recordingFetch( + handler: (url: string, init: RequestInit) => 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('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; + let { fetch } = recordingFetch((_url, init) => { + captured = init; + return jsonResponse({ + token: makeToken(1), + realm: REALM, + permissions: ['read'], + }); + }); + + await requestDelegatedRealmSession({ + realmServerURL: 'https://realm.example.com', + secret: SECRET, + onBehalfOf: ON_BEHALF_OF, + realm: REALM, + fetch, + now, + }); + + let headers = captured!.headers as Record; + let result = verifyDelegatedRealmSessionRequest({ + secret: SECRET, + 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[DELEGATED_REALM_SESSION_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 requestDelegatedRealmSession({ + 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 DelegatedRealmSessionError 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 requestDelegatedRealmSession({ + 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 DelegatedRealmSessionError, + `${status} → DelegatedRealmSessionError`, + ); + assert.strictEqual( + (e as DelegatedRealmSessionError).kind, + kind, + `${status} → ${kind}`, + ); + } + } + }); +}); + +module('DelegatedRealmSessionManager', () => { + test('is disabled and throws when no secret is configured', async () => { + 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 DelegatedRealmSessionError); + assert.strictEqual((e as DelegatedRealmSessionError).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 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 }); + 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 DelegatedRealmSessionManager(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 DelegatedRealmSessionManager(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 DelegatedRealmSessionManager(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 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 }); + assert.strictEqual(calls.length, 2, 're-minted after invalidate'); + }); +}); diff --git a/packages/realm-server/handlers/handle-delegate-session.ts b/packages/realm-server/handlers/handle-delegate-session.ts index d0a684e6a0e..bfcc56665ba 100644 --- a/packages/realm-server/handlers/handle-delegate-session.ts +++ b/packages/realm-server/handlers/handle-delegate-session.ts @@ -17,10 +17,10 @@ import { setContextResponse, } from '../middleware/index.ts'; import { - DELEGATION_SIGNATURE_HEADER, - DELEGATION_TIMESTAMP_HEADER, - verifyDelegationRequest, -} from '../utils/delegation.ts'; + 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 // enough to span a tool call, short enough to bound how stale a revoked @@ -31,7 +31,8 @@ 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. @@ -57,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 040f1d21628..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,10 +14,10 @@ import { testRealmURL, } from '../helpers/index.ts'; import { - DELEGATION_SIGNATURE_HEADER, - DELEGATION_TIMESTAMP_HEADER, - delegationSignature, -} from '../../utils/delegation.ts'; + DELEGATED_REALM_SESSION_SIGNATURE_HEADER, + DELEGATED_REALM_SESSION_TIMESTAMP_HEADER, + delegatedRealmSessionSignature, +} from '@cardstack/runtime-common/user-delegated-realm-server-session'; const onBehalfOf = '@jane:localhost'; // A user with no permission rows on the test realm. @@ -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/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/user-delegated-realm-server-session.ts b/packages/runtime-common/user-delegated-realm-server-session.ts new file mode 100644 index 00000000000..67fe0f322da --- /dev/null +++ b/packages/runtime-common/user-delegated-realm-server-session.ts @@ -0,0 +1,221 @@ +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 +// 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 +// `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 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 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 delegatedRealmSessionSignature( + secret: string, + timestamp: string, + rawBody: string, +): string { + return createHmac('sha256', secret) + .update(`${timestamp}.${rawBody}`) + .digest('hex'); +} + +export type DelegatedRealmSessionAuthResult = + | { ok: true } + | { ok: false; reason: string }; + +export function verifyDelegatedRealmSessionRequest({ + secret, + timestamp, + signature, + rawBody, + now, +}: { + secret: string; + timestamp: string | undefined; + signature: string | undefined; + rawBody: string; + now: number; +}): DelegatedRealmSessionAuthResult { + 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) > DELEGATED_REALM_SESSION_TIMESTAMP_WINDOW_MS) { + return { + ok: false, + reason: 'delegation timestamp is outside the allowed window', + }; + } + 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 + // 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 DelegatedRealmSession { + 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 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 DelegatedRealmSessionError extends Error { + readonly kind: DelegatedRealmSessionErrorKind; + readonly status?: number; + constructor( + kind: DelegatedRealmSessionErrorKind, + message: string, + status?: number, + ) { + super(message); + this.name = 'DelegatedRealmSessionError'; + this.kind = kind; + this.status = status; + } +} + +function delegatedRealmSessionErrorKindForStatus( + status: number, +): DelegatedRealmSessionErrorKind { + 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 requestDelegatedRealmSession({ + 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 = delegatedRealmSessionSignature(secret, timestamp, rawBody); + + let response: Response; + try { + response = await fetch(endpoint.href, { + method: 'POST', + headers: { + 'content-type': 'application/json', + [DELEGATED_REALM_SESSION_TIMESTAMP_HEADER]: timestamp, + [DELEGATED_REALM_SESSION_SIGNATURE_HEADER]: signature, + }, + body: rawBody, + }); + } catch (e: any) { + throw new DelegatedRealmSessionError( + 'unexpected', + `delegation request to ${endpoint.href} failed: ${e?.message ?? e}`, + ); + } + + if (!response.ok) { + let detail = await response.text().catch(() => ''); + throw new DelegatedRealmSessionError( + delegatedRealmSessionErrorKindForStatus(response.status), + `delegation request rejected (${response.status})${ + detail ? `: ${detail}` : '' + }`, + response.status, + ); + } + + let session: DelegatedRealmSession; + try { + session = (await response.json()) as DelegatedRealmSession; + } catch { + throw new DelegatedRealmSessionError( + 'unexpected', + 'delegation response was not valid JSON', + response.status, + ); + } + if (!session?.token) { + throw new DelegatedRealmSessionError( + 'unexpected', + 'delegation response did not include a token', + response.status, + ); + } + return session; +}