Skip to content

fix: tenant auth for Nango actions + invitation error handling#2785

Open
omar-inkeep wants to merge 2 commits intomainfrom
cred-security-scan
Open

fix: tenant auth for Nango actions + invitation error handling#2785
omar-inkeep wants to merge 2 commits intomainfrom
cred-security-scan

Conversation

@omar-inkeep
Copy link
Contributor

Summary

  • Nango tenant auth: listNangoProviderIntegrations, deleteNangoIntegration, and updateNangoIntegrationCredentials now verify tenant membership via backend API before executing with the shared Nango secret. Adds key-ownership assertion to prevent cross-tenant manipulation.
  • Invitation error handling: getPendingInvitations returns a discriminated result type (success/error) instead of silently returning [] on failure. /no-organization page shows an error state with retry button instead of falsely reporting no invitations.

Test plan

  • Nango integration CRUD works end-to-end (list, create, update, delete)
  • /no-organization shows error + retry when invitation API is unreachable
  • pnpm build passes
  • pnpm check passes

🤖 Generated with Claude Code

…rors

- Nango server actions (list/update/delete) now verify tenant membership
  via backend API before executing privileged operations with shared secret
- getPendingInvitations returns discriminated result type instead of
  silently swallowing errors as empty arrays
- /no-organization page shows error state with retry on fetch failure

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Mar 20, 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 20, 2026 10:05pm
agents-manage-ui Ready Ready Preview, Comment Mar 20, 2026 10:05pm
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
agents-docs Skipped Skipped Mar 20, 2026 10:05pm

Request Review

@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 20, 2026

TL;DR — Adds tenant-scoped authorization checks to Nango integration actions (delete, list, update credentials) that previously relied only on the shared Nango secret key, and replaces silent error swallowing in the invitation-fetching flow with a discriminated result type and a user-facing error state.

Key changes

  • Add tenant auth guards to Nango actionsverifyTenantAccess and assertKeyBelongsToTenant now run before every mutating or listing Nango call, ensuring the authenticated user belongs to the tenant that owns the integration.
  • Surface invitation fetch errorsgetPendingInvitations returns a PendingInvitationsResult discriminated union instead of silently returning [] on failure, and the no-organization page renders an error card with a retry button.
  • Thread tenantId through credential pages — The provider setup page passes tenantId to updateNangoIntegrationCredentials and deleteNangoIntegration.
  • Harden retry UX and key-matching logic — Retry button disables during loading with a stale-request guard (requestIdRef), and assertKeyBelongsToTenant uses exact segment/suffix matching to prevent substring collisions.

Summary | 6 files | 2 commits | base: maincred-security-scan


Tenant authorization for Nango integration actions

Before: Nango actions (deleteNangoIntegration, listNangoProviderIntegrations, updateNangoIntegrationCredentials) used the global Nango secret key with no per-tenant authorization — any authenticated user could operate on any tenant's integrations.
After: Each action calls verifyTenantAccess (a lightweight authenticated backend request) and assertKeyBelongsToTenant (validates the integration key contains the tenant ID) before touching the Nango API.

verifyTenantAccess piggybacks on an existing authenticated endpoint (tenants/{tenantId}/projects?limit=1) to confirm session validity and tenant membership without adding a new route. assertKeyBelongsToTenant is a synchronous check using exact segment (-{tenantId}-) and suffix (-{tenantId}) matching to prevent substring collisions with similarly-named tenants.

Why validate the key format instead of looking up ownership in a database? Nango integration keys are constructed with the tenant ID embedded by convention. Since there is no Nango-side ownership table to query, checking the key format is the most reliable way to confirm the integration belongs to the claimed tenant without an extra round-trip.

nango.ts · page.tsx (provider setup)


Invitation error handling with discriminated result type

Before: getPendingInvitations caught all errors and returned [], making failures indistinguishable from "no invitations." The no-organization page had no error state.
After: The function returns { success: true, invitations } or { success: false, error }. The no-organization page shows an error card with a retry button when the fetch fails, and a stale-request guard prevents out-of-order responses from overwriting state.

The PendingInvitationsResult union type makes error handling explicit at every call site. The home page treats a failed fetch the same as "no invitations" and redirects to the no-organization page, where the user sees the error and can retry. The retry button disables during loading and shows "Checking..." text, with a requestIdRef counter discarding responses from superseded requests.

invitations.ts · no-organization/page.tsx · page.tsx (home)

Pullfrog  | View workflow run | Triggered by Pullfrogpullfrog.com𝕏

