Skip to content

docs(workflow-executor): document FOREST_EXECUTOR_ENCRYPTION_KEY#1725

Open
hercemer42 wants to merge 25 commits into
feat/prd-367-pr1-executor-oauth-credentialsfrom
docs/prd-367-pr4-executor-encryption-key
Open

docs(workflow-executor): document FOREST_EXECUTOR_ENCRYPTION_KEY#1725
hercemer42 wants to merge 25 commits into
feat/prd-367-pr1-executor-oauth-credentialsfrom
docs/prd-367-pr4-executor-encryption-key

Conversation

@hercemer42

@hercemer42 hercemer42 commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

What

Documents FOREST_EXECUTOR_ENCRYPTION_KEY in the workflow-executor README — a new "OAuth-protected MCP connectors" section covering:

  • what it encrypts (the OAuth credentials the executor stores, AES-256-GCM at rest)
  • how to generate it (openssl rand -hex 32) and to keep it separate from FOREST_AUTH_SECRET
  • using the same value across instances that share a database
  • its lazy behaviour (while unset, credential storage returns 503 and those connectors stay unavailable; the executor still boots, with a startup warning)
  • no managed rotation — changing it forces affected users to reconnect

It complements the one-line entry already in .env.example.

Why

PRD-626 (the docs half of PRD-367): operators deploying the executor with OAuth-protected MCP connectors need a README reference for this variable.

Base / merge order

