From d9e1390d89a0644459188453c2b06acc77be8085 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 18 Jun 2026 21:32:11 -0400 Subject: [PATCH 1/2] Add realm-server /_delegate-session endpoint for user-scoped read-only JWTs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mints a 30-minute, single-realm, read-only JWT for a named user, authenticated by a shared-secret HMAC over the request body + timestamp (±60s replay window). ai-bot uses this to read a realm on a user's behalf without a blanket grant — avoiding the confused-deputy exfiltration risk of giving @aibot blanket realm read access (CS-11552; security design CS-11551). - utils/delegation.ts: HMAC-SHA256 sign/verify with a ±60s timestamp window, constant-time comparison; the secret never crosses the wire. - handlers/handle-delegate-session.ts: verify the signature, look up the named user's permissions on the realm, mint a `delegated` read-only token, and audit-log the outcome with a correlation id. - runtime-common/realm.ts: a `delegated` token is accepted only for read operations and only when the bound user actually has read, so the exact- permissions-match invariant stays intact for normal sessions. - AI_BOT_DELEGATION_SECRET is optional; when unset the endpoint returns 503. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handlers/handle-delegate-session.ts | 177 ++++++++++++++ packages/realm-server/main.ts | 7 + packages/realm-server/routes.ts | 9 + packages/realm-server/server.ts | 5 + packages/realm-server/tests/helpers/index.ts | 4 + .../server-endpoints/delegate-session-test.ts | 230 ++++++++++++++++++ packages/realm-server/utils/delegation.ts | 77 ++++++ packages/runtime-common/realm.ts | 33 +++ 8 files changed, 542 insertions(+) create mode 100644 packages/realm-server/handlers/handle-delegate-session.ts create mode 100644 packages/realm-server/tests/server-endpoints/delegate-session-test.ts create mode 100644 packages/realm-server/utils/delegation.ts diff --git a/packages/realm-server/handlers/handle-delegate-session.ts b/packages/realm-server/handlers/handle-delegate-session.ts new file mode 100644 index 0000000000..a299307564 --- /dev/null +++ b/packages/realm-server/handlers/handle-delegate-session.ts @@ -0,0 +1,177 @@ +import type Koa from 'koa'; +import { randomUUID } from 'crypto'; +import { + ensureTrailingSlash, + fetchUserPermissions, + logger, + SupportedMimeType, +} from '@cardstack/runtime-common'; +import type { CreateRoutesArgs } from '../routes.ts'; +import { createJWT } from '../jwt.ts'; +import { + fetchRequestFromContext, + sendResponseForBadRequest, + sendResponseForError, + sendResponseForUnauthorizedRequest, + setContextResponse, +} from '../middleware/index.ts'; +import { + DELEGATION_SIGNATURE_HEADER, + DELEGATION_TIMESTAMP_HEADER, + verifyDelegationRequest, +} from '../utils/delegation.ts'; + +// 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 +// realm permission can be when read through a delegated session. +const DELEGATED_TOKEN_TTL = '30m'; + +const log = logger('realm:delegate-session'); + +function headerValue( + headers: Koa.Context['req']['headers'], + name: string, +): string | undefined { + let value = headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +// 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'] +// 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. +export default function handleDelegateSession({ + dbAdapter, + realmSecretSeed, + serverURL, + aiBotDelegationSecret, +}: CreateRoutesArgs): (ctxt: Koa.Context, next: Koa.Next) => Promise { + return async function (ctxt: Koa.Context, _next: Koa.Next) { + if (!aiBotDelegationSecret) { + // No shared secret configured: the delegation endpoint is disabled. + await sendResponseForError( + ctxt, + 503, + 'Service Unavailable', + 'Delegation endpoint is not configured', + ); + return; + } + + let request = await fetchRequestFromContext(ctxt); + let rawBody = await request.text(); + + let auth = verifyDelegationRequest({ + secret: aiBotDelegationSecret, + timestamp: headerValue(ctxt.req.headers, DELEGATION_TIMESTAMP_HEADER), + signature: headerValue(ctxt.req.headers, DELEGATION_SIGNATURE_HEADER), + rawBody, + now: Date.now(), + }); + if (!auth.ok) { + log.warn(`delegation request rejected: ${auth.reason}`); + await sendResponseForUnauthorizedRequest(ctxt, auth.reason); + return; + } + + let json: Record; + try { + json = JSON.parse(rawBody); + } catch { + await sendResponseForBadRequest(ctxt, 'Request body is not valid JSON'); + return; + } + + let onBehalfOf = json?.onBehalfOf; + if (typeof onBehalfOf !== 'string' || onBehalfOf.length === 0) { + await sendResponseForBadRequest( + ctxt, + 'Request body must include a non-empty "onBehalfOf" string', + ); + return; + } + let realm = json?.realm; + if (typeof realm !== 'string' || realm.length === 0) { + await sendResponseForBadRequest( + ctxt, + 'Request body must include a non-empty "realm" string', + ); + return; + } + + let realmURL: URL; + try { + realmURL = new URL(realm); + } catch { + await sendResponseForBadRequest( + ctxt, + `"realm" is not a valid URL: ${realm}`, + ); + return; + } + // Normalise to the canonical realm-root form so the permission lookup hits + // the same realm_user_permissions row a write through other endpoints + // produced (they key by the trailing-slash href; see + // handle-upsert-realm-user-permission). + realmURL.search = ''; + realmURL.hash = ''; + let normalizedRealmHref = ensureTrailingSlash(realmURL.href); + + // Forensic audit record (security design CS-11551): every delegation + // request is logged with a correlation id, requester, and outcome. + let auditId = randomUUID(); + + let permissionsForAllRealms = await fetchUserPermissions(dbAdapter, { + userId: onBehalfOf, + onlyOwnRealms: false, + }); + let userPermissions = permissionsForAllRealms[normalizedRealmHref] ?? []; + if (!userPermissions.includes('read')) { + log.warn( + `[delegate-session ${auditId}] denied: user ${onBehalfOf} has no read access to ${normalizedRealmHref}`, + ); + await sendResponseForError( + ctxt, + 403, + 'Forbidden', + `User ${onBehalfOf} has no read access to ${normalizedRealmHref}`, + ); + return; + } + + let token = createJWT( + { + user: onBehalfOf, + realm: normalizedRealmHref, + permissions: ['read'], + sessionRoom: undefined, + realmServerURL: serverURL, + delegated: true, + }, + DELEGATED_TOKEN_TTL, + realmSecretSeed, + ); + + log.info( + `[delegate-session ${auditId}] granted: read-only session for ${onBehalfOf} on ${normalizedRealmHref}`, + ); + + await setContextResponse( + ctxt, + new Response( + JSON.stringify( + { + token, + realm: normalizedRealmHref, + permissions: ['read'], + }, + null, + 2, + ), + { headers: { 'content-type': SupportedMimeType.JSON } }, + ), + ); + }; +} diff --git a/packages/realm-server/main.ts b/packages/realm-server/main.ts index b8db76c556..78d8ddd97b 100644 --- a/packages/realm-server/main.ts +++ b/packages/realm-server/main.ts @@ -112,6 +112,12 @@ if (!REALM_SERVER_MATRIX_USERNAME) { const MATRIX_REGISTRATION_SHARED_SECRET = process.env.MATRIX_REGISTRATION_SHARED_SECRET; +// Shared secret authenticating ai-bot's delegation requests (CS-11552). +// Optional: when unset, the /_delegate-session endpoint responds 503 and mints +// nothing, so the pull-model feature stays inert until the secret is +// provisioned (and rotated, CS-11567) alongside ai-bot. +const AI_BOT_DELEGATION_SECRET = process.env.AI_BOT_DELEGATION_SECRET; + // Synapse admin credentials. Optional: only consumed by operator-action // endpoints that need to admin-impersonate a target user to read or write // their account_data on their behalf (synapse admin tokens can read but @@ -576,6 +582,7 @@ const smokeTestHostApp = async () => { realmServerSecretSeed: REALM_SERVER_SECRET_SEED, realmSecretSeed: REALM_SECRET_SEED, grafanaSecret: GRAFANA_SECRET, + aiBotDelegationSecret: AI_BOT_DELEGATION_SECRET, dbAdapter, queue, searchCache, diff --git a/packages/realm-server/routes.ts b/packages/realm-server/routes.ts index e319ee8f19..7156de38c8 100644 --- a/packages/realm-server/routes.ts +++ b/packages/realm-server/routes.ts @@ -38,6 +38,7 @@ import handleOpenRouterPassthrough from './handlers/handle-openrouter-passthroug import handlePostDeployment from './handlers/handle-post-deployment.ts'; import { handleCheckBoxelDomainAvailabilityRequest } from './handlers/handle-check-boxel-domain-availability.ts'; import handleRealmAuth from './handlers/handle-realm-auth.ts'; +import handleDelegateSession from './handlers/handle-delegate-session.ts'; import handleGetBoxelClaimedDomainRequest from './handlers/handle-get-boxel-claimed-domain.ts'; import handleClaimBoxelDomainRequest from './handlers/handle-claim-boxel-domain.ts'; import handleDeleteBoxelClaimedDomainRequest from './handlers/handle-delete-boxel-claimed-domain.ts'; @@ -84,6 +85,11 @@ export type CreateRoutesArgs = { realmServerSecretSeed: string; grafanaSecret: string; realmSecretSeed: string; + // Shared secret authenticating ai-bot's delegation requests (CS-11552). + // Optional: when unset, the /_delegate-session endpoint responds 503 rather + // than minting tokens, so the feature stays inert until a secret is + // provisioned. + aiBotDelegationSecret?: string; virtualNetwork: VirtualNetwork; queue: QueuePublisher; realms: Realm[]; @@ -289,6 +295,9 @@ export function createRoutes(args: CreateRoutesArgs) { jwtMiddleware(args.realmSecretSeed), handleRealmAuth(args), ); + // Shared-secret authenticated (HMAC over body + timestamp); auth is handled + // inside the handler because the signature covers the request body. + router.post('/_delegate-session', handleDelegateSession(args)); router.get( '/_check-boxel-domain-availability', jwtMiddleware(args.realmSecretSeed), diff --git a/packages/realm-server/server.ts b/packages/realm-server/server.ts index f2dcb358ba..ce11641512 100644 --- a/packages/realm-server/server.ts +++ b/packages/realm-server/server.ts @@ -931,6 +931,7 @@ export class RealmServer { private realmServerSecretSeed: string; private realmSecretSeed: string; private grafanaSecret: string; + private aiBotDelegationSecret: string | undefined; private realmsRootPath: string; private dbAdapter: DBAdapter; @@ -967,6 +968,7 @@ export class RealmServer { realmServerSecretSeed, realmSecretSeed, grafanaSecret, + aiBotDelegationSecret, realmsRootPath, dbAdapter, queue, @@ -989,6 +991,7 @@ export class RealmServer { realmServerSecretSeed: string; realmSecretSeed: string; grafanaSecret: string; + aiBotDelegationSecret?: string; realmsRootPath: string; dbAdapter: DBAdapter; queue: QueuePublisher; @@ -1029,6 +1032,7 @@ export class RealmServer { this.matrixClient = matrixClient; this.realmSecretSeed = realmSecretSeed; + this.aiBotDelegationSecret = aiBotDelegationSecret; this.realmServerSecretSeed = realmServerSecretSeed; this.grafanaSecret = grafanaSecret; this.realmsRootPath = realmsRootPath; @@ -1137,6 +1141,7 @@ export class RealmServer { realmServerSecretSeed: this.realmServerSecretSeed, realmSecretSeed: this.realmSecretSeed, grafanaSecret: this.grafanaSecret, + aiBotDelegationSecret: this.aiBotDelegationSecret, virtualNetwork: this.virtualNetwork, serveHostApp, serveIndex, diff --git a/packages/realm-server/tests/helpers/index.ts b/packages/realm-server/tests/helpers/index.ts index 27bdfca4d2..fd995c6abe 100644 --- a/packages/realm-server/tests/helpers/index.ts +++ b/packages/realm-server/tests/helpers/index.ts @@ -217,6 +217,7 @@ export const realmServerTestMatrix: MatrixConfig = { export const realmServerSecretSeed = "mum's the word"; export const realmSecretSeed = `shhh! it's a secret`; export const grafanaSecret = `shhh! it's a secret`; +export const aiBotDelegationSecret = `delegation shared secret for tests`; function getMatrixRegistrationSecret(): string { let secret = @@ -1330,6 +1331,7 @@ export async function runTestRealmServer({ queue: publisher, getIndexHTML, grafanaSecret, + aiBotDelegationSecret, serverURL: new URL(realmURL.origin), assetsURL: new URL(`http://example.com/notional-assets-host/`), domainsForPublishedRealms, @@ -1479,6 +1481,7 @@ export async function runTestRealmServerWithRealms({ queue: publisher, getIndexHTML, grafanaSecret, + aiBotDelegationSecret, serverURL, assetsURL: new URL(`http://example.com/notional-assets-host/`), domainsForPublishedRealms, @@ -2677,6 +2680,7 @@ async function buildBaseRealmTemplate( realmServerSecretSeed, realmSecretSeed, grafanaSecret, + aiBotDelegationSecret, matrixRegistrationSecret, realmsRootPath: dirSync().name, dbAdapter, diff --git a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts new file mode 100644 index 0000000000..2e4828eac2 --- /dev/null +++ b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts @@ -0,0 +1,230 @@ +import { module, test } from 'qunit'; +import { basename } from 'path'; +import type { Test, SuperTest } from 'supertest'; +import jwt from 'jsonwebtoken'; +import { SupportedMimeType, type TokenClaims } from '@cardstack/runtime-common'; +import type { PgAdapter } from '@cardstack/postgres'; +import { + aiBotDelegationSecret, + realmSecretSeed, + setupPermissionedRealmCached, + testRealmHref, + testRealmURL, +} from '../helpers/index.ts'; +import { + DELEGATION_SIGNATURE_HEADER, + DELEGATION_TIMESTAMP_HEADER, + delegationSignature, +} from '../../utils/delegation.ts'; + +const onBehalfOf = '@jane:localhost'; +// A user with no permission rows on the test realm. +const stranger = '@stranger:localhost'; + +module(`server-endpoints/${basename(__filename)}`, function () { + module('POST /_delegate-session', function (hooks) { + let request: SuperTest; + + setupPermissionedRealmCached(hooks, { + fixture: 'realistic', + // Deliberately no '*' read grant: reads on this realm require a valid + // JWT, so the delegated token's realm-side acceptance is actually + // exercised. `onBehalfOf` is realm-owner — broader than read — which is + // exactly the case the exact-permissions-match invariant would reject + // without the delegated-token handling in realm.ts. + permissions: { + [onBehalfOf]: ['read', 'write', 'realm-owner'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL: testRealmURL, + onRealmSetup: (args: { + request: SuperTest; + dbAdapter: PgAdapter; + }) => { + request = args.request; + }, + }); + + function postDelegation( + rawBody: string, + opts: { + timestamp?: number; + secret?: string; + signature?: string; + omitTimestamp?: boolean; + omitSignature?: boolean; + } = {}, + ) { + let timestamp = String(opts.timestamp ?? Date.now()); + let signature = + opts.signature ?? + delegationSignature( + opts.secret ?? aiBotDelegationSecret, + timestamp, + rawBody, + ); + let req = request + .post('/_delegate-session') + .set('Content-Type', 'application/json'); + if (!opts.omitTimestamp) { + req = req.set(DELEGATION_TIMESTAMP_HEADER, timestamp); + } + if (!opts.omitSignature) { + req = req.set(DELEGATION_SIGNATURE_HEADER, signature); + } + return req.send(rawBody); + } + + test('mints a read-only delegated token scoped to the user and realm', async function (assert) { + let response = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + ); + + assert.strictEqual(response.status, 200, 'HTTP 200'); + assert.strictEqual( + response.body.realm, + testRealmHref, + 'response echoes the normalized realm URL', + ); + assert.deepEqual( + response.body.permissions, + ['read'], + 'response reports read-only permissions', + ); + + let claims = jwt.verify( + response.body.token, + realmSecretSeed, + ) as TokenClaims & { + iat: number; + exp: number; + }; + assert.strictEqual(claims.user, onBehalfOf, 'token is bound to the user'); + assert.strictEqual( + claims.realm, + testRealmHref, + 'token is scoped to the realm', + ); + assert.deepEqual(claims.permissions, ['read'], 'token carries only read'); + assert.true(claims.delegated, 'token is flagged delegated'); + assert.strictEqual( + claims.exp - claims.iat, + 30 * 60, + 'token lives for 30 minutes', + ); + }); + + test('minted token authorizes a realm read even though the user is realm-owner', async function (assert) { + let mint = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + ); + assert.strictEqual(mint.status, 200, 'token minted'); + + let read = await request + .get('/friend.gts') + .set('Accept', SupportedMimeType.CardSource) + .set('Authorization', `Bearer ${mint.body.token}`); + assert.strictEqual( + read.status, + 200, + 'delegated token can read realm source', + ); + }); + + test('minted token cannot write to the realm', async function (assert) { + let mint = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + ); + assert.strictEqual(mint.status, 200, 'token minted'); + + let write = await request + .post('/') + .set('Accept', SupportedMimeType.CardJson) + .set('Content-Type', SupportedMimeType.CardJson) + .set('Authorization', `Bearer ${mint.body.token}`) + .send( + JSON.stringify({ + data: { + type: 'card', + attributes: {}, + meta: { + adoptsFrom: { + module: 'https://cardstack.com/base/card-api', + name: 'CardDef', + }, + }, + }, + }), + ); + assert.strictEqual( + write.status, + 403, + 'delegated token is rejected for write (read-only)', + ); + }); + + test('denies a user with no read access to the realm', async function (assert) { + let response = await postDelegation( + JSON.stringify({ onBehalfOf: stranger, realm: testRealmHref }), + ); + assert.strictEqual(response.status, 403, 'HTTP 403'); + }); + + test('rejects a request with no signature or timestamp', async function (assert) { + let response = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { omitSignature: true, omitTimestamp: true }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a request with an invalid signature', async function (assert) { + let response = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { signature: 'deadbeef'.repeat(8) }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a request signed with the wrong secret', async function (assert) { + let response = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { secret: 'not-the-shared-secret' }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a stale timestamp outside the ±60s window', async function (assert) { + let response = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { timestamp: Date.now() - 61_000 }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a timestamp too far in the future', async function (assert) { + let response = await postDelegation( + JSON.stringify({ onBehalfOf, realm: testRealmHref }), + { timestamp: Date.now() + 61_000 }, + ); + assert.strictEqual(response.status, 401, 'HTTP 401'); + }); + + test('rejects a body that is not valid JSON (signature still required)', async function (assert) { + let response = await postDelegation('this is not json'); + assert.strictEqual(response.status, 400, 'HTTP 400'); + }); + + test('rejects a body missing onBehalfOf', async function (assert) { + let response = await postDelegation( + JSON.stringify({ realm: testRealmHref }), + ); + assert.strictEqual(response.status, 400, 'HTTP 400'); + }); + + test('rejects a body missing realm', async function (assert) { + let response = await postDelegation(JSON.stringify({ onBehalfOf })); + assert.strictEqual(response.status, 400, 'HTTP 400'); + }); + }); +}); diff --git a/packages/realm-server/utils/delegation.ts b/packages/realm-server/utils/delegation.ts new file mode 100644 index 0000000000..dff76d13c8 --- /dev/null +++ b/packages/realm-server/utils/delegation.ts @@ -0,0 +1,77 @@ +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/realm.ts b/packages/runtime-common/realm.ts index 781114275a..11c1d20c60 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -579,6 +579,12 @@ export interface TokenClaims { sessionRoom: string | undefined; // TODO: remove when we create users on demand in ensureSessionRoom permissions: RealmPermissions['user']; realmServerURL: string; + // Set on tokens minted by the realm-server's /_delegate-session endpoint + // (CS-11552): a read-only session ai-bot uses to read a realm on behalf of + // a user. Unlike a normal session token, a delegated token carries only + // ['read'] even when the bound user has broader permissions, so request + // authorization treats it specially (read-only, no exact-permissions match). + delegated?: boolean; } export interface AdapterWriteResult { @@ -3697,6 +3703,33 @@ export class Realm { ); let user = token.user; + + // Delegated read-only session (minted by the realm-server's + // /_delegate-session endpoint for ai-bot — CS-11552). It is bound to a + // single user and deliberately scoped to ['read'] even when that user + // has broader permissions, so neither the exact-permissions-match + // invariant used for normal sessions below nor the assume-user + // indirection applies. Enforce instead the two guarantees the delegation + // design promises: the session is read-only, and it grants no more than + // the bound user can already read. + if (token.delegated) { + if (requiredPermission !== 'read') { + this.#log.warn( + `auth failed for ${request.method} ${request.url} (accept: ${request.headers.get('accept')}), delegated session for user ${user} attempted ${requiredPermission}; delegated sessions are read-only`, + ); + throw new AuthorizationError('Delegated sessions are read-only'); + } + if (!(await realmPermissionChecker.can(user, 'read'))) { + this.#log.warn( + `auth failed for ${request.method} ${request.url} (accept: ${request.headers.get('accept')}), delegated session for user ${user} but user lacks read permission`, + ); + throw new AuthenticationError( + AuthenticationErrorMessages.PermissionMismatch, + ); + } + return; + } + let assumedUser = request.headers.get('X-Boxel-Assume-User'); let didAssumeUser = false; if ( From 866653ffb12557934ec1ffaafa68d7fc4962390a Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Thu, 18 Jun 2026 23:42:54 -0400 Subject: [PATCH 2/2] Scope delegated tokens to a single realm and match the realm authorizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review findings on PR #5287: - realm.ts: reject a delegated token whose `realm` claim does not match the realm handling the request. Delegated tokens are signed with the shared realm-server seed and this branch skips the exact-permissions match, so without the check a token minted for realm A could be replayed against realm B on the same server whenever the bound user also has read on B. - handle-delegate-session.ts: decide read access with RealmPermissionChecker (exact row + `*` + `users` grants) rather than the raw realm_user_permissions rows, so the mint decision matches what the realm authorizer would accept — a `users: ['read']` realm no longer 403s a profile-bearing user. - Tests: cross-realm token rejection, and minting via a `users` grant (granted with a Matrix profile, denied without). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../handlers/handle-delegate-session.ts | 24 ++- .../server-endpoints/delegate-session-test.ts | 179 ++++++++++++++---- packages/runtime-common/realm.ts | 15 ++ 3 files changed, 169 insertions(+), 49 deletions(-) diff --git a/packages/realm-server/handlers/handle-delegate-session.ts b/packages/realm-server/handlers/handle-delegate-session.ts index a299307564..47a0f4cd4a 100644 --- a/packages/realm-server/handlers/handle-delegate-session.ts +++ b/packages/realm-server/handlers/handle-delegate-session.ts @@ -2,10 +2,11 @@ import type Koa from 'koa'; import { randomUUID } from 'crypto'; import { ensureTrailingSlash, - fetchUserPermissions, + fetchRealmPermissions, logger, SupportedMimeType, } from '@cardstack/runtime-common'; +import RealmPermissionChecker from '@cardstack/runtime-common/realm-permission-checker'; import type { CreateRoutesArgs } from '../routes.ts'; import { createJWT } from '../jwt.ts'; import { @@ -44,6 +45,7 @@ function headerValue( // couldn't, and can never write. export default function handleDelegateSession({ dbAdapter, + matrixClient, realmSecretSeed, serverURL, aiBotDelegationSecret, @@ -123,12 +125,20 @@ export default function handleDelegateSession({ // request is logged with a correlation id, requester, and outcome. let auditId = randomUUID(); - let permissionsForAllRealms = await fetchUserPermissions(dbAdapter, { - userId: onBehalfOf, - onlyOwnRealms: false, - }); - let userPermissions = permissionsForAllRealms[normalizedRealmHref] ?? []; - if (!userPermissions.includes('read')) { + // Mirror the realm's own authorizer (RealmPermissionChecker): the user can + // read if an exact permission row, the public `*` grant, or the `users` + // grant (any user with a Matrix profile) gives them read. Using the raw + // realm_user_permissions rows alone would 403 a user who can really read + // via a `users` grant, diverging from what the realm would accept. + let realmPermissions = await fetchRealmPermissions( + dbAdapter, + new URL(normalizedRealmHref), + ); + let permissionChecker = new RealmPermissionChecker( + realmPermissions, + matrixClient, + ); + if (!(await permissionChecker.can(onBehalfOf, 'read'))) { log.warn( `[delegate-session ${auditId}] denied: user ${onBehalfOf} has no read access to ${normalizedRealmHref}`, ); 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 2e4828eac2..040f1d2162 100644 --- a/packages/realm-server/tests/server-endpoints/delegate-session-test.ts +++ b/packages/realm-server/tests/server-endpoints/delegate-session-test.ts @@ -1,8 +1,10 @@ import { module, test } from 'qunit'; import { basename } from 'path'; import type { Test, SuperTest } from 'supertest'; +import sinon from 'sinon'; import jwt from 'jsonwebtoken'; import { SupportedMimeType, type TokenClaims } from '@cardstack/runtime-common'; +import { MatrixClient } from '@cardstack/runtime-common/matrix-client'; import type { PgAdapter } from '@cardstack/postgres'; import { aiBotDelegationSecret, @@ -21,6 +23,37 @@ const onBehalfOf = '@jane:localhost'; // A user with no permission rows on the test realm. const stranger = '@stranger:localhost'; +function signedPost( + request: SuperTest, + rawBody: string, + opts: { + timestamp?: number; + secret?: string; + signature?: string; + omitTimestamp?: boolean; + omitSignature?: boolean; + } = {}, +) { + let timestamp = String(opts.timestamp ?? Date.now()); + let signature = + opts.signature ?? + delegationSignature( + opts.secret ?? aiBotDelegationSecret, + timestamp, + rawBody, + ); + let req = request + .post('/_delegate-session') + .set('Content-Type', 'application/json'); + if (!opts.omitTimestamp) { + req = req.set(DELEGATION_TIMESTAMP_HEADER, timestamp); + } + if (!opts.omitSignature) { + req = req.set(DELEGATION_SIGNATURE_HEADER, signature); + } + return req.send(rawBody); +} + module(`server-endpoints/${basename(__filename)}`, function () { module('POST /_delegate-session', function (hooks) { let request: SuperTest; @@ -45,38 +78,9 @@ module(`server-endpoints/${basename(__filename)}`, function () { }, }); - function postDelegation( - rawBody: string, - opts: { - timestamp?: number; - secret?: string; - signature?: string; - omitTimestamp?: boolean; - omitSignature?: boolean; - } = {}, - ) { - let timestamp = String(opts.timestamp ?? Date.now()); - let signature = - opts.signature ?? - delegationSignature( - opts.secret ?? aiBotDelegationSecret, - timestamp, - rawBody, - ); - let req = request - .post('/_delegate-session') - .set('Content-Type', 'application/json'); - if (!opts.omitTimestamp) { - req = req.set(DELEGATION_TIMESTAMP_HEADER, timestamp); - } - if (!opts.omitSignature) { - req = req.set(DELEGATION_SIGNATURE_HEADER, signature); - } - return req.send(rawBody); - } - test('mints a read-only delegated token scoped to the user and realm', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), ); @@ -115,7 +119,8 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('minted token authorizes a realm read even though the user is realm-owner', async function (assert) { - let mint = await postDelegation( + let mint = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), ); assert.strictEqual(mint.status, 200, 'token minted'); @@ -132,7 +137,8 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('minted token cannot write to the realm', async function (assert) { - let mint = await postDelegation( + let mint = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), ); assert.strictEqual(mint.status, 200, 'token minted'); @@ -163,15 +169,44 @@ module(`server-endpoints/${basename(__filename)}`, function () { ); }); + test('a delegated token scoped to another realm is rejected (single-realm scope)', async function (assert) { + // A well-formed delegated token (validly signed with the realm-server + // seed) but minted for a different realm must not be accepted here, even + // though the bound user has read on this realm. + let foreignToken = jwt.sign( + { + user: onBehalfOf, + realm: 'http://some-other-realm.example/', + permissions: ['read'], + realmServerURL: testRealmURL.href, + delegated: true, + }, + realmSecretSeed, + { expiresIn: '30m' }, + ); + + let read = await request + .get('/friend.gts') + .set('Accept', SupportedMimeType.CardSource) + .set('Authorization', `Bearer ${foreignToken}`); + assert.strictEqual( + read.status, + 401, + 'token minted for another realm is rejected', + ); + }); + test('denies a user with no read access to the realm', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ onBehalfOf: stranger, realm: testRealmHref }), ); assert.strictEqual(response.status, 403, 'HTTP 403'); }); test('rejects a request with no signature or timestamp', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), { omitSignature: true, omitTimestamp: true }, ); @@ -179,7 +214,8 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('rejects a request with an invalid signature', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), { signature: 'deadbeef'.repeat(8) }, ); @@ -187,7 +223,8 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('rejects a request signed with the wrong secret', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), { secret: 'not-the-shared-secret' }, ); @@ -195,7 +232,8 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('rejects a stale timestamp outside the ±60s window', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), { timestamp: Date.now() - 61_000 }, ); @@ -203,7 +241,8 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('rejects a timestamp too far in the future', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ onBehalfOf, realm: testRealmHref }), { timestamp: Date.now() + 61_000 }, ); @@ -211,20 +250,76 @@ module(`server-endpoints/${basename(__filename)}`, function () { }); test('rejects a body that is not valid JSON (signature still required)', async function (assert) { - let response = await postDelegation('this is not json'); + let response = await signedPost(request, 'this is not json'); assert.strictEqual(response.status, 400, 'HTTP 400'); }); test('rejects a body missing onBehalfOf', async function (assert) { - let response = await postDelegation( + let response = await signedPost( + request, JSON.stringify({ realm: testRealmHref }), ); assert.strictEqual(response.status, 400, 'HTTP 400'); }); test('rejects a body missing realm', async function (assert) { - let response = await postDelegation(JSON.stringify({ onBehalfOf })); + let response = await signedPost(request, JSON.stringify({ onBehalfOf })); assert.strictEqual(response.status, 400, 'HTTP 400'); }); }); + + // A realm whose read access comes from the `users` grant (any Matrix user + // with a profile) rather than an exact per-user row. The endpoint must mint + // for such a user, matching what the realm authorizer would accept. + module('POST /_delegate-session — users grant', function (hooks) { + let request: SuperTest; + const karl = '@karl:localhost'; + + setupPermissionedRealmCached(hooks, { + fixture: 'realistic', + permissions: { + users: ['read'], + '@node-test_realm:localhost': ['read', 'realm-owner'], + }, + realmURL: testRealmURL, + onRealmSetup: (args: { + request: SuperTest; + dbAdapter: PgAdapter; + }) => { + request = args.request; + }, + }); + + hooks.afterEach(function () { + sinon.restore(); + }); + + test('mints for a user who can read via a `users` grant (no exact row)', async function (assert) { + sinon + .stub(MatrixClient.prototype, 'getProfile') + .resolves({ displayname: 'Karl' }); + + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf: karl, realm: testRealmHref }), + ); + assert.strictEqual(response.status, 200, 'HTTP 200'); + let claims = jwt.verify( + response.body.token, + realmSecretSeed, + ) as TokenClaims; + assert.strictEqual(claims.user, karl, 'token is bound to the user'); + assert.deepEqual(claims.permissions, ['read'], 'token carries only read'); + }); + + test('denies a `users`-grant realm when the user has no Matrix profile', async function (assert) { + sinon.stub(MatrixClient.prototype, 'getProfile').resolves(undefined); + + let response = await signedPost( + request, + JSON.stringify({ onBehalfOf: karl, realm: testRealmHref }), + ); + assert.strictEqual(response.status, 403, 'HTTP 403'); + }); + }); }); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 11c1d20c60..027b327cdc 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -3713,6 +3713,21 @@ export class Realm { // design promises: the session is read-only, and it grants no more than // the bound user can already read. if (token.delegated) { + // Single-realm scope. Delegated tokens are signed with the realm-server + // seed shared across every realm on this server and this branch skips + // the normal exact-permissions match, so without this check a token + // minted for realm A could be replayed against realm B whenever the + // bound user also has read on B. Bind the token to the realm it names. + if ( + ensureTrailingSlash(token.realm) !== ensureTrailingSlash(this.url) + ) { + this.#log.warn( + `auth failed for ${request.method} ${request.url} (accept: ${request.headers.get('accept')}), delegated session for user ${user} is scoped to realm ${token.realm}, not ${this.url}`, + ); + throw new AuthenticationError( + AuthenticationErrorMessages.TokenInvalid, + ); + } if (requiredPermission !== 'read') { this.#log.warn( `auth failed for ${request.method} ${request.url} (accept: ${request.headers.get('accept')}), delegated session for user ${user} attempted ${requiredPermission}; delegated sessions are read-only`,