Copy link
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.

Good direction — the verifyTenantAccess calls and the discriminated result type for invitations are both solid improvements. One security concern with assertKeyBelongsToTenant needs attention before merge.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +34 to +37
function assertKeyBelongsToTenant(uniqueKey: string, tenantId: string): void {
if (!uniqueKey.includes(`-${tenantId}`)) {
throw new NangoError(`Integration key '${uniqueKey}' does not belong to tenant '${tenantId}'`);
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Security: includes() is too loose for ownership assertion. A tenant ID that is a substring of another tenant's ID will pass this check. For example, if tenant abc calls this with a key belonging to tenant abcdef, the check "github-abcdef-x8f2kq".includes("-abc") returns true.

The listing function at line 232 already uses a stricter startsWith(prefix) check with the provider name. This function should be at least as strict — ideally using an exact segment match. Since the key format is {provider}-{tenantId} or {provider}-{tenantId}-{suffix}, the safest approach that doesn't require the provider name is:

function assertKeyBelongsToTenant(uniqueKey: string, tenantId: string): void {
  const tenantSegment = `-${tenantId}-`;
  const tenantSuffix = `-${tenantId}`;
  // Key ends with tenantId (no suffix) OR contains tenantId followed by a delimiter
  if (!uniqueKey.endsWith(tenantSuffix) && !uniqueKey.includes(tenantSegment)) {
    throw new NangoError(`Integration key '${uniqueKey}' does not belong to tenant '${tenantId}'`);
  }
}

This handles both key formats ({provider}-{tenantId} and {provider}-{tenantId}-{suffix}) without the substring collision.

* Throws if the user is not authenticated or not a member of the tenant.
*/
async function verifyTenantAccess(tenantId: string): Promise<void> {
await makeManagementApiRequest(`tenants/${tenantId}/projects?limit=1`);
Copy link
Contributor

Choose a reason for hiding this comment

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

This is a creative approach to verify tenant membership — piggyback on an authenticated management API call. However, it's worth noting this is a read operation (GET projects?limit=1) used as an authorization gate. If this endpoint ever changes behavior (e.g., returns 200 for unauthenticated requests, or is cached at a CDN layer), the gate silently opens.

Consider adding a brief inline note that this is intentionally used as an auth gate so future readers don't accidentally break the invariant, or consider using a more explicit endpoint if one exists (e.g., a membership check).

}

// No invitations either - show no-org page
// No invitations (or failed to fetch) - show no-org page
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: the (or failed to fetch) parenthetical in this comment is accurate but could mislead — a failure to fetch invitations silently redirects to /no-organization, where the user will see the error state and can retry. This is fine behavior, but the comment should clarify the intent rather than just the condition: e.g., // No invitations or fetch failed — the no-org page handles both cases.

} else {
setInviteError(result.error);
}
setLoadingInvites(false);
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: When retry succeeds after a prior error, inviteError is cleared and pendingInvites is set — but if the result has 0 invitations, the user transitions from the error state to the "no organization" fallback at the bottom. This is correct behavior, just noting the flow is sound.

Copy link
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

(4) Total Issues | Risk: Medium

This PR adds important security controls for Nango tenant isolation and improves error handling for invitation fetching. The changes are well-structured but have a few issues that should be addressed before merging.

🟠⚠️ Major (2) 🟠⚠️

Inline Comments:

  • 🟠 Major: nango.ts:35 Weak substring matching in tenant ownership assertion allows potential bypass
  • 🟠 Major: no-organization/page.tsx:80 Retry button missing disabled state causes concurrent requests

🟡 Minor (2) 🟡

Inline Comments:

  • 🟡 Minor: invitations.ts:29 Missing server-side error logging for observability
  • 🟡 Minor: no-organization/page.tsx:46 Stale pendingInvites state not cleared on error

💭 Consider (2) 💭

💭 1) no-organization/page.tsx:69,98 Decorative icons missing aria-hidden

Issue: The AlertTriangle and Mail icons are decorative (content conveyed via adjacent text) but lack aria-hidden="true".

Why: Screen readers will announce the icon names before the headings, adding redundant information.

Fix: Add aria-hidden="true" to both icons.

💭 2) page.tsx:82-93 Silent redirect on invitation fetch failure

Issue: When getPendingInvitations fails in page.tsx, users are redirected to /no-organization without context that the check failed (comment on line 90 confirms: "No invitations (or failed to fetch)").

