Skip to content

Bind tenant/project into anonymous session JWTs#2924

Merged
amikofalvy merged 1 commit intomainfrom
fix/anon-jwt-tenant-binding
Mar 31, 2026
Merged

Bind tenant/project into anonymous session JWTs#2924
amikofalvy merged 1 commit intomainfrom
fix/anon-jwt-tenant-binding

Conversation

@amikofalvy
Copy link
Copy Markdown
Collaborator

Summary

  • Security fix: Anonymous session JWTs for global apps (like app_playground) embedded null tid/pid from the app record, providing no tenant binding. The run auth middleware then fell back to request headers for tenant resolution.
  • Now embeds tenant/project from request headers at session creation time, locking the tenant scope into the JWT
  • Rolling refresh preserves the original JWT's tid/pid — callers cannot change tenant binding by sending different headers on refresh
  • Run auth middleware extracts tid/pid from the verified anonymous JWT instead of falling back to request headers

Test plan

  • Verify new anonymous session JWT contains tid/pid from request headers for global apps
  • Verify rolling refresh preserves original tid/pid (send different headers on refresh, confirm original values are kept)
  • Verify tenant-scoped apps still embed app.tenantId/app.projectId in the JWT
  • Verify run auth middleware uses JWT claims, not headers, for tenant resolution on anonymous path
  • pnpm typecheck passes
  • Pre-commit hooks pass

🤖 Generated with Claude Code

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 31, 2026

🦋 Changeset detected

Latest commit: 3509bb7

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@inkeep/agents-api Patch
@inkeep/agents-manage-ui Patch
@inkeep/agents-cli Patch
@inkeep/agents-core Patch
@inkeep/agents-email Patch
@inkeep/agents-mcp Patch
@inkeep/agents-sdk Patch
@inkeep/agents-work-apps Patch
@inkeep/ai-sdk-provider Patch
@inkeep/create-agents Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 31, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
agents-api Ready Ready Preview, Comment Mar 31, 2026 2:49am
agents-docs Ready Ready Preview, Comment Mar 31, 2026 2:49am
agents-manage-ui Ready Ready Preview, Comment Mar 31, 2026 2:49am

Request Review

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Mar 31, 2026

TL;DR — Anonymous session JWTs for global apps (e.g. app_playground) previously embedded null tid/pid from the app record, letting the run auth middleware fall back to request headers for tenant resolution. This PR locks tenant and project identity into the JWT at creation time and reads them back from verified claims — closing a tenant-binding gap.

Key changes

  • Embed tid/pid from request headers into anonymous JWTs for global apps — when the app record has no tenant/project, the session-creation endpoint now reads x-inkeep-tenant-id / x-inkeep-project-id headers and bakes them into the signed token.
  • Preserve original tid/pid on rolling refresh — on token refresh, the existing JWT's tenant/project claims are carried forward so callers cannot re-bind to a different tenant by sending new headers.
  • Resolve tenant from JWT claims in run auth middlewaretryAppCredentialAuth now extracts anonTenantId/anonProjectId from the verified anonymous JWT instead of falling back to reqData (request headers).

Summary | 3 files | 2 commits | base: mainfix/anon-jwt-tenant-binding


Tenant binding in anonymous session JWTs

Before: Global apps had null tenantId/projectId, so SignJWT embedded tid: null, pid: null. The run auth middleware fell back to request headers (reqData.tenantId) — meaning any caller could change tenant context after session creation.
After: The auth route resolves tid/pid from the app record first, then from request headers for global apps, and signs them into the JWT. On rolling refresh, the prior JWT's claims take precedence. The run auth middleware reads anonTenantId/anonProjectId from the verified token payload, removing the header fallback.

How does rolling refresh preserve tenant binding?

When an existing anonymous JWT is presented for refresh, the handler extracts refreshTenantId and refreshProjectId from the verified payload. These values take priority (via nullish coalescing) over the app record's fields when minting the new token, so a caller sending different x-inkeep-tenant-id / x-inkeep-project-id headers on a refresh request cannot rebind the session.

