feat(workflow-executor): add OAuth credential store + deposit endpoint (PRD-367 PR1)#1619
feat(workflow-executor): add OAuth credential store + deposit endpoint (PRD-367 PR1)#1619hercemer42 wants to merge 5 commits into
Conversation
1 new issue
|
|
Coverage Impact ⬇️ Merging this pull request will decrease total coverage on Modified Files with Diff Coverage (7)
🤖 Increase coverage with AI coding...🚦 See full report on Qlty Cloud » 🛟 Help
|
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>
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).
2d516a3 to
b20ff7c
Compare
Cover the buildDatabaseExecutor wiring of the credentials store and encryption into the HTTP server (and its absence in-memory), and pin the DELETE success contract to 204 with no body, replacing the todo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
|
||
| const body = (ctx.request.body ?? {}) as DepositCredentialsBody; | ||
|
|
||
| if (!body.mcpServerId || !body.refreshToken) { |
There was a problem hiding this comment.
Claude Fable 5 — [Violates conventions] The deposit body is a frontend HTTP body, and this package's CLAUDE.md (Boundary validation) requires those to be zod-validated with .strict() — as the sibling /trigger route does via pending-data-validators.ts. Here DepositCredentialsBody is a plain interface plus a truthy presence check, so: non-string values pass the guard (a numeric refreshToken reaches encryption.encrypt), an unparseable clientSecretExpiresAt persists as an Invalid Date instead of being rejected, over-length values for the bounded columns (2048/64/2048) aren't caught at the boundary, and unknown keys are silently accepted. Recommend a .strict() zod schema mirroring pending-data-validators.ts, returning 400 on parse failure. Not flagged by the linter — note for the team: this convention isn't lintable today.
There was a problem hiding this comment.
Claude Fable 5 — Agreed and fixed in 564bb77. Added http/mcp-oauth-credentials-validators.ts with a .strict() zod schema mirroring the pending-data-validators.ts sibling: required mcpServerId/refreshToken, column-bound lengths (255/2048/64) on the VARCHAR-backed fields, and a parseable-date check on clientSecretExpiresAt; the handler now safeParses and returns 400 with the issue summary. Two deliberate scope choices: refreshToken/clientSecret stay unbounded (BLOB columns, and the body-size question was already adjudicated on the earlier thread — bodyParser caps the payload), and .strict() upgraded the 'userId in body' behavior from ignore-and-override to outright 400, with the test updated to assert the stricter contract.
The deposit body is a frontend HTTP body, which the package's boundary- validation convention requires to be zod-validated with .strict() like the /trigger pending-data payloads. Rejecting unknown keys also makes the token-only user identity explicit, and the column-bound fields now fail with a 400 at the boundary instead of a dialect-dependent insert error. Adds the missing DELETE 400 coverage for tokens without a numeric id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| const bearerUserId = this.getBearerUserId(ctx); | ||
|
|
||
| if (!Number.isFinite(bearerUserId)) { | ||
| if (bearerUserId === null) { |
There was a problem hiding this comment.
Why not just if (!bearerUserId) - if for whatever strange reason it's undefined or 0 it's also a problem ?
There was a problem hiding this comment.
Claude Opus 4.8 — Good call on 0. Handled centrally in the shared requireBearerUserId helper rather than !bearerUserId at each site: it now rejects non-integers, 0, and negatives (!Number.isInteger(userId) || userId <= 0) since a Forest user id is always a positive integer. Call sites keep the explicit === null sentinel. 4ca006e.
| ): Promise<void> { | ||
| const userId = this.getBearerUserId(ctx); | ||
|
|
||
| if (userId === null) { |
There was a problem hiding this comment.
Likewise, if (!userId) ?
There was a problem hiding this comment.
Claude Opus 4.8 — Same fix as the trigger guard: the 0/negative/non-integer check lives once in requireBearerUserId, so all three handlers share one validity rule. 4ca006e.
| ctx.body = { error: 'Missing or invalid user id in token' }; | ||
|
|
||
| return; | ||
| } |
There was a problem hiding this comment.
Minor duplication with handletrigger, could move this to a function - debatable if worth it for the size
There was a problem hiding this comment.
Claude Opus 4.8 — Agreed, extracted. The three handlers now call requireBearerUserId(ctx), which writes the 400 and returns null (callers just if (id === null) return). Removed the repeated block from handleTrigger, handleDepositCredentials, and handleDeleteCredentials. 4ca006e.
|
|
||
| try { | ||
| const refreshToken = encryption.encrypt(body.refreshToken); | ||
| const clientSecret = body.clientSecret ? encryption.encrypt(body.clientSecret) : null; |
There was a problem hiding this comment.
Why the ternary ? Won't it just return a null value when trying to encrypt a null value ?
There was a problem hiding this comment.
Claude Opus 4.8 — Pushing back: the ternary is necessary. encrypt(plaintext: string) has no null pass-through — it calls cipher.update(plaintext, 'utf8'), so encrypt(undefined) throws rather than returning null. clientSecret is optional (public / PKCE clients have none), so we encrypt only when present and store null otherwise. The logic moved into buildMcpOAuthCredentialInput per your other comment, but the guard itself is unchanged and intentional.
| // rejects unknown keys outright (including any attempt to smuggle a user id; the JWT is the only | ||
| // identity source). Length bounds mirror the column limits so oversized input fails here with a | ||
| // 400 instead of a dialect-dependent insert error. The refresh token and client secret are | ||
| // unbounded: they land in BLOB columns and the body parser already caps the payload size. |
There was a problem hiding this comment.
Shorten comment if possible
There was a problem hiding this comment.
Claude Opus 4.8 — Shortened from 5 lines to 3, keeping the three rationales (.strict()/unknown-keys, lengths-mirror-columns, BLOB-fields-unbounded). 4ca006e.
| .string() | ||
| .refine(value => !Number.isNaN(Date.parse(value)), { message: 'must be a parseable date' }) | ||
| .optional(), | ||
| tokenEndpoint: z.string().max(2048).optional(), |
There was a problem hiding this comment.
tokenEndpoint should be required, better to give the front a specific error if it's not provided
There was a problem hiding this comment.
Claude Opus 4.8 — Done. tokenEndpoint is now required in the schema (z.string().min(1).max(2048)), so a missing one returns a specific 400 at deposit rather than failing once PR2 reads the row. Matched with NOT NULL on the column + non-null store types (your two comments below). Left clientId / clientSecret / authMethod / scopes optional per our discussion — tokenEndpoint is the only one that's unrecoverable when absent. Added a route test (400 when tokenEndpoint missing) and a mapper unit test. 4ca006e.
| client_id: string | null; | ||
| client_secret_enc: Buffer | null; | ||
| client_secret_expires_at: string | Date | null; | ||
| token_endpoint: string | null; |
There was a problem hiding this comment.
Claude Opus 4.8 — Done: token_endpoint: string in CredentialRow and tokenEndpoint: string in McpOAuthCredentialInput; toCredential no longer ?? nulls it. 4ca006e.
| }, | ||
| tokenEndpoint: { | ||
| type: DataTypes.STRING(2048), | ||
| allowNull: true, |
There was a problem hiding this comment.
Claude Opus 4.8 — Done: allowNull: false on the column in migration 002. Since PR1 is unmerged and 002 has never run anywhere, I edited the column in place rather than stacking a follow-up migration — no real rows to migrate. Added a store test asserting the DB rejects a null token_endpoint, and fixed the UNIQUE-constraint test's direct INSERT to include token_endpoint (otherwise it would have started passing on the NOT NULL violation instead of the UNIQUE one — a silent false pass). 4ca006e.
…oint Apply review feedback on PR1: - require tokenEndpoint (zod schema + NOT NULL column): the refresh grant has nowhere to go without it, so a missing one 400s at deposit rather than failing once the runtime read path lands - extract the body-to-record mapping into buildMcpOAuthCredentialInput and rename mcp-oauth-credentials-validators.ts to mcp-oauth-credentials.ts; the mapping is credential-domain logic, not transport - dedupe the bearer-user-id guard into requireBearerUserId, which also rejects non-positive / non-integer ids before they reach the store - shorten the new HKDF/boundary comments Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
4ca006e to
e1838a9
Compare
| // Translates a validated deposit body into the at-rest record: encrypts the refresh token (and | ||
| // client secret when present) and maps optional fields to their nullable columns. Lives outside the | ||
| // HTTP layer because it is credential-domain logic, not transport. encrypt() throws | ||
| // ExecutorEncryptionKeyMissingError when the key is unset; the caller maps that to a 503. |
There was a problem hiding this comment.
Tighten this comment, we don't need to explain why we moved it. The fact that it is moved is enough.

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 b20ff7c. 59 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted
🗂️ Filtered Issues
No issues evaluated.