Why: The no-organization page now properly handles errors with retry UI, so this is acceptable UX. However, users experience a double-request pattern (fail on page.tsx → redirect → retry on no-organization).

Fix: Consider passing a query param like ?fetchFailed=true to provide immediate context, but this is a minor enhancement.

🧹 While You're Here (2) 🧹

These pre-existing issues were identified during review and are worth addressing alongside this PR:

🧹 1) nango.ts:173 fetchNangoIntegration lacks tenant authorization

Issue: This function fetches integration details (including masked credentials) using the shared Nango secret key without tenant verification. A caller who knows another tenant's integration key could retrieve credential metadata.

Why: This PR establishes a pattern of tenant auth for Nango operations. Extending it to fetchNangoIntegration would complete the security boundary.

Fix: Add tenantId parameter and call verifyTenantAccess(tenantId) + assertKeyBelongsToTenant(uniqueKey, tenantId).

🧹 2) nango.ts:415 createProviderConnectSession lacks tenant authorization

Issue: This function creates integrations and connect sessions without verifying tenant membership. The organizationId parameter is caller-supplied and not validated against authenticated context.

Why: Similar to above - completing the tenant auth pattern across all Nango operations would strengthen the security model.

Fix: Add required tenantId parameter and validate against authenticated session.


🚫 REQUEST CHANGES

Summary: The core security improvement (tenant auth for Nango delete/update/list) is good, but the substring-based ownership check at line 35 should use the same startsWith pattern as listNangoProviderIntegrations for consistency and defense in depth. The retry button also needs a disabled state to prevent concurrent requests.

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

Note: Standards reviewer finding on substring match deduplicated with security-iam reviewer.

Discarded (3)
Location Issue Reason Discarded
nango.ts:509 getNango*Metadata functions lack tenant auth Pre-existing issue not modified by this PR, and lower risk than other functions since connectionId/providerConfigKey are typically loaded from authorized credential records
no-organization/page.tsx:69 AlertTriangle icon missing aria-hidden Moved to Consider section
no-organization/page.tsx:98 Mail icon missing aria-hidden Duplicate of above, same issue pattern

* Integration keys follow the convention: {provider}-{tenantId}-{suffix}
*/
function assertKeyBelongsToTenant(uniqueKey: string, tenantId: string): void {
if (!uniqueKey.includes(`-${tenantId}`)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Weak substring matching allows potential tenant boundary bypass

Issue: Using .includes(-${tenantId}) for tenant ownership validation is vulnerable to substring collisions. A key like provider-abc123-suffix would incorrectly pass validation for tenant abc since it contains -abc.

Why: While tenant IDs are likely UUIDs making exploitation unlikely in practice, this check is inconsistent with the stricter startsWith pattern used in listNangoProviderIntegrations at line 234. For defense in depth, the assertion should use the same validation approach.

Fix: Align with the existing pattern by checking the prefix:

Suggested change
if (!uniqueKey.includes(`-${tenantId}`)) {
function assertKeyBelongsToTenant(uniqueKey: string, tenantId: string): void {
const expectedPrefix = `-${tenantId}`;
const providerEndIndex = uniqueKey.indexOf('-');
const keyAfterProvider = providerEndIndex >= 0 ? uniqueKey.slice(providerEndIndex) : '';
if (!keyAfterProvider.startsWith(expectedPrefix)) {
throw new NangoError(`Integration key '${uniqueKey}' does not belong to tenant '${tenantId}'`);
}
}

Refs:

</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button onClick={checkInvitations} className="w-full">
Copy link
Contributor

Choose a reason for hiding this comment

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

🟠 MAJOR: Retry button can trigger multiple concurrent requests

Issue: The "Try Again" button lacks a disabled state during the async operation. While checkInvitations sets loadingInvites(true), this state only controls the initial loading spinner (line 55), not this button.

Why: Rapid clicks will fire multiple concurrent API calls, potentially causing race conditions and unnecessary load.

Fix:

Suggested change
<Button onClick={checkInvitations} className="w-full">
<Button onClick={checkInvitations} disabled={loadingInvites} className="w-full">
{loadingInvites ? 'Checking...' : 'Try Again'}
</Button>

} catch {
return [];
return { success: true, invitations };
} catch (error) {
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Minor: Missing server-side error logging

Issue: Unlike verifyInvitation at line 67 which logs errors, this function returns errors without logging them.

Why: Without server-side logging, operators have no visibility into invitation fetch failure rates or causes, making it harder to diagnose patterns of failures.

Fix:

Suggested change
} catch (error) {
} catch (error) {
console.error('[getPendingInvitations] Error:', error);
const message = error instanceof ApiError ? error.message : 'Failed to load invitations';
return { success: false, error: message };

}))
);
} else {
setInviteError(result.error);
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 Minor: Stale state not cleared on error

Issue: When the invitation check fails, inviteError is set but pendingInvites is not cleared. If a user previously had a successful fetch, pendingInvites would contain stale data.

Why: While the conditional ordering at line 63 prevents display issues, maintaining inconsistent internal state can lead to subtle bugs during future refactors.

Fix:

Suggested change
setInviteError(result.error);
} else {
setInviteError(result.error);
setPendingInvites([]); // Clear stale state on error
}

