Skip to content

fix: set initiatedBy for app credential auth to resolve user-scoped MCP credentials#2912

Merged
amikofalvy merged 2 commits intomainfrom
fix/app-credential-initiatedby
Mar 30, 2026
Merged

fix: set initiatedBy for app credential auth to resolve user-scoped MCP credentials#2912
amikofalvy merged 2 commits intomainfrom
fix/app-credential-initiatedby

Conversation

@amikofalvy
Copy link
Copy Markdown
Collaborator

Summary

  • App credential auth paths (authenticated and anonymous web_client) set endUserId but not initiatedBy in the execution context metadata
  • getUserIdFromContext checks initiatedBy.type === 'user' first — when it's missing, userId is undefined
  • For user-scoped MCP tools (e.g. Linear OAuth), this causes the isUserScoped && userId check in AgentMcpManager.getToolSet() to evaluate to false, skipping the user-scoped credential lookup entirely and resulting in no-cred / 401 from the MCP server
  • Follow-up to fix: resolve userId for user-scoped MCP credentials in playground auth path #2899 which fixed the playground temp-JWT auth path but missed the app credential paths

Test plan

  • Invoke a user-scoped MCP tool (e.g. Linear) via an app credential (web_client) authenticated session
  • Verify the no-cred cache key segment is replaced with the actual credential reference
  • Verify the MCP connection succeeds with the user's OAuth token

Made with Cursor

…CP credentials

The app credential auth paths (both authenticated and anonymous web_client) were
setting endUserId but not initiatedBy in the execution context metadata.
getUserIdFromContext checks initiatedBy first, so user-scoped MCP tools (like
Linear OAuth) couldn't resolve credentials — the userId was undefined, causing
the user-scoped credential lookup to be skipped entirely.

This is a follow-up to #2899 which fixed the playground auth path but missed the
app credential paths.

Made-with: Cursor
@vercel
Copy link
Copy Markdown

vercel Bot commented Mar 30, 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 30, 2026 8:48pm
agents-docs Ready Ready Preview, Comment Mar 30, 2026 8:48pm
agents-manage-ui Ready Ready Preview, Comment Mar 30, 2026 8:48pm

Request Review

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Mar 30, 2026

🦋 Changeset detected

Latest commit: 25d1a56

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

@pullfrog
Copy link
Copy Markdown
Contributor

pullfrog Bot commented Mar 30, 2026

TL;DR — App credential auth paths (web_client authenticated and anonymous) were missing the initiatedBy field in execution context metadata, causing getUserIdFromContext to return undefined and breaking user-scoped MCP credential lookups (e.g. Linear OAuth). This adds initiatedBy to both code paths so the downstream userId resolution works correctly.

Key changes

  • Set initiatedBy in tryAppCredentialAuth for both authenticated and anonymous web_client paths — the authenticated path always sets it from endUserId; the anonymous path conditionally sets it when endUserId is present. This is a follow-up to fix: resolve userId for user-scoped MCP credentials in playground auth path #2899 which fixed the playground temp-JWT path but missed these two.
  • Add changeset — patch bump for @inkeep/agents-api describing the fix.

Summary | 2 files | 2 commits | base: mainfix/app-credential-initiatedby

Before: tryAppCredentialAuth set endUserId in metadata but not initiatedBy, so getUserIdFromContext — which checks initiatedBy.type === 'user' first — returned undefined. User-scoped MCP tools failed with no-cred / 401 because isUserScoped && userId evaluated to false.
After: Both the authenticated JWT return (line 765) and the anonymous fallback return (line 834) now include initiatedBy: { type: 'user', id: endUserId }, matching the pattern already used by temp-JWT, bypass, and Slack auth strategies.

Why are the two additions shaped differently? The authenticated path (line 765) always has a verified `endUserId` from the JWT `sub` claim, so `initiatedBy` is set unconditionally. The anonymous path (line 834) may not have an `endUserId` if the anonymous JWT lacks a `sub`, so it uses a conditional spread to avoid setting `initiatedBy` with an `undefined` id.

runAuth.ts · fix-app-credential-initiatedby.md

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