agents-api/src/domains/run/routes/auth.ts · agents-api/src/middleware/runAuth.ts

Pullfrog  | View workflow run | Triggered by Pullfrog | Using Claude Opus𝕏

Copy link
Copy Markdown
Contributor

@pullfrog pullfrog Bot left a comment

Choose a reason for hiding this comment

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

Solid security fix — the tenant-binding gap for global-app anonymous JWTs is real and the approach is correct. One concern about backwards compatibility with already-issued JWTs that have null tid/pid.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using Claude Opus𝕏

Comment thread agents-api/src/middleware/runAuth.ts Outdated
Comment on lines +832 to +833
tenantId: app.tenantId || anonTenantId || '',
projectId: app.projectId || anonProjectId || '',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

After this change, if a client presents a pre-fix anonymous JWT (where tid/pid were null), anonTenantId will be undefined and the fallback chain becomes app.tenantId || undefined || '' — resolving to '' for global apps.

Previously, the chain was app.tenantId || reqData.tenantId || '', which would pick up the header value.

This means existing anonymous sessions on global apps will silently get an empty-string tenant/project after deploy, which is likely the correct security posture (force re-auth). But worth confirming there's no downstream code that blows up on tenantId: '' — and whether you want to explicitly reject these stale tokens with a 401 instead of letting them through with empty scope.


const tenantId = isRefresh
? (refreshTenantId ?? appRecord.tenantId)
: appRecord.tenantId || c.req.header('x-inkeep-tenant-id') || null;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

For a new session (!isRefresh) on a global app where appRecord.tenantId is null, the header fallback c.req.header('x-inkeep-tenant-id') can also be undefined, making tenantId resolve to null. The JWT will then contain tid: null.

Downstream in runAuth.ts, typeof payload.tid === 'string' will be false, so anonTenantId stays undefined, and the auth result gets tenantId: ''.

This is a valid state for truly anonymous/unscoped sessions, but double-check that this path is intentional and tested — if global-app anonymous sessions must have a tenant binding, you may want to reject the session creation here when both appRecord.tenantId and the header are absent.

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(1) Total Issues | Risk: Medium

🟠⚠️ Major (1) 🟠⚠️

🟠 1) system Missing test coverage for security-critical anonymous JWT paths

Issue: The PR adds security-critical logic for binding tenant/project into anonymous session JWTs for global apps, but the existing test suite does not cover these new code paths. Specifically:

  1. auth.ts:229-234 — Global app anonymous sessions embedding tid/pid from request headers is untested
  2. auth.ts:229-234 — Rolling refresh preserving original tid/pid (preventing rebinding via different headers) is untested
  3. runAuth.ts:797-798, 832-833 — Middleware extracting tid/pid from anonymous JWT claims (instead of headers) for global apps is untested

The existing tests at auth.test.ts:103-106 and auth.test.ts:361-362 only verify tenant-scoped apps (where appRecord.tenantId is non-null). The runAuth-appCredentialAuth.test.ts:567-673 tests global apps but only the authenticated RS256 JWT path, not the anonymous HS256 JWT path that this PR modifies.

Why: Without tests for these paths:

  • A regression could silently break tenant isolation — anonymous JWTs would be issued with null tid/pid, and the middleware would fail to resolve tenant context
  • The security guarantee this PR claims (callers cannot rebind tenant on refresh) is not verified
  • Future refactors have no safety net for this critical auth flow

Fix: Add the following tests to auth.test.ts:

// 1. Test global app embeds tid/pid from headers
it('should embed tid/pid from request headers for global apps', async () => {
  // Create app with tenantId: null, projectId: null
  // Request session with x-inkeep-tenant-id and x-inkeep-project-id headers
  // Verify JWT contains those values (not null)
});

// 2. Test rolling refresh preserves original values
it('should preserve original tid/pid during refresh even with different headers', async () => {
  // Create session for global app with headers tenant-A/project-A
  // Refresh with Authorization header AND different headers tenant-B/project-B
  // Verify new JWT still has tenant-A/project-A
});