Based on the PRD-692 branch (feature/prd-692-harden-executor-oauth-runtime-clear-idempotency-phase-on, #1724) — the top of the OAuth-MCP executor stack, where FOREST_EXECUTOR_ENCRYPTION_KEY and the code that reads it already live. Targeting that branch keeps this diff to just the README change. Intended to merge last, once PR1 (#1619), PR2 (#1665) and PRD-692 (#1724) are in.

Replaces docs-site PR ForestAdmin/docs#3 (now closed) — the executor's own README, next to the code and .env.example, is the right home for an operator-facing deployment variable (and "executor" isn't a concept on the public docs site).

Refs: PRD-626

🤖 Generated with Claude Code

Note

Add OAuth2 token refresh and MCP tool re-authentication to the workflow executor

  • Introduces OAuthTokenService to acquire, cache, and refresh OAuth2 access tokens per user and MCP server, using a per-key mutex to serialize concurrent refreshes and proactively refreshing before expiry.
  • RemoteToolFetcher now injects Bearer tokens into MCP configs for OAuth2-backed servers and retries once with a forced refresh on 401-like failures; persistent auth failures throw OAuthReauthRequiredError.
  • McpStepExecutor intercepts auth errors during tool invocation, retries via a reloadWithFreshAuth callback, and converts a second failure to an awaiting-input outcome with awaitingInputReason: 'needs-oauth-reauth'.
  • Adds a GET /list-mcp-tools HTTP endpoint that fetches available tools for the authenticated user's stored credential, returning HTTP 409 on re-auth required and 503 on missing encryption key.
  • Adds SSRF protection via assertSafeTokenEndpoint, which rejects link-local, metadata, unspecified, and (in production) loopback token endpoint URLs.
  • Documents FOREST_EXECUTOR_ENCRYPTION_KEY (AES-256-GCM) in README.md, covering generation, deployment, and rotation guidance.
  • Risk: Runner and ExecutorHttpServer constructors now require additional OAuth service dependencies; existing instantiation without them will fail to compile.
📊 Macroscope summarized e3db719. 9 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

hercemer42 and others added 24 commits June 29, 2026 17:21
…ete endpoint [PRD-367]

PR1 of PRD-367 (PRD-621): executor-side OAuth credential persistence + intake, additive and dormant.

- ai_mcp_oauth_credentials table via Umzug migration 002 (own runner, shared SequelizeMeta) under the forest schema + transaction-scoped advisory lock, shared with the run store via schema-migrations.ts
- at-rest encryption helper: HKDF (FOREST_EXECUTOR_ENCRYPTION_KEY) + AES-256-GCM, read lazily, fail-closed, no default key; no per-row key version (rotation is a hard swap)
- POST/DELETE /mcp-oauth-credentials on the koaJwt-authed HTTP server, user_id from the token, .strict() zod body validation, typed executor_encryption_key_missing 503
- boot WARN when the key is unset

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…et [PRD-621]

Addresses Macroscope review findings on #1619:
- pass the configured schema to DatabaseMcpOAuthCredentialsStore so it shares the run store's schema instead of always falling back to `forest`
- make executor start() all-or-nothing: stop the runner if server.start() (which runs migration 002) fails, rather than leaving it polling
- reject an empty-string clientSecret (.min(1)) so a confidential client isn't silently persisted as a public one

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
At an oauth2 MCP step the executor looks up the stored credential by (user, server), runs the refresh-token grant against the stored token endpoint behind an expiry-skew cache, injects the bearer token before connecting, retries once on a 401 across list-tools and the tool call, and pauses for re-authentication when no usable credential exists or the refresh is rejected. Bearer and none steps are unchanged. Adds additive auth-error classification to the shared ai-proxy McpClient consumed by this path. Behaviour stays dormant until the orchestrator serves authType and the frontend ships (deploy orchestrator first), so it is safe to deploy alone. Depends on the PR1 credential store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the invalid_grant concurrent-rotation retry path the write-back used the pre-retry credential for the non-token fields; thread the credential whose token produced the grant through so a concurrent re-deposit is not partially reverted. Addresses review on #1665.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A 403 is a permission/scope failure that a token refresh or re-consent cannot resolve, so it no longer triggers the refresh + re-auth flow (which looped) and instead surfaces as an ordinary failure. The spec specifies retry on 401. Addresses review on #1665.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cutor [PRD-367]

PR1 wired an in-memory credential store + deposit endpoint into buildInMemoryExecutor, so the previous "in-memory raises ConfigurationError for oauth2 steps" behavior was inconsistent: a credential could be deposited but never used. Wire an OAuthTokenService into the in-memory runner (sharing the same store instance the deposit endpoint writes to) so oauth2 steps work end-to-end in dev, matching the database executor.

The token service is now a required RunnerConfig/RemoteToolFetcher collaborator (both executors provide it), so the unreachable ConfigurationError guard and its fetcher test are removed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tials port [PRD-367]

The PR1 rebase moved the credentials store interface + types from stores/mcp-oauth-credentials-store to ports/mcp-oauth-credentials-store (the store file now holds only the Database/InMemory implementations). Import McpOAuthCredentialsStore and StoredMcpOAuthCredential from the new port path so the package compiles against the rebased PR1 base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion [PRD-367]

PR1 dropped enc_key_version from the credential store (no version-aware decrypt path), so the rotation write-back no longer carries encKeyVersion. Per PRD-367 key-rotation handling, a decrypt failure with the encryption key PRESENT (auth-tag mismatch from a since-rotated/hard-swapped key) is recoverable: toGrantParams now classifies it as OAuthReauthRequiredError (needs-oauth-reauth) so re-consent re-deposits under the new key. A missing key (ExecutorEncryptionKeyMissingError) stays terminal — re-consent cannot help and a re-deposit would 503.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ting [PRD-367]

New GET /list-mcp-tools?mcpServerId= route on the executor HTTP server for the orchestrator-engine MCP-server details page: resolves the caller's vault credential (user_id from the validated JWT, never the request), refreshes, injects the Bearer, and returns the server's tool definitions — reusing RemoteToolFetcher, no new fetch/refresh logic. A missing/unrefreshable credential returns a typed needs-oauth-reauth (409), not a generic error or empty list. Wired into both the database and in-memory executors so oauth2 tool listing works in dev too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nses [PRD-367]

A literal JSON null (or other non-object) body from the token endpoint overwrote the {} parse default, so the subsequent payload.error / payload.access_token reads threw a TypeError instead of the typed OAuthRefreshError. Keep the {} default for non-object bodies so the status checks still surface a typed OAuthRefreshError.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nnect [PRD-367]

On DELETE /mcp-oauth-credentials/:mcpServerId, evict the in-process cached access
token for (user, server) so a disconnect takes effect immediately — otherwise the
executor keeps serving the cached token until it expires even though the credential
row is gone (surfaced by end-to-end testing).

Wires OAuthTokenService into both executor builders + the HTTP server (optional on
the options so credential-free tests need not construct one), and adds
OAuthTokenService.evict with coverage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- list-mcp-tools: map ExecutorEncryptionKeyMissingError to the typed 503
  { code } the deposit endpoint already returns, so the details page shows
  the admin message instead of a generic error.
- token-service: encrypt the rotated refresh token inside the best-effort
  write-back try, so an encrypt failure can't fail an otherwise-valid
  getAccessToken.
- ai-proxy isMcpAuthError: an explicit status is authoritative — a 403 is
  not refreshable even when its message says "unauthorized"; fall back to
  the message only when no status is present.
- Trim comments to why-not-how.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The re-read recovery path only handles a peer instance's rotation, not our
own failed write-back (the stored token is unchanged, so re-read sees no
rotation and forces re-auth). Describe the actual fallback: re-authentication.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…RD-624]

The token endpoint is where the executor POSTs the refresh grant (with
client credentials), but it was stored as an unconstrained string, so an
authenticated caller could aim the executor at an internal address (SSRF,
incl. cloud metadata).

Add assertSafeTokenEndpoint, enforced at deposit (400) and again before the
refresh POST (defence in depth for pre-existing rows):
- scheme must be http(s); https required in production (http stays allowed
  off-prod for the local OAuth sim);
- link-local / cloud-metadata (169.254.0.0/16, fe80::/10) blocked everywhere;
- loopback blocked in production;
- RFC1918 private ranges stay allowed — private OAuth providers are supported.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d [PRD-624]

The URL parser normalizes `::ffff:127.0.0.1` to the hex form `::ffff:7f00:1`,
which the IPv6 branch classified as neither loopback nor link-local — so a
mapped loopback/metadata address slipped past the SSRF block in production.
Extract the embedded IPv4 from `::ffff:` addresses and run it through the
IPv4 loopback/link-local checks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…omment [PRD-624]

Match the codebase convention (run-to-available-step-mapper.ts).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drop the local-test-tool and ticket references; explain the why (SSRF
rationale, the deliberate RFC1918 allowance, why no DNS resolution) rather
than enumerating the rules.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nt guard [PRD-624]

0.0.0.0 (and ::) connects to loopback on Linux, so it was an SSRF bypass the
guard accepted. Classify the unspecified range (0.0.0.0/8, ::) and reject it
on every environment — it is never a valid token endpoint.

Surfaced by evaluating ipaddr.js, which flags it as `unspecified`; the WHATWG
URL parser already normalizes the decimal/hex/short-form IP encodings to
dotted form, so those were already covered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…oken guard [PRD-624]

Only the bare `localhost` was treated as loopback, so `localhost.` and the
`.localhost` TLD (RFC 6761) — both resolving to loopback — slipped past the
guard in production. Match any host whose last label is `localhost` (with or
without a trailing dot), without over-blocking real domains like
auth.localhost.example.com.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…t guard [PRD-624]

Regression test proving `127.0.0.1.` / `169.254.169.254.` are rejected: the
guard runs `new URL()` first, which normalizes the trailing dot before
classification, so the trailing-dot form is not a bypass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three correctness fixes that gate the OAuth2 MCP executor flag:
- clear the step idempotency marker on a re-auth pause so a resumed step
  is no longer rejected as interrupted
- record a re-auth pause as a non-failure in the activity log instead of
  a spurious failure
- re-check credential existence before the rotated-token write-back so a
  concurrent disconnect is not silently resurrected

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Assert the activity entry is closed as succeeded on a re-auth pause, not
just that it was never failed. Drop the stale TDD/iteration framing and
ticket references from the re-auth pause test block.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the get-then-upsert existence re-check with an atomic
updateIfPresent (UPDATE ... WHERE) on the credentials store, closing the
read-to-write window where a concurrent disconnect could be resurrected.
upsert stays for the consent deposit path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revert the isNonFailure audit special-casing on the re-auth pause path.
An MCP tool call that 401s has genuinely failed, so it should surface as
a failed audit entry (useful for observability) rather than be recorded
as completed. The step still pauses (awaiting-input) and the resumed run
logs its own entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jun 30, 2026

Copy link
Copy Markdown

PRD-367

PRD-626

Document the FOREST_EXECUTOR_ENCRYPTION_KEY environment variable in the workflow-executor README: what it encrypts, how to generate it, using the same value across instances sharing a database, its lazy behaviour, and that it has no managed rotation. Complements the .env.example entry.

Refs: PRD-626
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hercemer42 hercemer42 force-pushed the docs/prd-367-pr4-executor-encryption-key branch from 19a12d3 to e3db719 Compare June 30, 2026 10:02
@qltysh

qltysh Bot commented Jun 30, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

Unable to calculate total coverage change because base branch coverage was not found.

Modified Files with Diff Coverage (26)

RatingFile% DiffUncovered Line #s
New Coverage rating: A
...ow-executor/src/stores/database-mcp-oauth-credentials-store.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/runner.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/executors/step-executor-factory.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/oauth/refresh-grant.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/build-workflow-executor.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/oauth/token-service.ts98.2%153
New Coverage rating: A
packages/workflow-executor/src/remote-tool-fetcher.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/stores/in-memory-store.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/http/executor-http-server.ts95.5%348
New Coverage rating: A
packages/workflow-executor/src/oauth/token-endpoint-url.ts100.0%
New Coverage rating: A
packages/ai-proxy/src/mcp-auth-error.ts92.3%25-27
New Coverage rating: B
packages/workflow-executor/src/adapters/server-ai-adapter.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/types/validated/step-outcome.ts100.0%
New Coverage rating: A
packages/ai-proxy/src/mcp-client.ts100.0%
New Coverage rating: A
...w-executor/src/stores/in-memory-mcp-oauth-credentials-store.ts100.0%
New Coverage rating: C
packages/workflow-executor/src/adapters/ai-client-adapter.ts100.0%
New Coverage rating: A
packages/ai-proxy/src/ai-client.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/oauth/keyed-mutex.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/executors/mcp-step-executor.ts100.0%
New Coverage rating: A
...ow-executor/src/adapters/step-outcome-to-update-step-mapper.ts100.0%
New Coverage rating: A
...s/workflow-executor/src/adapters/always-error-ai-model-port.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/defaults.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/stores/database-store.ts100.0%
New Coverage rating: A
packages/ai-proxy/src/index.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/http/mcp-oauth-credentials.ts100.0%
Total98.6%
🤖 Increase coverage with AI coding...
In the `docs/prd-367-pr4-executor-encryption-key` branch, add test coverage for this new code:

- `packages/ai-proxy/src/mcp-auth-error.ts` -- Line 25-27
- `packages/workflow-executor/src/http/executor-http-server.ts` -- Line 348
- `packages/workflow-executor/src/oauth/token-service.ts` -- Line 153

🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@Scra3 Scra3 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Docs review (PRD-367 PR4). The section is accurate against the implementation; one small suggestion.


| Variable | Description |
| --- | --- |
| `FOREST_EXECUTOR_ENCRYPTION_KEY` | At-rest encryption key (AES-256-GCM) for the OAuth credentials the executor stores. Generate with `openssl rand -hex 32`. Use a **separate** secret from `FOREST_AUTH_SECRET`. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

suggestion: unlike FOREST_ENV_SECRET, the key's length/format is not validated by the code — worth noting operators must use the exact openssl rand -hex 32 output, since a weak value is accepted silently.

Base automatically changed from feature/prd-692-harden-executor-oauth-runtime-clear-idempotency-phase-on to feat/prd-367-pr2-executor-oauth-runtime July 6, 2026 09:29
Base automatically changed from feat/prd-367-pr2-executor-oauth-runtime to feat/prd-367-pr1-executor-oauth-credentials July 6, 2026 09:30
@qltysh

qltysh Bot commented Jul 6, 2026

Copy link
Copy Markdown

12 new issues

Tool Category Rule Count
qlty Structure Function with many returns (count = 9): create 5
qlty Structure Function with many parameters (count = 4): constructor 4
qlty Structure Function with high complexity (count = 12): invokeWithReauthRetry 3

// An unrefreshable OAuth credential pauses the step for re-authentication rather than failing
// it. Clear the write-ahead marker so the resumed step is not rejected as interrupted.
if (error instanceof OAuthReauthRequiredError) {
await this.context.runStore.deleteStepExecution(this.context.runId, this.context.stepIndex);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium executors/mcp-step-executor.ts:94

doExecute deletes the entire persisted MCP execution when OAuthReauthRequiredError is caught. When the step is resumed, runStep() finds no pending data, so it falls back to selectTool() and re-runs the LLM tool selection. This discards the previously chosen (and possibly user-confirmed) tool call — in fully-automated mode the resumed step can execute a different tool or input, and in confirmation mode the user's confirmed McpToolCall is lost. Consider clearing only the idempotencyPhase marker (or setting it to a value that lets runStep() reuse the existing pendingData) instead of deleting the whole execution record.

Suggested change
await this.context.runStore.deleteStepExecution(this.context.runId, this.context.stepIndex);
await this.context.runStore.saveStepExecution(this.context.runId, {
...existingExecution,
type: 'mcp',
stepIndex: this.context.stepIndex,
idempotencyPhase: undefined,
});
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/executors/mcp-step-executor.ts around line 94:

`doExecute` deletes the entire persisted MCP execution when `OAuthReauthRequiredError` is caught. When the step is resumed, `runStep()` finds no pending data, so it falls back to `selectTool()` and re-runs the LLM tool selection. This discards the previously chosen (and possibly user-confirmed) tool call — in fully-automated mode the resumed step can execute a different tool or input, and in confirmation mode the user's confirmed `McpToolCall` is lost. Consider clearing only the `idempotencyPhase` marker (or setting it to a value that lets `runStep()` reuse the existing `pendingData`) instead of deleting the whole execution record.

});
}

async updateIfPresent(credential: McpOAuthCredentialInput): Promise<void> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium stores/database-mcp-oauth-credentials-store.ts:199

updateIfPresent overwrites every column from a stale snapshot, not just the rotated refresh_token_enc. If the user reconnects the same mcpServerId between the initial get() and this write-back, the new row still matches WHERE user_id = :userId AND mcp_server_id = :mcpServerId, so this statement overwrites the freshly re-authenticated client_id, client_secret_enc, token_endpoint, and scopes with old values, corrupting the new credential and breaking later refreshes. Consider scoping the update to only the rotated refresh token (e.g., SET refresh_token_enc = :refreshTokenEnc, updated_at = :now), or tracking the row id/version from the get() to avoid touching a re-created row.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/stores/database-mcp-oauth-credentials-store.ts around line 199:

`updateIfPresent` overwrites every column from a stale snapshot, not just the rotated `refresh_token_enc`. If the user reconnects the same `mcpServerId` between the initial `get()` and this write-back, the new row still matches `WHERE user_id = :userId AND mcp_server_id = :mcpServerId`, so this statement overwrites the freshly re-authenticated `client_id`, `client_secret_enc`, `token_endpoint`, and `scopes` with old values, corrupting the new credential and breaking later refreshes. Consider scoping the update to only the rotated refresh token (e.g., `SET refresh_token_enc = :refreshTokenEnc, updated_at = :now`), or tracking the row id/version from the `get()` to avoid touching a re-created row.

@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from 0783047 to d3dbde6 Compare July 6, 2026 09:52
return links;
}

export function isMcpAuthError(error: unknown): boolean {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium src/mcp-auth-error.ts:42

isMcpAuthError returns true for an error whose message matches /unauthorized/i even when a later link in the cause chain carries an explicit 403 status. For example, new Error('Unauthorized scope', { cause: Object.assign(new Error('forbidden'), { status: 403 }) }) is classified as refreshable auth, because .some() returns at the outer message match before the inner 403 is inspected. The comment states an explicit status is authoritative, but the implementation lets a message match short-circuit before any later status is checked. Consider scanning the full chain for an explicit non-refreshable status before falling back to message matching.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/ai-proxy/src/mcp-auth-error.ts around line 42:

`isMcpAuthError` returns `true` for an error whose message matches `/unauthorized/i` even when a later link in the cause chain carries an explicit `403` status. For example, `new Error('Unauthorized scope', { cause: Object.assign(new Error('forbidden'), { status: 403 }) })` is classified as refreshable auth, because `.some()` returns at the outer message match before the inner `403` is inspected. The comment states an explicit status is authoritative, but the implementation lets a message match short-circuit before any later status is checked. Consider scanning the full chain for an explicit non-refreshable status before falling back to message matching.

return entry.accessToken;
}

private async refreshAndCache(userId: number, mcpServerId: string, key: string): Promise<string> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium oauth/token-service.ts:95

When runGrantWithRotationRetry throws OAuthReauthRequiredError, the cache entry for that key is left in place. A subsequent getAccessToken(..., { forceRefresh: false }) call returns the stale access token from readCache until its local expiry, so requests keep using a token whose refresh already failed instead of surfacing the re-auth requirement. Consider evicting the cache entry before throwing in refreshAndCache.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/oauth/token-service.ts around line 95:

When `runGrantWithRotationRetry` throws `OAuthReauthRequiredError`, the cache entry for that key is left in place. A subsequent `getAccessToken(..., { forceRefresh: false })` call returns the stale access token from `readCache` until its local expiry, so requests keep using a token whose refresh already failed instead of surfacing the re-auth requirement. Consider evicting the cache entry before throwing in `refreshAndCache`.

Comment on lines +87 to +93
private readCache(key: string): string | undefined {
const entry = this.cache.get(key);
if (!entry || entry.expiresAtMs === undefined) return undefined;
if (this.now() >= entry.expiresAtMs - this.expirySkewMs) return undefined;

return entry.accessToken;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium oauth/token-service.ts:87

readCache() returns undefined for expired entries but never deletes them from this.cache, and evict() is only called on explicit disconnect. Expired entries for (userId, mcpServerId) pairs accumulate in this.cache indefinitely, so process memory grows over the lifetime of the executor. Consider deleting the entry in readCache() when it is expired.

Suggested change
private readCache(key: string): string | undefined {
const entry = this.cache.get(key);
if (!entry || entry.expiresAtMs === undefined) return undefined;
if (this.now() >= entry.expiresAtMs - this.expirySkewMs) return undefined;
return entry.accessToken;
}
private readCache(key: string): string | undefined {
const entry = this.cache.get(key);
if (!entry || entry.expiresAtMs === undefined) return undefined;
if (this.now() >= entry.expiresAtMs - this.expirySkewMs) {
this.cache.delete(key);
return undefined;
}
return entry.accessToken;
}
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/oauth/token-service.ts around lines 87-93:

`readCache()` returns `undefined` for expired entries but never deletes them from `this.cache`, and `evict()` is only called on explicit disconnect. Expired entries for `(userId, mcpServerId)` pairs accumulate in `this.cache` indefinitely, so process memory grows over the lifetime of the executor. Consider deleting the entry in `readCache()` when it is expired.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium

createWorkflowExecutor.start() calls runner.start() before server.start(), so the polling loop can invoke DatabaseMcpOAuthCredentialsStore.get() — which issues SELECT against ai_mcp_oauth_credentials — before ExecutorHttpServer.start() runs mcpOAuthCredentialsStore.init() to create the table. On startup with a pending OAuth-backed step, the first poll cycle fails with a missing-table error instead of executing the run. Consider awaiting the store migration before runner.start(), or calling init() explicitly in buildDatabaseExecutor prior to starting the runner.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/build-workflow-executor.ts around line 198:

`createWorkflowExecutor.start()` calls `runner.start()` before `server.start()`, so the polling loop can invoke `DatabaseMcpOAuthCredentialsStore.get()` — which issues `SELECT` against `ai_mcp_oauth_credentials` — before `ExecutorHttpServer.start()` runs `mcpOAuthCredentialsStore.init()` to create the table. On startup with a pending OAuth-backed step, the first poll cycle fails with a missing-table error instead of executing the run. Consider awaiting the store migration before `runner.start()`, or calling `init()` explicitly in `buildDatabaseExecutor` prior to starting the runner.

Comment on lines +50 to +52
const credentials = `${encodeURIComponent(clientId ?? '')}:${encodeURIComponent(
clientSecret,
)}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium oauth/refresh-grant.ts:50

buildRequest encodes clientId and clientSecret with encodeURIComponent before building the Basic auth header, which encodes spaces as %20. RFC 6749 requires the application/x-www-form-urlencoded algorithm for Basic auth credentials, which encodes spaces as +. Any client ID or secret containing a space sends malformed credentials and the refresh grant fails. Consider using URLSearchParams to encode each component instead.

Suggested change
const credentials = `${encodeURIComponent(clientId ?? '')}:${encodeURIComponent(
clientSecret,
)}`;
const credentials = `${new URLSearchParams('', { clientId: clientId ?? '' }).toString().slice('clientId='.length)}:${new URLSearchParams('', { clientSecret }).toString().slice('clientSecret='.length)}`;
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @packages/workflow-executor/src/oauth/refresh-grant.ts around lines 50-52:

`buildRequest` encodes `clientId` and `clientSecret` with `encodeURIComponent` before building the Basic auth header, which encodes spaces as `%20`. RFC 6749 requires the `application/x-www-form-urlencoded` algorithm for Basic auth credentials, which encodes spaces as `+`. Any client ID or secret containing a space sends malformed credentials and the refresh grant fails. Consider using `URLSearchParams` to encode each component instead.

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.

2 participants