Bind tenant/project into anonymous session JWTs#2924
Conversation
🦋 Changeset detectedLatest commit: 3509bb7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 10 packages
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 |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
TL;DR — Anonymous session JWTs for global apps (e.g. Key changes
Summary | 3 files | 2 commits | base: Tenant binding in anonymous session JWTs
|
There was a problem hiding this comment.
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.
Claude Opus | 𝕏
| tenantId: app.tenantId || anonTenantId || '', | ||
| projectId: app.projectId || anonProjectId || '', |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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:
- auth.ts:229-234 — Global app anonymous sessions embedding
tid/pidfrom request headers is untested - auth.ts:229-234 — Rolling refresh preserving original
tid/pid(preventing rebinding via different headers) is untested - runAuth.ts:797-798, 832-833 — Middleware extracting
tid/pidfrom 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:
- auth.test.ts — existing tenant-scoped test
- runAuth-appCredentialAuth.test.ts — global app authenticated path
🕐 Pending Recommendations (2)
- 🟠
runAuth.ts:832-833Pre-existing anonymous JWTs with nulltid/pidwill resolve to empty string instead of falling back to headers — confirm this is intentional and downstream code handlestenantId: '' - 🟡
auth.ts:229-234Token refresh preserves nulltid/pidfrom 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).
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]>
074bb72 to
3509bb7
Compare
There was a problem hiding this comment.
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-234Missing test coverage for security-critical anonymous JWT paths — global app sessions embeddingtid/pidfrom headers, rolling refresh preserving original values, and middleware using JWT claims instead of headers - 🟡
runAuth.ts:832-833Pre-existing anonymous JWTs with nulltid/pidwill 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.
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)Commit: Tell us how we did: Give Ito Feedback |
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]>
Summary
app_playground) embedded nulltid/pidfrom the app record, providing no tenant binding. The run auth middleware then fell back to request headers for tenant resolution.tid/pid— callers cannot change tenant binding by sending different headers on refreshtid/pidfrom the verified anonymous JWT instead of falling back to request headersTest plan
tid/pidfrom request headers for global appstid/pid(send different headers on refresh, confirm original values are kept)app.tenantId/app.projectIdin the JWTpnpm typecheckpasses🤖 Generated with Claude Code