And to runAuth-appCredentialAuth.test.ts:

// 3. Test middleware uses JWT claims not headers
it('should use tid/pid from anonymous JWT claims for global apps', async () => {
  // Create global app
  // Mock anonymous JWT with tid/pid claims
  // Send request with DIFFERENT x-inkeep-tenant-id headers
  // Verify executionContext uses JWT claim values, not header values
});

Refs:


🕐 Pending Recommendations (2)

  • 🟠 runAuth.ts:832-833 Pre-existing anonymous JWTs with null tid/pid will resolve to empty string instead of falling back to headers — confirm this is intentional and downstream code handles tenantId: ''
  • 🟡 auth.ts:229-234 Token refresh preserves null tid/pid from pre-fix tokens — consider forcing re-binding on refresh or documenting that pre-fix token holders must clear session storage

💡 APPROVE WITH SUGGESTIONS

Summary: The security fix is sound and the implementation is correct — this properly addresses the tenant-binding gap for global app anonymous JWTs. The main recommendation is to add test coverage for the security-critical code paths before merging. Once tests are added, this provides a solid security improvement. The backward compatibility concern (pre-fix JWTs) was already raised by a prior reviewer and is a reasonable tradeoff for a security fix.


Discarded (0)

No findings discarded.

Reviewers (4)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-security-iam 0 0 0 0 0 0 0
pr-review-standards 0 0 0 0 0 0 0
pr-review-breaking-changes 2 0 0 0 0 2 0
pr-review-tests 4 1 0 0 0 0 3
Total 6 1 0 0 0 2 3

Note: IAM and Standards reviewers confirmed the implementation is correct. Breaking-changes findings were already raised by Pullfrog. Test findings consolidated into one Major issue (missing test coverage for security-critical paths).

@github-actions github-actions Bot deleted a comment from claude Bot Mar 31, 2026
@amikofalvy amikofalvy added this pull request to the merge queue Mar 31, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Mar 31, 2026
@amikofalvy amikofalvy enabled auto-merge March 31, 2026 02:44
For global apps, embed the tenant/project from request headers into the
anonymous session JWT at creation time. On refresh, preserve the original
JWT's tenant binding instead of re-reading from headers. In the run auth
middleware, extract tid/pid from the anonymous JWT payload and use them
for scope resolution instead of falling back to request headers.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

PR Review Summary

(2) Total Issues | Risk: Medium

🟠⚠️ Major (0) 🟠⚠️

No new Major issues identified.

🟡 Minor (0) 🟡

No new Minor issues identified.


🕐 Pending Recommendations (2)

  • 🟠 auth.ts:229-234 Missing test coverage for security-critical anonymous JWT paths — global app sessions embedding tid/pid from headers, rolling refresh preserving original values, and middleware using JWT claims instead of headers
  • 🟡 runAuth.ts:832-833 Pre-existing anonymous JWTs with null tid/pid will resolve to empty string instead of header fallback — confirm this is intentional for the security fix

💡 APPROVE WITH SUGGESTIONS

Summary: The security fix is well-implemented — tenant/project binding is now properly locked into anonymous JWTs at creation time, and refresh correctly preserves the original binding. The domain reviewers (IAM security, standards, tests) confirmed the implementation is sound with no new issues. The two pending recommendations from prior reviews remain relevant: (1) adding test coverage for the global app anonymous JWT paths would strengthen confidence in this security-critical code, and (2) the backward compatibility behavior for pre-existing JWTs should be explicitly acknowledged as intentional.


Discarded (0)

No findings discarded.

Reviewers (3)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
pr-review-security-iam 0 0 0 0 0 0 0
pr-review-standards 0 0 0 0 0 0 0
pr-review-tests 0 0 0 0 0 0 0
Total 0 0 0 0 0 0 0

Note: All reviewers confirmed the implementation is correct. Prior review findings (test coverage, backward compatibility) remain valid and are tracked in Pending Recommendations.