Made-with: Cursor
@amikofalvy amikofalvy enabled auto-merge March 30, 2026 20:46
@github-actions github-actions Bot deleted a comment from claude Bot Mar 30, 2026
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.

Clean, correct fix. Both change sites are well-guarded: the authenticated path (line 765) correctly sets initiatedBy unconditionally since endUserId is guaranteed non-empty from tryAsymmetricJwtVerification, and the anonymous fallback path (line 834) correctly uses a conditional spread since payload.sub may be undefined.

Pullfrog  | View workflow run | Using Claude Opus𝕏

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

(0) Total Issues | Risk: Low

Analysis

This PR adds initiatedBy metadata to the two app credential auth paths (web_client authenticated and anonymous) in tryAppCredentialAuth. This is a follow-up to #2899, which added a fallback in getUserIdFromContext() to check endUserId when initiatedBy is missing.

What the fix does:

  1. Authenticated path (line 765): Sets initiatedBy: { type: 'user', id: endUserId } unconditionally — safe because endUserId is guaranteed non-empty from the verified JWT's sub claim.
  2. Anonymous path (line 834): Sets initiatedBy conditionally via spread — correctly handles the case where payload.sub may be undefined.

Why it's correct:

  • Normalizes auth metadata shape across all strategies (temp-JWT, Slack, bypass, team-agent all set initiatedBy)
  • No security implications — the endUserId values were already being set and trusted; this change simply propagates them into the canonical field
  • The credential lookup in AgentMcpManager.getToolSet() is already tenant/project-scoped, so no cross-tenant access is possible

Changeset: Properly formatted and accurately describes the change.


✅ APPROVE

Summary: Clean, minimal fix that normalizes auth metadata across strategies. The implementation is correct, type-safe, and consistent with existing patterns. Ship it! 🚀

Note: Unable to submit formal approval due to GitHub App permissions — this is an approval recommendation.

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

@github-actions github-actions Bot deleted a comment from claude Bot Mar 30, 2026
@amikofalvy amikofalvy added this pull request to the merge queue Mar 30, 2026
Merged via the queue into main with commit 7dc35b6 Mar 30, 2026
21 of 22 checks passed
@amikofalvy amikofalvy deleted the fix/app-credential-initiatedby branch March 30, 2026 21:04
@itoqa
Copy link
Copy Markdown

itoqa Bot commented Mar 30, 2026

Ito Test Report ❌

10 test cases ran. 3 failed, 7 passed.

Overall, the unified run executed 10 test cases with 7 passes and 3 failures, indicating generally solid auth boundary behavior but with meaningful gaps that should block a full pass. The key confirmed defects were: anonymous global web_client sessions can lose tenant/project context and fail chat (High), invalid/expired asymmetric JWTs do not actually fall back to anonymous mode when allowed (Medium), and disallowed-origin web_client requests can still proceed in development/test due to default-context fallback (Medium), while other checks passed including no-endUserId handling, strict token/scope/app-claim rejection paths, prompt-injection non-disclosure, and refresh/rapid-submit stability.

❌ Failed (3)
Category Summary Screenshot
Adversarial 🟠 Disallowed Origin requests are not rejected in development/test mode and continue into downstream app/project handling instead of failing at origin auth. ADV-1
Edge 🟠 Invalid/expired asymmetric JWT returns 401 instead of degrading to anonymous flow when allowed. EDGE-1
Happy-path ⚠️ Anonymous global web_client session token leads to chat failure with missing tenant context. ROUTE-3
🟠 Disallowed Origin requests bypass rejection in development/test auth fallback
  • What failed: The disallowed-origin request should be rejected by auth, but it proceeds past origin validation and reaches downstream handling (for example, a project lookup failure) when the auth attempt returns no context in development/test mode.
  • Impact: Security-focused origin checks can be bypassed during development/test verification, so hostile-origin requests are not reliably blocked where they should be. This weakens confidence in pre-production auth boundary testing and can mask origin-policy regressions.
  • Introduced by this PR: No – pre-existing bug (code not changed in this PR)
  • Steps to reproduce:
    1. Configure a web_client app with an allowed domain list.
    2. Send one request from an allowed origin and one from a disallowed origin using a valid bearer token and x-inkeep-app-id.
    3. Compare responses and observe that the disallowed-origin request is not rejected at auth and proceeds to downstream handling.
  • Stub / mock context: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code analysis: I reviewed the app-credential auth flow and origin validator. tryAppCredentialAuth correctly flags disallowed origins, but in development/test middleware handling, any auth attempt without authResult falls back to createDevContext, allowing request processing to continue instead of returning an auth error.
  • Why this is likely a bug: The code explicitly marks disallowed origins as auth failure, but the dev/test branch converts that failure into an executable default context, which contradicts origin enforcement behavior expected by the feature.