@github-actions github-actions bot deleted a comment from claude bot Mar 20, 2026
@itoqa
Copy link

itoqa bot commented Mar 20, 2026

Ito Test Report ❌

19 test cases ran. 1 failed, 18 passed.

Across 6 report shards covering 19 test cases, 18 passed and 1 failed, indicating strong overall stability for no-organization routing, invitation-state handling, provider credential CRUD, authorization redirects, cross-tenant tampering protections, and session invalidation behavior. The key issue is a Medium-severity defect in /no-organization where rapid repeated Try Again clicks can trigger overlapping invitation fetches that resolve out of order, allowing a stale failed response to overwrite a newer successful one and leave users incorrectly stuck in an error state (likely introduced by the PR due to missing request cancellation/freshness guards).

❌ Failed (1)
Category Summary Screenshot
Edge 🟠 Rapid Try Again clicks can race invitation requests and leave an incorrect terminal error state. EDGE-1
🟠 Rapid retry-clicking does not produce inconsistent invitation state
  • What failed: The page can end in an error state after a successful invitation response because overlapping async retries resolve out of order; expected behavior is convergence to the latest successful terminal state.
  • Impact: Users can be shown a false failure even when invitations were loaded successfully, which blocks progression to accept invitation. This creates flaky, confusing recovery behavior under repeated retry attempts.
  • Introduced by this PR: Yes – this PR modified the relevant code
  • Steps to reproduce:
    1. Open /no-organization with invitation API forced to return an error.
    2. Configure retries so one request fails slowly and a later request succeeds quickly with invitation data.
    3. Click Try Again rapidly 5-10 times to create overlapping in-flight retries.
    4. Observe that the UI can finish in the error branch even after successful invitation data was returned.
  • Code analysis: I reviewed the retry logic in the no-organization page and invitation fetch action. The retry handler starts concurrent async calls without cancellation or freshness checks, and each call mutates shared state independently; a stale failure response can overwrite state after a newer success.
  • Why this is likely a bug: The production retry handler has no request-order guard, so stale async failures can deterministically override newer successful results during rapid retries.

Relevant code:

agents-manage-ui/src/app/no-organization/page.tsx (lines 27-49)

const checkInvitations = useCallback(async () => {
  if (!user?.email) {
    setLoadingInvites(false);
    return;
  }

  setLoadingInvites(true);
  setInviteError(null);

  const result = await getPendingInvitations(user.email);
  if (result.success) {
    setPendingInvites(
      result.invitations.map((inv) => ({
        id: inv.id,
        organizationName: inv.organizationName,
        role: inv.role,
      }))
    );
  } else {
    setInviteError(result.error);
  }
  setLoadingInvites(false);
}, [user?.email]);

agents-manage-ui/src/app/no-organization/page.tsx (lines 63-90)

if (inviteError) {
  return (
    <div className="flex min-h-screen items-center justify-center px-4 py-12">
      <Card className="w-full max-w-md shadow-none border-none bg-transparent space-y-3">
        <CardHeader>
          <div className="flex items-center gap-2">
            <AlertTriangle className="h-6 w-6 text-destructive" />
            <CardTitle className="text-2xl font-medium tracking-tight text-foreground">
              Unable to load invitations
            </CardTitle>
          </div>
✅ Passed (18)
Category Summary Screenshot
Adversarial Tenant segment tampering is denied with no cross-tenant integration exposure. ADV-1
Adversarial Delete attempt using a forged cross-tenant integration key is rejected. ADV-2
Adversarial Credential update attempt using a forged cross-tenant integration key is rejected. ADV-3
Adversarial Forbidden invitation access is handled by the no-organization error state without data leakage; no product bug confirmed. ADV-4
Adversarial Re-test confirms no product defect: with explicit invalidation marker, protected navigation redirects to login and stale privileged actions are blocked. ADV-5
Edge Rapid double-confirm delete produced a single destructive outcome with consistent UI state. EDGE-2
Edge Read-only access to provider setup is redirected to credentials list as expected. EDGE-3
Edge Mobile no-organization error UI remains usable with visible, tappable full-width CTAs at 390x844. EDGE-4
Edge Root/back/forward transitions re-run the guard and resolve cleanly without stale redirect loops. EDGE-5
Edge Refresh during invitation loading resolves to one terminal state and does not leave a permanent spinner. EDGE-6
Logic Editing OAuth app credentials completed successfully with UI refresh and success feedback. LOGIC-1
Logic Single-confirm delete removed the target OAuth app and showed expected success feedback. LOGIC-2
Happy-path Root route redirected to the first pending invitation as expected. ROUTE-1
Happy-path Root route fell back to no-organization when invitation fetch failed. ROUTE-2
Happy-path No-organization page showed invitation error UI with retry and sign-out actions. ROUTE-3
Happy-path Retry cleared the invitation error state and surfaced an invitation link. ROUTE-4
Happy-path No-organization empty state rendered correctly without error heading. ROUTE-5
Happy-path Provider setup loaded and displayed tenant-scoped github-default integrations without redirect. ROUTE-6

Commit: 805a694

View Full Run


Tell us how we did: Give Ito Feedback

- Fix substring collision in assertKeyBelongsToTenant (exact segment match)
- Add request-order guard to prevent stale retry responses overwriting state
- Disable retry button during loading to prevent concurrent requests
- Add inline comment on verifyTenantAccess auth gate intent
- Clear stale pendingInvites on error
- Clarify no-org redirect comment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@changeset-bot
Copy link

changeset-bot bot commented Mar 20, 2026

🦋 Changeset detected

Latest commit: ca6f39c

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-manage-ui Patch
@inkeep/agents-api 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

Copy link
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

(0) Total Issues | Risk: Low

This is a delta review scoped to changes since commit 805a694c14f6 (1 new commit: ca6f39cb0).

✅ Prior Feedback Addressed

The new commit comprehensively addresses all issues raised in the previous review:

Prior Issue Fix Applied
🟠 Weak substring matching.includes() vulnerable to tenant ID substring collisions ✅ Fixed with endsWith/includes(segment) pattern that correctly handles both {provider}-{tenantId} and {provider}-{tenantId}-{suffix} key formats
🟠 Retry button missing disabled state — could trigger concurrent requests ✅ Added disabled={loadingInvites} + loading text feedback
🟡 Stale pendingInvites not cleared on error — inconsistent internal state ✅ Added setPendingInvites([]) in error branch
🔴 Ito test failure — rapid retry clicks causing race condition ✅ Added requestIdRef pattern to discard stale responses

💭 Consider (1) 💭

💭 1) invitations.ts:29 Missing error logging remains unaddressed

Issue: The previous review flagged that getPendingInvitations catches errors without logging them, limiting observability for ops debugging.

Why: This is a minor enhancement and doesn't block the security fix. The discriminated result type is the more important improvement.

Fix: Consider adding server-side logging in a follow-up:

} catch (error) {
  console.error('[getPendingInvitations] Error:', error);
  const message = error instanceof ApiError ? error.message : 'Failed to load invitations';
  return { success: false, error: message };
}

🧹 While You're Here (2) 🧹

These pre-existing issues were noted in the previous review and remain unaddressed. They are optional enhancements that would complete the tenant auth pattern:

🧹 1) nango.ts:173 fetchNangoIntegration lacks tenant authorization — Could add tenant auth to prevent cross-tenant metadata reads.

🧹 2) nango.ts:415 createProviderConnectSession lacks tenant authorization — Could validate organizationId against authenticated session.


✅ APPROVE

Summary: Excellent follow-up commit! All Major and Minor issues from the previous review have been resolved. The substring matching fix is correctly implemented, the race condition is properly guarded with requestIdRef, and the UI now provides appropriate loading feedback. The Nango tenant isolation security improvement and invitation error handling are now both solid. 🎉

Reviewers (1)
Reviewer Returned Main Findings Consider While You're Here Inline Comments Pending Recs Discarded
orchestrator (delta) 4 0 1 2 0 0 0
Total 4 0 1 2 0 0 0

Note: Delta review performed directly by orchestrator — prior Major/Minor issues verified as resolved, no new domain-specific reviewers dispatched.

@github-actions github-actions bot deleted a comment from claude bot Mar 20, 2026
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