@github-actions github-actions Bot deleted a comment from claude Bot Mar 31, 2026
@amikofalvy amikofalvy added this pull request to the merge queue Mar 31, 2026
Merged via the queue into main with commit 704026c Mar 31, 2026
23 of 24 checks passed
@amikofalvy amikofalvy deleted the fix/anon-jwt-tenant-binding branch March 31, 2026 03:12
@itoqa
Copy link
Copy Markdown

itoqa Bot commented Mar 31, 2026

Ito Test Report ✅

18 test cases ran. 18 passed.

The unified run passed all 18/18 test cases with zero failures, confirming expected anonymous-session authentication behavior and no confirmed application bugs in the covered scenarios. Most importantly, tokens consistently enforced immutable JWT-bound tenant/project scope across mint, refresh, stale-tab reuse, downstream run endpoints, and concurrent/adversarial attempts (spoofed headers, malformed payloads, cross-app replay, and origin bypass), while invalid or expired refresh tokens safely minted new anonymous identities and mobile playground flows remained stable through rapid interactions and navigation loops.

✅ Passed (18)
Category Summary Screenshot
Adversarial Forged tenant/project headers during refresh did not alter scope; refreshed token remained bound to tenant-A/project-A. ADV-1
Adversarial Header override attack remained bound to token scope and did not expose forged-tenant data. ADV-2
Adversarial Replaying app A anonymous token against app B anonymous-session returned a token with app claim bound to app B and a different anonymous subject. ADV-3
Adversarial Disallowed origin request was rejected with 403 and control request from allowed localhost origin succeeded with 200. ADV-4
Adversarial Ten refresh calls with alternating attacker tenant/project headers all returned tokens fixed to original tenant-A/project-A scope; no scope fork observed. ADV-5
Adversarial Malformed scope-header payload (CRLF injection attempt + oversized project header) produced safe bounded behavior (HTTP 200 on allowed-origin fixture app), no crash, and resulting token remained bound to expected app scope without unauthorized tenant/project pivot. ADV-6
Edge Token minted without tenant/project claims was rejected by protected chat endpoint. EDGE-1
Edge API accepted malformed refresh input safely and minted a new anonymous subject distinct from the original token. EDGE-2
Edge A signed expired anonymous token was rejected for identity reuse and refresh returned a new subject with valid scoped claims. EDGE-3
Edge tokenA minted in tab A and tokenB refreshed in tab B remained bound to tenant-A/project-A; stale token run call with tenant-C/project-C spoofing resolved against project-A. EDGE-4
Edge Mobile playground initialized and remained usable through rapid submit/clear interactions without auth-loop failures. EDGE-6
Journey Refresh/back/forward loop preserved session continuity and allowed successful follow-up chat in mobile viewport. JOURNEY-1
Logic Refresh returned 200 and preserved sub/tid/pid while rotating iat/exp forward. LOGIC-1
Logic Refresh returned 200 and retained tenant-A/project-A despite forged tenant-X/project-X headers. LOGIC-2
Logic Forged tenant/project headers did not pivot downstream run scope away from JWT claims. LOGIC-3
Happy-path Anonymous-session returned 200 and token claims stayed bound to the tenant-scoped app despite spoofed headers. ROUTE-1
Happy-path Global app anonymous-session returned 200 and bound tid/pid to tenant-A/project-A from request headers. ROUTE-2
Happy-path PoW challenge succeeded and refresh with attacker headers preserved tenant-A/project-A scope. ROUTE-3

Commit: 3509bb7

View Full Run


Tell us how we did: Give Ito Feedback

tim-inkeep pushed a commit that referenced this pull request Mar 31, 2026
For global apps, embed the tenant/project from request headers into the
anonymous session JWT at creation time. On refresh, preserve the original
JWT's tenant binding instead of re-reading from headers. In the run auth
middleware, extract tid/pid from the anonymous JWT payload and use them
for scope resolution instead of falling back to request headers.

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
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