Relevant code:

agents-api/src/middleware/runAuth.ts (lines 605-614)

if (app.type === 'web_client') {
  const config = app.config as WebClientConfig;

  if (!validateOrigin(origin, config.webClient.allowedDomains)) {
    logger.warn(
      { origin, allowedDomains: config.webClient.allowedDomains, appId: app.id },
      'App credential auth: origin not allowed'
    );
    return { authResult: null, failureMessage: 'Origin not allowed for this app' };
  }

agents-api/src/middleware/runAuth.ts (lines 953-975)

// Development/test environment handling
if (isDev) {
  logger.info({}, 'development environment');

  const attempt = await authenticateRequest(reqData);

  if (attempt.authResult) {
    c.set('executionContext', buildExecutionContext(attempt.authResult, reqData));
  } else {
    logger.info(
      {},
      reqData.apiKey
        ? 'Development/test environment - fallback to default context due to invalid API key'
        : 'Development/test environment - no API key provided, using default context'
    );
    c.set('executionContext', buildExecutionContext(createDevContext(reqData), reqData));
  }
}
🟠 Allow-anonymous asymmetric auth path still hard-fails invalid tokens
  • What failed: The request returns 401 Invalid or expired token instead of degrading to the anonymous flow when allowAnonymous permits fallback.
  • Impact: Clients presenting a stale or malformed asymmetric token cannot recover through the intended anonymous path. This causes avoidable auth failures and degraded chat availability.
  • Introduced by this PR: No – pre-existing bug (code not changed in this PR)
  • Steps to reproduce:
    1. Configure a web_client app with asymmetric auth keys and allowAnonymous enabled.
    2. Call POST /run/api/chat with x-inkeep-app-id: app_playground and an invalid or expired asymmetric bearer token.
    3. Observe the response status and error payload.
    4. Expected behavior is anonymous fallback, but actual behavior is 401 Invalid or expired token.
  • Stub / mock context: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code analysis: I inspected the asymmetric verification branch in tryAppCredentialAuth. When asymmetric verification fails and anonymous is allowed, the code logs that it will fall through, but the subsequent anonymous JWT verification block throws 401 whenever auth keys are configured, effectively overriding the intended fallback.
  • Why this is likely a bug: The fallback path is contradicted by a later unconditional unauthorized throw under hasAuthConfigured, so the implementation cannot satisfy its own allow-anonymous behavior.

Relevant code:

agents-api/src/middleware/runAuth.ts (lines 631-644)

if (!asymResult.ok) {
  const allowAnonymous = config.webClient.auth?.allowAnonymous !== false;
  if (!allowAnonymous) {
    logger.debug(
      { appId: app.id, reason: asymResult.failureMessage },
      'Asymmetric JWT verification failed, anonymous not allowed'
    );
    throw createApiError({ code: 'unauthorized', message: asymResult.failureMessage });
  }
  logger.debug(
    { appId: app.id, reason: asymResult.failureMessage },
    'Asymmetric JWT verification failed, falling back to anonymous'
  );

agents-api/src/middleware/runAuth.ts (lines 795-805)

} catch (err) {
  const errorType =
    err instanceof errors.JWTExpired
      ? 'expired'
      : err instanceof errors.JWSSignatureVerificationFailed
        ? 'signature_invalid'
        : 'unknown';
  logger.debug({ errorType, appId: appIdHeader }, 'Anonymous JWT verification failed');
  if (hasAuthConfigured) {
    throw createApiError({ code: 'unauthorized', message: 'Invalid or expired token' });
  }
⚠️ Anonymous global web_client session drops tenant/project scope
  • What failed: Anonymous chat for a global web_client app fails because auth context resolves to empty tenant/project values, instead of allowing non-user-scoped chat.
  • Impact: Anonymous chat is broken for global web_client apps using this path. Integrations that rely on anonymous access can fail immediately at request validation.
  • Introduced by this PR: No – pre-existing bug (code not changed in this PR)
  • Steps to reproduce:
    1. Use a global web_client app such as app_playground.
    2. Call POST /run/auth/apps/app_playground/anonymous-session from an allowed origin and capture the returned token.
    3. Call POST /run/api/chat with Authorization: Bearer <anon_token> and x-inkeep-app-id: app_playground.
    4. Observe failure caused by missing tenant/project context instead of successful anonymous chat.
  • Stub / mock context: No stubs, mocks, or bypasses were applied for this test in the recorded run.
  • Code analysis: I reviewed the anonymous-session token issuance route and app-credential auth middleware. The token is signed with tid/pid taken directly from the app record (which is null for global apps), and the downstream anonymous auth path does not recover tenant/project scope from token claims for global apps, producing empty context.
  • Why this is likely a bug: The code path for global apps can legitimately produce empty tenant/project execution context during anonymous auth, which directly explains the observed Missing tenantId runtime failure.

Relevant code:

agents-api/src/domains/run/routes/auth.ts (lines 230-235)

const token = await new SignJWT({
  tid: appRecord.tenantId,
  pid: appRecord.projectId,
  app: appId,
  type: 'anonymous',
})

agents-api/src/middleware/runAuth.ts (lines 782-787)

const { payload } = await jwtVerify(bearerToken, secret, { issuer: 'inkeep' });

if (payload.app !== appIdHeader) {
  if (hasAuthConfigured) {
    throw createApiError({ code: 'unauthorized', message: 'Invalid or expired token' });
  }

agents-api/src/middleware/runAuth.ts (lines 825-829)

return {
  authResult: {
    apiKey: bearerToken || appIdHeader,
    tenantId: app.tenantId || reqData.tenantId || '',
    projectId: app.projectId || reqData.projectId || '',
✅ Passed (7)
Category Summary Screenshot
Adversarial Using a valid token with mismatched x-inkeep-app-id returned a rejection (404 not_found), while the same token with matching app id reached a different processing branch (400 PoW required), demonstrating mismatch rejection without partial execution. ADV-2
Adversarial Prompt-injection request returned a generic failure response in chat output and did not expose bearer/app/credential metadata. ADV-5
Edge No defect found: no-endUserId path correctly avoids fabricating initiatedBy/userId context. EDGE-2
Edge Browser-executed POST to /run/api/chat with TOKEN_NO_TID_PID returned 401 unauthorized and no stream/tool execution. EDGE-3
Edge Browser-executed POST to /run/api/chat with denied-user token returned 403 forbidden, confirming authz block before MCP/tool run. EDGE-4
Edge Not a real application bug; prior blockage was environment/tooling related and credential scoping logic remains user-identity based. EDGE-5
Edge Rapid submit stress in the Try it panel stayed stable with no observed 401/no-cred regression during completed responses. EDGE-7

Commit: 25d1a56

View Full Run


Tell us how we did: Give Ito Feedback

tim-inkeep pushed a commit that referenced this pull request Mar 31, 2026
…CP credentials (#2912)

* fix: set initiatedBy for app credential auth to resolve user-scoped MCP credentials

The app credential auth paths (both authenticated and anonymous web_client) were
setting endUserId but not initiatedBy in the execution context metadata.
getUserIdFromContext checks initiatedBy first, so user-scoped MCP tools (like
Linear OAuth) couldn't resolve credentials — the userId was undefined, causing
the user-scoped credential lookup to be skipped entirely.

This is a follow-up to #2899 which fixed the playground auth path but missed the
app credential paths.

Made-with: Cursor

* chore: add changeset

Made-with: Cursor
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