Skip to content

feat(workflow-executor): add OAuth credential store + deposit endpoint (PRD-367 PR1)#1619

Open
hercemer42 wants to merge 8 commits into
mainfrom
feat/prd-367-pr1-executor-oauth-credentials
Open

feat(workflow-executor): add OAuth credential store + deposit endpoint (PRD-367 PR1)#1619
hercemer42 wants to merge 8 commits into
mainfrom
feat/prd-367-pr1-executor-oauth-credentials

Conversation

@hercemer42

@hercemer42 hercemer42 commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

What

Executor-side persistence + intake for OAuth-protected MCP credentials (PRD-367, PR1 of the story):

  • ai_mcp_oauth_credentials table via a new Umzug migration (002) + McpOAuthCredentialsStore
  • CredentialEncryption: HKDF (read lazily from FOREST_EXECUTOR_ENCRYPTION_KEY, fixed context label) + AES-256-GCM, fail-closed
  • POST/DELETE /mcp-oauth-credentials on the existing koaJwt-authed HTTP server (user_id from the token), encrypts + upserts; returns a typed executor_encryption_key_missing (503) when the key is unset

Notes

  • Additive and dormant — nothing reads the table or calls the endpoint yet, so it merges with no production impact.
  • The encryption key is read lazily; an executor without OAuth in use boots unaffected.
  • No enc_key_version column: managed key rotation isn't built, so decrypt is single-generation and rotation is a destructive re-consent — a key change makes existing rows undecryptable and they fall to the re-consent path (fail-closed, no silent breakage).

Tests

  • Unit — CredentialEncryption: round-trip, AES-GCM tamper/cross-key fail-closed, lazy/missing-key.
  • Store (sqlite) — upsert/get/delete, UNIQUE(user_id, mcp_server_id), nullable public-client fields, isolation, migration.
  • Route — JWT auth, user_id-from-token (never body), encrypt-before-persist, key-missing 503, body validation.

Refs PRD-367

🤖 Generated with Claude Code

Note

Add MCP OAuth credential store and deposit/delete endpoints to workflow executor

  • Adds POST /mcp-oauth-credentials and DELETE /mcp-oauth-credentials/:mcpServerId routes to ExecutorHttpServer, with JWT-authenticated user identity extraction, Zod body validation, and AES-256-GCM encryption before persistence.
  • Introduces CredentialEncryption (AES-256-GCM via HKDF-SHA256) keyed from FOREST_EXECUTOR_ENCRYPTION_KEY, and ExecutorEncryptionKeyMissingError thrown when the key is absent.
  • Adds McpOAuthCredentialsStore port with two implementations: InMemoryMcpOAuthCredentialsStore for dev/ephemeral use and DatabaseMcpOAuthCredentialsStore backed by a Sequelize migration creating ai_mcp_oauth_credentials with a unique index on (user_id, mcp_server_id).
  • Extracts shared migration helpers (resolveSchema, runMigrations, etc.) into schema-migrations.ts, refactoring DatabaseStore to use them.
  • ExecutorHttpServer.start() now initializes the credential store before binding; if startup fails, the runner is stopped before rethrowing.
  • Risk: ExecutorHttpServer constructor now requires mcpOAuthCredentialsStore and credentialEncryption — all call sites must be updated.

Changes since #1619 opened

  • Implemented OAuth2 access token management infrastructure including token caching, refresh, and credential persistence [0783047]
  • Integrated OAuth2 authentication into MCP tool loading and execution flow with automatic retry and re-authentication pause logic [0783047]
  • Added GET /list-mcp-tools endpoint returning tool metadata with OAuth re-authentication and encryption error handling [0783047]
  • Extended AI model loading to return per-server load failures alongside tools with auth and connection error classification [0783047]
  • Introduced AwaitingInputReason enum and extended McpStepOutcome schema to include awaitingInputReason field [0783047]
  • Added deleteStepExecution method to RunStore interface and implemented in both database and in-memory stores [0783047]
  • Added OAuth2 end-to-end testing documentation and comprehensive test coverage for all OAuth components [0783047]
  • Modified OAuthTokenService to prevent caching tokens obtained during evicted refresh operations and to purge expired entries [278b64b]
  • Fixed McpStepExecutor.invokeWithReauthRetry to clear re-auth pause state when token refresh fails with non-auth errors [278b64b]
  • Updated RemoteToolFetcher.fetch to inject Bearer token into all scoped configs sharing the same mcpServerId for OAuth2 servers [278b64b]
  • Enhanced depositCredentialsBodySchema validation to restrict authentication methods and enforce client credential relationships [278b64b]
  • Added defensive validation to RemoteToolFetcher constructor requiring oauthTokenService [278b64b]
  • Fixed runMigrations to check write sub-pool size for Postgres replication configurations [278b64b]
  • Fixed OAuth Basic Authorization credential encoding in buildRequest to comply with RFC 6749 by introducing formUrlEncode utility function and replacing encodeURIComponent with form-url-encoding for clientId and clientSecret before base64-encoding, ensuring spaces become '+' instead of '%20' [a955626]
  • Reworded comments and explanatory text across OAuth token management components [4f9e173]
  • Reworded comments in MCP step executor and remote tool fetcher components [4f9e173]
  • Updated test descriptions and inline comments for clarity across OAuth and MCP executor test suites [4f9e173]
  • Tightened clientId validation in depositCredentialsBodySchema to reject empty strings when provided, and added test coverage for this constraint [8fa438f]
  • Added loadFailed flag to RemoteToolFetcher.fetch and RemoteToolFetcher.fetchOAuthTools results to indicate partial or total tool load failures, propagated this flag through ExecutorHttpServer.handleListMcpTools to return 404 when no MCP server is configured and 503 when the server could not be reached [426dbb2]
  • Modified refreshAccessToken in workflow-executor to coerce numeric string expires_in values to numbers and treat invalid_client and unauthorized_client token endpoint errors as OAuthInvalidGrantError instead of OAuthRefreshError [426dbb2]
  • Updated McpStepExecutor.execute to call clearReauthPauseState before throwing McpToolNotFoundError when a refreshed tool matching the original target is not found in the retry-after-refresh path [426dbb2]
  • Modified isMcpAuthError in ai-proxy to only treat numeric codes as authoritative HTTP status codes when they fall within the 100-599 range, falling back to message-based detection for non-HTTP codes such as JSON-RPC codes [426dbb2]
  • Added test coverage in workflow-executor for assertSafeTokenEndpoint to reject integer-encoded cloud metadata IPs and alternate encodings of loopback IPs including integer, hex, octal, and short-form representations [426dbb2]

Macroscope summarized d3dbde6.

@linear-code

linear-code Bot commented Jun 2, 2026

Copy link
Copy Markdown

PRD-367

PRD-621

@qltysh

qltysh Bot commented Jun 2, 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 = 17): invokeWithReauthRetry 3

@qltysh

qltysh Bot commented Jun 2, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.08%.

Modified Files with Diff Coverage (29)

RatingFile% DiffUncovered Line #s
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/runner.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/step-executor-factory.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/build-workflow-executor.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/remote-tool-fetcher.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/stores/in-memory-store.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/http/executor-http-server.ts94.2%333, 337, 365
Coverage rating: B Coverage rating: B
packages/workflow-executor/src/adapters/server-ai-adapter.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/types/validated/step-outcome.ts100.0%
Coverage rating: A Coverage rating: A
packages/ai-proxy/src/mcp-client.ts100.0%
Coverage rating: C Coverage rating: C
packages/workflow-executor/src/adapters/ai-client-adapter.ts100.0%
Coverage rating: A Coverage rating: A
packages/ai-proxy/src/ai-client.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/errors.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/index.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/executors/mcp-step-executor.ts100.0%
Coverage rating: A Coverage rating: A
...ow-executor/src/adapters/step-outcome-to-update-step-mapper.ts100.0%
Coverage rating: A Coverage rating: A
...s/workflow-executor/src/adapters/always-error-ai-model-port.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/defaults.ts100.0%
Coverage rating: A Coverage rating: A
packages/workflow-executor/src/stores/database-store.ts100.0%
Coverage rating: A Coverage rating: A
packages/ai-proxy/src/index.ts100.0%
New Coverage rating: A
...ow-executor/src/stores/database-mcp-oauth-credentials-store.ts97.0%133
New Coverage rating: A
packages/workflow-executor/src/oauth/refresh-grant.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/oauth/token-service.ts98.5%171
New Coverage rating: A
packages/workflow-executor/src/stores/schema-migrations.ts100.0%
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: A
...w-executor/src/stores/in-memory-mcp-oauth-credentials-store.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/oauth/keyed-mutex.ts100.0%
New Coverage rating: A
packages/workflow-executor/src/crypto/credential-encryption.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 `feat/prd-367-pr1-executor-oauth-credentials` 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` -- Lines 333, 337, and 365
- `packages/workflow-executor/src/oauth/token-service.ts` -- Line 171
- `packages/workflow-executor/src/stores/database-mcp-oauth-credentials-store.ts` -- Line 133

🚦 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.

Comment thread packages/workflow-executor/src/crypto/credential-encryption.ts
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from 2d516a3 to b20ff7c Compare June 11, 2026 10:00
@hercemer42 hercemer42 changed the base branch from feat/prd-214-server-step-mapper to main June 11, 2026 10:00
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials-validators.ts Outdated
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials-validators.ts Outdated
Comment thread packages/workflow-executor/src/stores/mcp-oauth-credentials-store.ts Outdated
Comment thread packages/workflow-executor/src/stores/mcp-oauth-credentials-store.ts Outdated
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from 4ca006e to e1838a9 Compare June 11, 2026 14:49
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials.ts
@hercemer42 hercemer42 closed this Jun 15, 2026
@hercemer42 hercemer42 reopened this Jun 15, 2026
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch 2 times, most recently from 44ec986 to 6b7fb91 Compare June 15, 2026 13:34
Comment thread packages/workflow-executor/example/.env.example Outdated
@hercemer42 hercemer42 marked this pull request as ready for review June 15, 2026 14:38
Comment thread packages/workflow-executor/README.md Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from d42828e to 30b063c Compare June 23, 2026 09:49
hercemer42 added a commit that referenced this pull request Jun 23, 2026
…version [PRD-621]

Two PRD-621 changes folded into #1619 (still unmerged):

- forest-schema + advisory-lock: extract the run-store's schema/lock logic into a shared
  schema-migrations module; both DatabaseStore and DatabaseMcpOAuthCredentialsStore now create
  their table + SequelizeMeta under the `forest` schema and run migrations behind one advisory
  lock, so the OAuth store no longer leaks into `public` (shared-DB safety). Verified against
  real Postgres (both *.pg.test.ts).
- drop enc_key_version: managed key rotation is dropped (rotation is a hard swap + re-consent), so
  the per-row column was written-but-never-read dead weight. Removed from migration 002, the
  model/types, encrypt()'s return, and the store; the unset-vs-bad-key distinction still comes from
  ExecutorEncryptionKeyMissingError vs a decrypt throw, not a version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials.ts Outdated
Comment thread packages/workflow-executor/src/stores/schema-migrations.ts
hercemer42 added a commit that referenced this pull request Jun 24, 2026
…version [PRD-621]

Two PRD-621 changes folded into #1619 (still unmerged):

- forest-schema + advisory-lock: extract the run-store's schema/lock logic into a shared
  schema-migrations module; both DatabaseStore and DatabaseMcpOAuthCredentialsStore now create
  their table + SequelizeMeta under the `forest` schema and run migrations behind one advisory
  lock, so the OAuth store no longer leaks into `public` (shared-DB safety). Verified against
  real Postgres (both *.pg.test.ts).
- drop enc_key_version: managed key rotation is dropped (rotation is a hard swap + re-consent), so
  the per-row column was written-but-never-read dead weight. Removed from migration 002, the
  model/types, encrypt()'s return, and the store; the unset-vs-bad-key distinction still comes from
  ExecutorEncryptionKeyMissingError vs a decrypt throw, not a version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from e121a1a to 270dbcc Compare June 24, 2026 08:40
hercemer42 added a commit that referenced this pull request Jun 25, 2026
…version [PRD-621]

Two PRD-621 changes folded into #1619 (still unmerged):

- forest-schema + advisory-lock: extract the run-store's schema/lock logic into a shared
  schema-migrations module; both DatabaseStore and DatabaseMcpOAuthCredentialsStore now create
  their table + SequelizeMeta under the `forest` schema and run migrations behind one advisory
  lock, so the OAuth store no longer leaks into `public` (shared-DB safety). Verified against
  real Postgres (both *.pg.test.ts).
- drop enc_key_version: managed key rotation is dropped (rotation is a hard swap + re-consent), so
  the per-row column was written-but-never-read dead weight. Removed from migration 002, the
  model/types, encrypt()'s return, and the store; the unset-vs-bad-key distinction still comes from
  ExecutorEncryptionKeyMissingError vs a decrypt throw, not a version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from 270dbcc to 2974d29 Compare June 25, 2026 08:42
Comment thread packages/workflow-executor/src/build-workflow-executor.ts
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from 2974d29 to ace5602 Compare June 29, 2026 15:27
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials.ts
hercemer42 added a commit that referenced this pull request Jun 29, 2026
…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>

@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.

Review of the OAuth credential store + deposit endpoint (PRD-367 PR1). No blocking issues; a few non-blocking hardening/consistency points on the encryption key and the upsert path.

Comment thread packages/workflow-executor/src/crypto/credential-encryption.ts
hercemer42 and others added 2 commits July 6, 2026 10:12
…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>
Comment thread packages/workflow-executor/src/runner.ts
Comment thread packages/workflow-executor/src/executors/mcp-step-executor.ts
Comment thread packages/workflow-executor/src/executors/mcp-step-executor.ts
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials.ts
…PRD-367] (#1665)

* feat(workflow-executor): use stored OAuth credentials for MCP steps

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>

* fix(workflow-executor): write rotated refresh token to the current row

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>

* fix(ai-proxy): treat only 401 (not 403) as a refreshable MCP auth error

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>

* feat(workflow-executor): support OAuth MCP steps in the in-memory executor [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>

* fix(workflow-executor): repoint token service at the relocated credentials 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>

* feat(workflow-executor): re-consent on decrypt failure after key rotation [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>

* feat(workflow-executor): add GET /list-mcp-tools design-time tool listing [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>

* fix(workflow-executor): guard against non-object token-endpoint responses [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>

* fix(workflow-executor): evict cached access token on credential disconnect [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>

* fix(workflow-executor): address PR review on oauth2 runtime [PRD-624]

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

* docs(workflow-executor): correct write-back-failure comment [PRD-624]

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>

* fix(workflow-executor): validate OAuth token endpoint against SSRF [PRD-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>

* fix(workflow-executor): block IPv4-mapped IPv6 in token-endpoint guard [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>

* docs(workflow-executor): use US spelling "defense" in refresh-grant comment [PRD-624]

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(workflow-executor): rewrite token-endpoint guard comment [PRD-624]

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>

* fix(workflow-executor): block the unspecified address in token-endpoint 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>

* fix(workflow-executor): reject localhost FQDN/.localhost aliases in token 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>

* test(workflow-executor): cover trailing-dot IP forms in token-endpoint 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>

* fix(workflow-executor): address PR review — SSRF redirect, deposit evict, fail-closed env [PRD-624]

- refresh-grant: reject redirects on the token POST (redirect:'manual' + any 3xx →
  OAuthRefreshError) so a validated token endpoint can't 3xx the grant body (refresh
  token / client secret) to an internal host, bypassing the endpoint validation.
- executor-http-server: evict the cached access token on deposit too, so a reconnect /
  re-deposit takes effect immediately instead of serving the prior token until expiry
  (mirrors the delete path).
- token-endpoint-url: fail closed — the strict https/no-loopback rules now apply unless
  NODE_ENV explicitly opts into the dev/test relaxation, so an unset or misspelled
  NODE_ENV can no longer silently allow loopback targets or cleartext http.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(workflow-executor): harden OAuth reauth and token write-back [PRD-692] (#1724)

* fix(workflow-executor): harden OAuth reauth and token write-back

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>

* test(workflow-executor): strengthen reauth-pause audit assertion

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>

* fix(workflow-executor): make OAuth refresh write-back atomic

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>

* fix(workflow-executor): audit a re-auth-paused MCP call as failed

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>

* fix(workflow-executor): correct reauth-pause cleanup and write-back scope

Addresses review findings on the re-auth pause and refresh write-back:
- preserve a confirmation-flow step's approved pendingData on a re-auth
  pause (clear only the marker) so resume replays that exact call;
  delete only when there is no pendingData (FullyAutomated), which would
  otherwise mis-route the resumed step into the confirmation flow
- make the pause cleanup best-effort so a store error still returns
  awaiting-input instead of a hard failure
- key updateIfPresent on the row id so a disconnect + re-authorize is not
  clobbered by a stale in-flight write-back (a re-created row has a new id)

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

* style(workflow-executor): trim added comments to the 2-line convention

Keep the non-obvious why (preserve-vs-delete on re-auth pause, id-scoped write-back); drop what the method names and code already state.

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

* fix(workflow-executor): propagate reauth-cleanup store errors

Swallowing a cleanup failure left the 'executing' marker in place, so the pause returned awaiting-input but could never resume (checkIdempotency rejects the stale marker). Let the store error propagate to an ordinary step error instead — consistent with the executor's other store-failure paths. Supersedes the earlier best-effort try/catch.

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

* docs(workflow-executor): link the OAuth2-MCP test server

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hercemer42 hercemer42 force-pushed the feat/prd-367-pr1-executor-oauth-credentials branch from 0783047 to d3dbde6 Compare July 6, 2026 09:52
Comment thread packages/workflow-executor/src/stores/schema-migrations.ts Outdated
Comment thread packages/workflow-executor/src/remote-tool-fetcher.ts
Comment thread packages/workflow-executor/src/oauth/refresh-grant.ts Outdated
Comment thread packages/workflow-executor/src/oauth/token-service.ts
Comment thread packages/workflow-executor/src/oauth/token-service.ts
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials.ts Outdated
Comment thread packages/workflow-executor/src/executors/mcp-step-executor.ts
hercemer42 and others added 3 commits July 6, 2026 13:51
…time [PRD-367]

- reject clientSecret without clientId and restrict tokenEndpointAuthMethod to
  the methods the refresh grant implements
- clear the write-ahead marker on a non-auth refresh failure so a transient
  error can't wedge the step
- guard the token-service cache against unbounded growth and against a
  mid-refresh disconnect repopulating it
- inject the bearer token for every same-id scoped MCP config, not just the first
- size the migration pool check against the write sub-pool under replication
- fail loudly when RemoteToolFetcher is constructed without a token service

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

Encode the client id/secret with application/x-www-form-urlencoded (space -> '+')
before base64, per RFC 6749 §2.3.1. Identical output to the previous encoding for
the base64url/hex credentials providers actually issue; correct for the rare
value containing a space.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Comment thread packages/workflow-executor/src/http/mcp-oauth-credentials.ts Outdated

@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.

Final pass over the OAuth stack (arch/security/bugs via Fable agents, tests/comments/types via Opus, each finding double-checked against the code). Security is sound — no SSRF bypass, crypto/authz/rotation correct. One blocking-class bug (marker strand on the reload-tool-missing path — recommend fixing before merge), four robustness issues, one test-gap. Not re-posting the concurrent-deposit unique-violation (overlaps the already-resolved raw-Sequelize-error thread on the store).

Comment thread packages/workflow-executor/src/executors/mcp-step-executor.ts Outdated
Comment thread packages/workflow-executor/src/http/executor-http-server.ts Outdated
Comment thread packages/ai-proxy/src/mcp-auth-error.ts Outdated
Comment thread packages/workflow-executor/src/oauth/refresh-grant.ts
Comment thread packages/workflow-executor/src/oauth/refresh-grant.ts Outdated
Comment thread packages/workflow-executor/test/oauth/token-endpoint-url.test.ts
hercemer42 and others added 2 commits July 6, 2026 17:23
…367]

An empty clientId passed validation and was stored, then dropped as falsy at
refresh time, silently breaking authentication.

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

- clear the write-ahead marker when the auth-retry reload yields no tool (was wedging)
- classify a JSON-RPC McpError by message, not its non-HTTP error code
- route invalid_client / unauthorized_client to re-consent, not a doomed retry
- coerce a numeric-string expires_in so the token is still cached
- error (not 200 + empty list) from list-mcp-tools for an unreachable/misrouted server

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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