Skip to content

Add realm-server /_delegate-session endpoint for user-scoped read-only JWTs#5287

Draft
lukemelia wants to merge 2 commits into
mainfrom
cs-11552-realm-server-delegation-endpoint
Draft

Add realm-server /_delegate-session endpoint for user-scoped read-only JWTs#5287
lukemelia wants to merge 2 commits into
mainfrom
cs-11552-realm-server-delegation-endpoint

Conversation

@lukemelia

Copy link
Copy Markdown
Contributor

What & why

New shared-secret-authenticated realm-server endpoint, POST /_delegate-session, that mints a 30-minute, single-realm, read-only JWT for a named user:

ai-bot → POST /_delegate-session   (HMAC over body + timestamp)
         { onBehalfOf: '@user:boxel.ai', realm: 'https://realm/u/jane/' }
       ← { token, realm, permissions: ["read"] }

ai-bot will use this (CS-11553 / CS-11554) to read a realm on a user's behalf without a blanket @aibot read grant — avoiding the confused-deputy exfiltration risk where any room member could point a skills state event at someone else's realm. Implements CS-11552; follows the v1 security design settled in CS-11551 (Spec §4).

How it works

  1. Auth — HMAC-SHA256 over ${timestamp}.${rawBody} with the shared secret, sent as x-boxel-delegation-signature + x-boxel-delegation-timestamp headers. Constant-time compare, ±60s timestamp window. The secret never crosses the wire, so the window genuinely bounds replays (TLS + rotation remain the defenses against leak — rotation tooling is CS-11567).
  2. Scope check — looks up onBehalfOf's permissions on the realm; requires read (else 403). The token grants exactly the read access the user already has.
  3. MintcreateJWT({ user: onBehalfOf, realm, permissions: ['read'], delegated: true }, '30m', realmSecretSeed).
  4. Audit — every request is logged with a correlation id and outcome.

Realm-side acceptance

A ["read"]-only token would otherwise be rejected by the realm's exact-permissions-match check (realm.ts) on a private realm where the user has write/owner perms. So assertRequestPermissions now accepts a delegated token only for read operations and only when the bound user actually has read — a write attempt gets 403, and the exact-match invariant is untouched for all normal sessions.

Decisions worth a reviewer's eye

  • AI_BOT_DELEGATION_SECRET is optional, not required in main.ts. Unset → the endpoint returns 503 and mints nothing, so deployments aren't forced to provision a secret for a feature nothing consumes yet. Provisioning/rotation lands with CS-11553 / CS-11567.
  • Spec's iss/aud/sub/jti claims are subsumed by the existing realm-token shape the verifier actually reads (user = subject, realm = audience, realmServerURL = issuer); the audit log is the forensic record jti was for.

Testing

  • tests/server-endpoints/delegate-session-test.ts — 12 tests: claim assertions, end-to-end realm read accepted for a realm-owner user, write rejected (read-only), auth failures (missing/invalid sig, wrong secret, stale/future timestamp), 403 no-read, 400 bad/missing body.
  • HMAC sign/verify logic unit-verified locally (8/8 cases incl. the ±60s edge).
  • ⚠️ The full QUnit suite (needs the dev services stack: test-pg + worker-manager + prerender) has not been run in this environment yet — type-check and lint are clean.

🤖 Generated with Claude Code

…y JWTs

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) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Host Test Results

    1 files  ±0      1 suites  ±0   1h 43m 56s ⏱️ + 1m 11s
3 113 tests ±0  3 098 ✅ ±0  15 💤 ±0  0 ❌ ±0 
3 132 runs  ±0  3 117 ✅ ±0  15 💤 ±0  0 ❌ ±0 

Results for commit 866653f. ± Comparison against earlier commit d9e1390.

Realm Server Test Results

    1 files  ±0      1 suites  ±0   9m 36s ⏱️ - 3m 30s
1 735 tests ±0  1 735 ✅ ±0  0 💤 ±0  0 ❌ ±0 
1 828 runs  ±0  1 828 ✅ ±0  0 💤 ±0  0 ❌ ±0 

Results for commit 866653f. ± Comparison against earlier commit d9e1390.

@lukemelia lukemelia left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Codex] Code review findings from a focused pass on delegated-token scope and permission parity.

// 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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Codex] This delegated-token branch does not check that the JWT's realm claim matches the realm currently handling the request (requestContext.realm.url / this.url). Because delegated tokens are signed with the realm-server seed and this branch skips the normal exact-permissions match, a token minted for realm A can be replayed against realm B on the same server whenever the bound user also has read on B. That breaks the advertised single-realm scope. Please reject delegated tokens whose normalized token.realm differs from the current realm before allowing the read.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Good catch — fixed in 866653f. The delegated branch in assertRequestPermissions now rejects a token whose normalized realm claim differs from the realm handling the request (ensureTrailingSlash(token.realm) !== ensureTrailingSlash(this.url)) before allowing the read, restoring single-realm scope. Added a test that mints a validly-signed delegated token for a different realm and asserts it's rejected (401) against this realm even though the bound user has read here.

userId: onBehalfOf,
onlyOwnRealms: false,
});
let userPermissions = permissionsForAllRealms[normalizedRealmHref] ?? [];

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Codex] This mint-time check uses fetchUserPermissions, which includes exact-user rows and public * grants, but the live realm authorizer also grants users permissions to Matrix users whose profile exists (RealmPermissionChecker.for). For a realm with users: ['read'], the user really can read the realm, but this endpoint will return 403 because there is no exact row for onBehalfOf. Since the endpoint is supposed to mint when the bound user actually has read, this should mirror RealmPermissionChecker or explicitly include validated users grants, with a test for that case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude Code 🤖] Agreed — fixed in 866653f. The mint-time check now uses RealmPermissionChecker (built from fetchRealmPermissions + the realm-server's matrixClient) and .can(onBehalfOf, 'read'), so it resolves *, exact rows, and users grants identically to the realm authorizer. A realm with users: ['read'] no longer 403s a profile-bearing user. Added two tests: minting succeeds for a users-grant user with a Matrix profile, and is denied when no profile exists.

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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant