feat(workflow-executor): add OAuth credential store + deposit endpoint (PRD-367 PR1)#1619
Conversation
Add the ai_mcp_oauth_credentials table (002 migration) + store, an HKDF/AES-GCM credential encryption helper read lazily from FOREST_EXECUTOR_ENCRYPTION_KEY, and a koaJwt-authed POST/DELETE /mcp-oauth-credentials endpoint. Additive and dormant: nothing reads the table or calls the endpoint yet. Refs PRD-367 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2 new issues
|
|
Coverage Impact Unable to calculate total coverage change because base branch coverage was not found. Modified Files with Diff Coverage (6)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
| } | ||
| } | ||
|
|
||
| async get(userId: number, mcpServerId: string): Promise<StoredMcpOAuthCredential | null> { |
There was a problem hiding this comment.
claude-opus-4-8 — [Should fix] get/upsert/delete let raw Sequelize errors propagate, unlike the sibling DatabaseStore which routes every method through callPort → RunStorePortError. Consider matching that pattern (or noting it is intentional) so infra failures surface as typed errors instead of reaching the HTTP layer as generic 500s.
There was a problem hiding this comment.
claude-opus-4-8 — Pushing back; leaving this deliberate. RunStorePortError is semantically the RunStore port error (its userMessage is about step state), so reusing it here would be wrong, and adding a bespoke port-error class is scope this dormant PR1 does not need. Unexpected DB failures surface correctly as 500 at the HTTP error middleware. Happy to revisit with a typed error contract when the runtime read path lands.
|
|
||
| // hkdfSync returns an ArrayBuffer; wrap it as a concrete Uint8Array<ArrayBuffer> so it | ||
| // satisfies the crypto CipherKey type (Buffer's generic ArrayBufferLike backing does not). | ||
| return new Uint8Array(hkdfSync(HKDF_DIGEST, secret, new Uint8Array(0), HKDF_INFO, KEY_BYTES)); |
There was a problem hiding this comment.
claude-opus-4-8 — [Preferential] HKDF uses an empty salt. This is acceptable (the fixed info label provides domain separation and the input is a high-entropy secret), but a one-line comment noting the empty salt is intentional would preempt the security-review question.
There was a problem hiding this comment.
claude-opus-4-8 — Agreed. Added a comment noting the empty salt is intentional (domain separation comes from the fixed info label; the input is a single high-entropy secret). Pending in the next push.
| } | ||
|
|
||
| try { | ||
| const refreshToken = encryption.encrypt(body.refreshToken); |
There was a problem hiding this comment.
claude-opus-4-8 — [Preferential] No explicit upper bound on refreshToken/clientSecret length before encryption. bodyParser caps the JSON body (~1mb) and the columns are BLOB, so it is bounded in practice; an explicit length guard would be defensive on this authenticated external surface.
There was a problem hiding this comment.
claude-opus-4-8 — Pushing back; low probability and already bounded: bodyParser caps the JSON body (~1mb) and these are BLOB columns. An explicit length guard is validation surface the spec does not call for, so declining for now.
| } | ||
|
|
||
| await store.delete(userId, ctx.params.mcpServerId); | ||
| ctx.status = 204; |
There was a problem hiding this comment.
claude-opus-4-8 — [Preferential] DELETE returns 204 no-body while POST returns 200 { stored: true }. 204 is correct REST, but since the frontend will consume this, confirm it expects a no-body delete rather than a JSON envelope (the success-body convention is currently an it.todo in the route test).
There was a problem hiding this comment.
claude-opus-4-8 — Intentional: 204 No Content is correct REST for a delete. The frontend consumer is a later PR and will be written against this endpoint, so there is no existing contract to break; the success-body shape stays an it.todo until that PR pins it.
Pre-empt the security-review question raised in code review: the empty salt is deliberate (domain separation comes from the fixed HKDF info label, input is a single high-entropy secret).

What
Executor-side persistence + intake for OAuth-protected MCP credentials (PRD-367, PR1 of the story):
ai_mcp_oauth_credentialstable via a new Umzug migration (002) +McpOAuthCredentialsStoreCredentialEncryption: HKDF (read lazily fromFOREST_EXECUTOR_ENCRYPTION_KEY, fixed context label) + AES-256-GCM, fail-closedPOST/DELETE /mcp-oauth-credentialson the existingkoaJwt-authed HTTP server (user_idfrom the token), encrypts + upserts; returns a typedexecutor_encryption_key_missing(503) when the key is unsetNotes
enc_key_versionis persisted per row for the deferred key-rotation work;decryptis single-generation for now.Tests
CredentialEncryption: round-trip, AES-GCM tamper/cross-key fail-closed, lazy/missing-key.UNIQUE(user_id, mcp_server_id), nullable public-client fields, isolation, migration.user_id-from-token (never body), encrypt-before-persist, key-missing 503, body validation.Refs PRD-367
🤖 Generated with Claude Code
Note
Add OAuth credential store and deposit/delete endpoints to workflow-executor
McpOAuthCredentialsStorebacked by a newai_mcp_oauth_credentialstable (keyed byuser_id+mcp_server_id), with Umzug migration run at server startup.CredentialEncryptionusing AES-256-GCM with HKDF-derived keys from theFOREST_EXECUTOR_ENCRYPTION_KEYenv var to encrypt refresh tokens and client secrets at rest.POST /mcp-oauth-credentialsto validate, encrypt, and upsert credentials bound to the JWT user, andDELETE /mcp-oauth-credentials/:mcpServerIdto remove them.FOREST_EXECUTOR_ENCRYPTION_KEYis unset, deposit requests return 503 withexecutor_encryption_key_missing; the env var must be configured before deploying.Macroscope summarized bb1b281. (Automatic summaries will resume when PR exits draft mode or review begins).